This commit is contained in:
2026-07-29 17:19:33 +08:00
parent 641d3ffac5
commit c58a6d345a
3 changed files with 256 additions and 65 deletions

View File

@@ -0,0 +1,253 @@
<?php
/**
* 网关 异常处理器
* 返回http的json格式
*/
declare(strict_types=1);
namespace Dhd\Micro\Core\Exception\Handler;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Throwable;
use Hyperf\HttpServer\Contract\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Hyperf\JsonRpc\ResponseBuilder;
use Hyperf\RpcClient\Exception\RequestException;
use Hyperf\JsonRpc\Exception\ClientException;
use Dhd\Micro\Core\Exception\BusinessException;
use Dhd\Micro\Core\Trait\RpcContextTrait;
use Dhd\Micro\Core\Library\Util;
class ApiExceptionHandler extends ExceptionHandler
{
use RpcContextTrait;
protected RequestInterface $request;
protected string $traceId;
public function __construct()
{
$this->request = Util::get(RequestInterface::class);
$this->traceId = $this->getTraceId();
}
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
{
$this->stopPropagation(); // 阻止异常冒泡
$logData = $this->buildLogData($throwable);
// 按异常类型记录日志(业务异常warning,系统异常error)
$this->logException($throwable, $logData);
// 解析错误信息
[$code, $message, $data] = $this->resolveErrorInfo($throwable);
// 根据请求类型返回对应格式的响应(HTTP/RPC)
if ($this->isRpcRequest()) {
return $this->buildRpcResponse($response, $code, $message, $data);
}
return $this->buildHttpResponse($response, $code, $message, $data);
}
public function isValid(Throwable $throwable): bool
{
return true;
}
/**
* 构建日志数据(包含完整上下文,生产环境脱敏)
*/
private function buildLogData(Throwable $throwable): array
{
$logData = [
'trace_id' => $this->traceId,
'type' => get_class($throwable),
'message' => $throwable->getMessage(),
'code' => $throwable->getCode(),
'file' => $throwable->getFile(),
'line' => $throwable->getLine(),
'trace' => $throwable->getTraceAsString(),
'context' => [
'url' => (string)$this->request->getUri(),
'method' => $this->request->getMethod(),
'user_id' => $this->request->header('X-User-Id') ?? 'unknown',
'timestamp' => date('Y-m-d H:i:s'),
],
];
// 生产环境脱敏敏感参数
if (env('APP_ENV') === 'prod') {
$logData['context']['params'] = $this->desensitizeParams($this->request->all());
} else {
$logData['context']['params'] = $this->request->all();
}
return $logData;
}
/**
* 按异常类型记录日志
*/
private function logException(Throwable $throwable, array $logData): void
{
if ($throwable instanceof BusinessException) {
Util::recordLog($throwable->getMessage(), $logData, $this->traceId);
} else {
// 系统异常额外标记级别,便于日志告警
Util::recordLog('系统异常', $logData, $this->traceId);
}
}
/**
* 解析错误信息(区分环境和异常类型)
*/
private function resolveErrorInfo(Throwable $throwable): array
{
// 业务异常:直接使用其错误码和消息
if ($throwable instanceof BusinessException) {
return [
$throwable->getCode(),
$throwable->getMessage(),
$throwable->getData() ?? [],
];
}
// 微服务 JSON-RPC 调用异常:按 code 是否为系统异常保留值(-32000)区分业务错误与系统错误,
// 不要求业务码必须落在 400-499(各微服务有自己的业务状态码,不是 HTTP 状态码)
if ($throwable instanceof RequestException) {
$code = $throwable->getCode();
if ($code !== ResponseBuilder::SERVER_ERROR) {
return [$code, $throwable->getMessage(), []];
}
$isDev = env('APP_ENV') === 'dev';
return [500, $isDev ? $throwable->getMessage() : '服务异常,请稍后重试', []];
}
// 系统异常:开发环境返回详细信息,生产环境返回通用消息
$isDev = env('APP_ENV') === 'dev';
$innerCode = $this->getInnerCode($throwable) ?? 500;
return [
$innerCode,
$throwable->getMessage() ?: '服务异常,请稍后重试',
$isDev ? [
'trace' => $throwable->getTraceAsString(),
'file' => $throwable->getFile(),
'line' => $throwable->getLine(),
'trace_id' => $this->traceId, // 开发环境返回追踪ID便于调试
] : ['trace_id' => $this->traceId], // 生产环境仅返回追踪ID供排查
];
}
/**
* 判断是否为RPC请求(如JSON-RPC)
*/
private function isRpcRequest(): bool
{
$contentType = $this->request->getHeaderLine('Content-Type');
$path = $this->request->getUri()->getPath();
// 适配常见RPC场景:Content-Type为json且路径包含/rpc
return str_contains($contentType, 'application/json')
&& str_contains($path, '/rpc');
}
/**
* 构建RPC响应(JSON-RPC 2.0规范)
*/
private function buildRpcResponse(ResponseInterface $response, int $code, string $message, array $data): ResponseInterface
{
$requestBody = $this->request->getBody()->getContents();
$requestData = json_decode($requestBody, true) ?? [];
$rpcId = $requestData['id'] ?? null;
$responseData = [
'jsonrpc' => '2.0',
'error' => [
'code' => $code,
'data' => $data,
'message' => $message,
],
'id' => $rpcId,
];
return $response
->withHeader('Content-Type', 'application/json')
->withHeader('X-Trace-Id', $this->traceId) // 透传追踪ID
->withStatus(200) // RPC错误必须返回200状态码
->withBody(new SwooleStream(json_encode($responseData, JSON_UNESCAPED_UNICODE)));
}
/**
* 构建HTTP响应(通用JSON格式)
*/
private function buildHttpResponse(ResponseInterface $response, int $code, string $message, array $data): ResponseInterface
{
// HTTP状态码映射:业务码本身就是合法 HTTP 状态码时按原样用,
// 否则统一回落 200(body 里带真实业务码,与本项目 success()/error() 助手函数约定一致),
// 避免把非法状态码(如各服务自定义的业务码)发给真实 HTTP 客户端
$statusCode = ($code >= 100 && $code <= 599) ? $code : 200;
$responseData = [
'code' => $code,
'data' => $data,
'message' => $message,
];
return $response
->withHeader('Content-Type', 'application/json')
->withHeader('X-Trace-Id', $this->traceId)
->withStatus($statusCode)
->withBody(new SwooleStream(json_encode($responseData, JSON_UNESCAPED_UNICODE)));
}
/**
* 脱敏敏感参数(生产环境用)
*/
private function desensitizeParams(array $params): array
{
$sensitiveKeys = ['password', 'pwd', 'mobile', 'phone', 'id_card', 'credit_card'];
foreach ($params as $key => &$value) {
if (in_array(strtolower($key), $sensitiveKeys) && is_string($value)) {
$value = $this->maskValue($value, $key);
}
}
return $params;
}
/**
* 具体字段脱敏逻辑
*/
private function maskValue(string $value, string $key): string
{
return match (strtolower($key)) {
'password', 'pwd' => '******',
'mobile', 'phone' => substr($value, 0, 3) . '****' . substr($value, -4),
'id_card' => substr($value, 0, 6) . '********' . substr($value, -4),
default => substr($value, 0, 2) . '****' . substr($value, -2),
};
}
/**
* 获取内部异常错误码
*/
private function getInnerCode(Throwable $throwable): ?int
{
if (method_exists($throwable, 'getThrowable') && is_array($inner = $throwable->getThrowable())) {
return $inner['code'] ?? null;
}
// 兼容嵌套异常
if ($throwable->getPrevious()) {
return $throwable->getPrevious()->getCode() ?: null;
}
return null;
}
}

