签入版本
This commit is contained in:
@@ -1,77 +1,221 @@
|
||||
# 5.16 异常与响应处理
|
||||
# 5.16 统一响应与异常抛出
|
||||
|
||||
:::tip 概述
|
||||
ExceptionHandle 将所有异常统一渲染为 JSON 响应,保证前端始终拿到结构一致的错误信息。
|
||||
本页从**后端开发视角**讲解两件事:接口如何返回统一响应(`Result` 类 + Controller 快捷方法),业务异常如何抛出并被 `Handler` 统一渲染为 JSON。前端报文格式见 [8.1 统一响应格式](/zh/api-spec/response.html),规范约束见 [12.1 API 响应规范](/zh/standards/api-response.html)。
|
||||
:::
|
||||
|
||||
## 响应结构
|
||||
|
||||
所有接口(含成功与失败)返回统一 JSON 结构,HTTP 状态码默认 200,前端以 body 中的 `code` 判断结果:
|
||||
|
||||
```json
|
||||
// 成功
|
||||
{"code": 0, "ok": true, "msg": "操作成功", "data": {...}}
|
||||
|
||||
// 失败
|
||||
{"code": 1, "ok": false, "msg": "操作失败", "data": null}
|
||||
|
||||
// 分页
|
||||
{"code": 0, "ok": true, "msg": "操作成功",
|
||||
"data": {"records": [...], "total": 100, "current": 1, "size": 20, "pages": 5}}
|
||||
```
|
||||
|
||||
## 异常处理优先级
|
||||
## 统一响应类(Result)
|
||||
|
||||
`App\Support\Result` 是所有响应的构造入口——Controller 快捷方法与全局异常处理器都委托给它,保证全站结构一致。
|
||||
|
||||
### 业务码常量
|
||||
|
||||
| 常量 | 值 | 含义 | HTTP 状态码 |
|
||||
|------|----|------|-------------|
|
||||
| `Result::SUCCESS` | 0 | 成功 | 200 |
|
||||
| `Result::FAIL` | 1 | 一般失败(业务逻辑失败) | 200 |
|
||||
| `Result::UNAUTHORIZED` | 401 | 未授权(Token 缺失/无效/过期) | 200 |
|
||||
| `Result::FORBIDDEN` | 403 | 禁止访问(权限不足 / 租户异常 / 演示环境) | **403** |
|
||||
| `Result::NOT_FOUND` | 404 | 数据不存在 / 路由不存在 | 200 |
|
||||
| `Result::VALID_ERROR` | 422 | 参数验证错误 | 200 |
|
||||
|
||||
:::tip HTTP 状态码约定
|
||||
除 `forbidden()` 同时设置 HTTP 403(便于网关按 HTTP 状态码处理)外,其余响应 HTTP 状态码恒为 200,结果一律看 body 的 `code`(`code=401` 时前端跳转登录)。
|
||||
:::
|
||||
|
||||
### 方法速查
|
||||
|
||||
```php
|
||||
// app/ExceptionHandle.php
|
||||
Result::success($data, $msg); // 成功响应(data 自动驼峰转换)
|
||||
Result::success($data, $msg, true); // 成功响应,$raw=true 跳过驼峰转换
|
||||
Result::page($records, $total, $current, $size, $pages = null); // 分页响应
|
||||
Result::fail($msg, $code, $data); // 失败响应($code 默认 1)
|
||||
Result::unauthorized($msg); // 401 未授权(HTTP 200 + body code=401)
|
||||
Result::forbidden($msg); // 403 禁止访问(HTTP 403 + body code=403)
|
||||
Result::notFound($msg); // 404 数据不存在(body code=404)
|
||||
Result::validateError($errors); // 422 验证错误(data=字段错误明细)
|
||||
```
|
||||
|
||||
public function render($request, Throwable $e): Response
|
||||
### Controller 快捷方法
|
||||
|
||||
控制器基类把常用方法转发到 `Result`,在控制器中直接用 `$this->` 调用:
|
||||
|
||||
```php
|
||||
// app/Http/Controllers/Controller.php
|
||||
|
||||
$this->success($data, '操作成功'); // → Result::success()
|
||||
$this->paginate($result); // → Result::page(),$result 来自 Service::pageList()
|
||||
$this->fail('操作失败'); // → Result::fail(),$code 默认 1
|
||||
$this->fail('数据不存在', 404); // → 自定义 code
|
||||
|
||||
// 通用 CRUD 快捷方法(内部已处理响应与异常,控制器一行调用)
|
||||
$this->_page($service); // 分页 $this->_detail($service, $id); // 详情(空返回 404)
|
||||
$this->_list($service); // 全量列表 $this->_add($service, $data); // 新增(返回 id)
|
||||
$this->_treeList($service); // 树形列表 $this->_update($service, $id, $data);
|
||||
$this->_delete($service, $id); // 删除 $this->_batchDelete($service, $ids);
|
||||
```
|
||||
|
||||
## 抛出业务异常(BizException)
|
||||
|
||||
业务规则校验失败时,在 **Service 层**抛出 `App\Exceptions\BizException`,无须自己构造响应:
|
||||
|
||||
```php
|
||||
use App\Exceptions\BizException;
|
||||
|
||||
// 典型场景:唯一性校验、状态依赖校验
|
||||
if ($this->uniqueCheck('code', $data, $id)) {
|
||||
throw new BizException('编码已存在');
|
||||
}
|
||||
|
||||
if ($model->status !== 1) {
|
||||
throw new BizException('该状态下不允许删除');
|
||||
}
|
||||
```
|
||||
|
||||
**工作机制:**
|
||||
|
||||
- `BizException` 继承 `RuntimeException`,属于"预期内"的业务异常,默认 `code=1`;
|
||||
- 走 `_add() / _update() / _delete()` 等快捷方法时,被 Controller 捕获并转为 `$this->fail($e->getMessage(), $e->getCode())`;
|
||||
- 没被捕获时由全局 `Handler` 兜底处理,仍返回 `code=1` 的失败响应——**任何路径下前端拿到的结构都一致**。
|
||||
|
||||
:::warning 使用边界
|
||||
`BizException` 仅用于**业务规则失败**(消息直接展示给用户,不含敏感信息)。程序错误(空指针、SQL 异常等)应让其自然冒泡,由 `Handler` 统一转为 `code=500`,生产环境隐藏真实消息——不要把未知异常包装成业务异常掩盖问题。
|
||||
:::
|
||||
|
||||
## 异常处理流程(Handler)
|
||||
|
||||
`app/Exceptions/Handler.php` 拦截 `api/*` 或期望 JSON 的请求,按异常类型分派:
|
||||
|
||||
```text
|
||||
任意异常抛出
|
||||
│
|
||||
▼
|
||||
Handler::render() ── 非 api/* 且非 JSON 请求 → 交回框架默认处理(如网页 404)
|
||||
│
|
||||
▼(api/* 请求)
|
||||
renderJson() 按类型分派:
|
||||
├─ BizException → Result::fail(消息, 异常 code ?: 1)
|
||||
├─ ValidationException → Result::validateError(字段明细) code=422
|
||||
├─ AuthenticationException → Result::unauthorized() code 401(HTTP 200)
|
||||
├─ NotFoundHttpException → Result::notFound() code=404
|
||||
└─ 其它异常 → Result::fail(消息, 500)
|
||||
APP_DEBUG=true 时消息=异常原文,
|
||||
生产环境消息固定为「操作失败」
|
||||
```
|
||||
|
||||
对应代码:
|
||||
|
||||
```php
|
||||
// app/Exceptions/Handler.php(节选)
|
||||
protected function renderJson($request, Throwable $e): JsonResponse
|
||||
{
|
||||
// 1. 参数验证失败
|
||||
if ($e instanceof ValidateException) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => '参数验证失败', 'data' => $e->getError()]);
|
||||
if ($e instanceof BizException) {
|
||||
return Result::fail($e->getMessage(), $e->getCode() ?: 1);
|
||||
}
|
||||
if ($e instanceof ValidationException) {
|
||||
return Result::validateError($e->errors());
|
||||
}
|
||||
if ($e instanceof AuthenticationException) {
|
||||
return Result::unauthorized();
|
||||
}
|
||||
if ($e instanceof NotFoundHttpException) {
|
||||
return Result::notFound();
|
||||
}
|
||||
|
||||
// 2. 数据不存在
|
||||
if ($e instanceof ModelNotFoundException || $e instanceof DataNotFoundException) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => '数据不存在']);
|
||||
}
|
||||
|
||||
// 3. JWT 异常(code=401)
|
||||
if ($e instanceof \RuntimeException && in_array($e->getCode(), [401])) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// 4. HTTP 异常
|
||||
if ($e instanceof HttpException) {
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => $e->getMessage() ?: '请求错误']);
|
||||
}
|
||||
|
||||
// 5. 其他未知异常(生产环境隐藏详情)
|
||||
$msg = config('app.show_error_msg') ? $e->getMessage() : '操作失败';
|
||||
return json(['code' => 1, 'ok' => false, 'msg' => $msg]);
|
||||
// 其他异常:生产环境隐藏细节,调试模式返回真实消息
|
||||
$msg = config('app.debug') ? $e->getMessage() : '操作失败';
|
||||
return Result::fail($msg ?: '操作失败', 500);
|
||||
}
|
||||
```
|
||||
|
||||
:::danger 生产环境安全
|
||||
`SHOW_ERROR_MSG` 必须设为 `false`,否则未知异常的详细信息(可能包含 SQL 语句、文件路径等敏感信息)会暴露给前端。
|
||||
`APP_DEBUG` 必须为 `false`,否则未知异常的真实消息(可能包含 SQL 语句、文件路径等敏感信息)会通过 `msg` 返回给前端。详见 [11.8 安全加固清单](/zh/deploy/security.html)。
|
||||
:::
|
||||
|
||||
## 统一响应类(Result)
|
||||
## 参数验证的两条路径
|
||||
|
||||
```php
|
||||
// extend/response/Result.php
|
||||
参数验证失败有两条路径,**code 不同**,联调时注意区分:
|
||||
|
||||
Result::success($data, $msg); // 成功响应
|
||||
Result::page($records, $total, ...); // 分页响应
|
||||
Result::fail($msg, $code); // 失败响应
|
||||
Result::unauthorized($msg); // 401 未授权
|
||||
Result::forbidden($msg); // 403 禁止访问
|
||||
Result::notFound($msg); // 404 数据不存在
|
||||
Result::validateError($errors); // 422 验证错误
|
||||
| 路径 | 触发方式 | 响应 |
|
||||
|------|----------|------|
|
||||
| FormRequest(控制器入参) | 路由类型注入 `XxxRequest`,`rules()` 不通过 | `code=1`,`msg=参数验证失败`,`data=字段明细`(`BaseRequest::failedValidation()` 统一处理,与 `fail()` 默认 code 一致) |
|
||||
| ValidationException(代码内手动校验) | `Validator::validate()` / `$request->validate()` 抛出 | `code=422`(`Handler` 分派到 `Result::validateError()`) |
|
||||
|
||||
FormRequest 失败响应示例:
|
||||
|
||||
```json
|
||||
{"code": 1, "ok": false, "msg": "参数验证失败",
|
||||
"data": {"username": ["用户名不能为空"], "password": ["密码不能为空"]}}
|
||||
```
|
||||
|
||||
## 状态码说明
|
||||
## 完整示例
|
||||
|
||||
| code | 含义 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| 0 | 成功 | 所有成功操作 |
|
||||
| 1 | 一般失败 | 业务逻辑失败 |
|
||||
| 401 | 未授权 | Token 缺失/无效/过期 |
|
||||
| 403 | 禁止访问 | 权限不足 / 租户异常 / 演示环境 |
|
||||
| 404 | 数据不存在 | 查询单条记录为空 |
|
||||
| 422 | 参数验证失败 | 表单验证不通过 |
|
||||
```php
|
||||
// Controller:参数由 FormRequest 验证,响应交给快捷方法
|
||||
public function add(ExampleAddRequest $request)
|
||||
{
|
||||
return $this->_add($this->service, $request->validated());
|
||||
}
|
||||
|
||||
// Service:业务规则失败直接抛异常
|
||||
public function add(array $data): int
|
||||
{
|
||||
if ($this->uniqueCheck('code', $data)) {
|
||||
throw new BizException('编码已存在');
|
||||
}
|
||||
|
||||
return $this->doAdd($data); // DB::transaction 由基类钩子处理
|
||||
}
|
||||
|
||||
// 手动构造响应(非 CRUD 场景)
|
||||
public function custom()
|
||||
{
|
||||
$data = $this->service->stats();
|
||||
|
||||
if (! $data) {
|
||||
return $this->fail('暂无统计数据', 404);
|
||||
}
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
```
|
||||
|
||||
## 驼峰转换
|
||||
|
||||
`Result` 构造响应时自动把 `snake_case` 键转为 `camelCase`(由 `config/api.php` 的 `API_CAMEL_SNAKE_CONVERT` 控制,请求方向反向转换):
|
||||
|
||||
- 响应:`create_time` → `createTime`
|
||||
- 需要原样返回时:`Result::success($data, $msg, true)`(`$raw=true`)
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 问题 | 说明 |
|
||||
|------|------|
|
||||
| 为什么 HTTP 200 却是失败? | 约定 body `code` 判断结果,HTTP 状态码仅 403 例外(便于网关处理);`code=401` 由前端跳转登录 |
|
||||
| 前端拿到 `code=500` 且 `msg=操作失败` | 未知异常 + 生产模式;排查服务端日志 `storage/logs/` |
|
||||
| 抛了 BizException 却返回 500? | 检查是否被外层 `try/catch (\Exception)` 吞掉后重新抛出,或消息中混入了敏感信息 |
|
||||
| 验证失败有时 1 有时 422? | 见上文「参数验证的两条路径」,前端两种都要兼容(项目以 `code !== 0` 判失败) |
|
||||
| 分页字段顺序为何固定? | `Result::page()` 固定 `records / total / current / size / pages` 五项,与 `Service::pageList()` 返回结构对应 |
|
||||
|
||||
## 相关页面
|
||||
|
||||
- [8.1 统一响应格式](/zh/api-spec/response.html) — 前端对接视角的报文格式
|
||||
- [8.2 错误码对照表](/zh/api-spec/error-code.html) — 全部错误码速查
|
||||
- [12.1 API 响应规范](/zh/standards/api-response.html) — 必须遵守的规范约束
|
||||
- [3.6 控制器开发](/zh/guide/controller.html) — 快捷方法的完整用法
|
||||
|
||||
Reference in New Issue
Block a user