View File

@@ -1,64 +0,0 @@
<?php
/**
* 网关 异常处理器
* 返回http的json格式
*/
declare(strict_types=1);
namespace Dhd\Micro\Core\Exception\Handler;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Throwable;
use Psr\Http\Message\ResponseInterface;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Hyperf\JsonRpc\Exception\ClientException;
use Dhd\Micro\Core\Exception\BusinessException;
use Dhd\Micro\Core\Trait\RpcContextTrait;
use Dhd\Micro\Core\Library\Util;
class GatewayExceptionHandler extends ExceptionHandler
{
use RpcContextTrait;
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
{
$this->stopPropagation(); // 阻止异常冒泡
$traceId = $this->getTraceId();
$code = 500;
$message = '服务器内部错误';
if (($throwable instanceof BusinessException)) {
Util::recordLog($throwable->getMessage(), $throwable, $traceId);
$code = $throwable->getCode();
$message = $throwable->getMessage();
} else if ($throwable instanceof ClientException) {
$code = $throwable->getCode() ?: 500;
$message = $throwable->getMessage() ?: '下游服务异常';
Util::recordLog($message, $throwable, $traceId);
}
$responseData = [
'code' => $code,
'message' => $message,
'data' => null,
];
return $response
->withHeader('Content-Type', 'application/json')
->withHeader('X-Trace-Id', $traceId)
->withStatus(200)
->withBody(new SwooleStream(json_encode($responseData, JSON_UNESCAPED_UNICODE)));
}
public function isValid(Throwable $throwable): bool
{
return true;
}
}