From c58a6d345ad8746ec3fd69083e440b96d1304bea Mon Sep 17 00:00:00 2001 From: zj Date: Wed, 29 Jul 2026 17:19:33 +0800 Subject: [PATCH] update --- composer.json | 4 +- src/Exception/Handler/ApiExceptionHandler.php | 253 ++++++++++++++++++ .../Handler/GatewayExceptionHandler.php | 64 ----- 3 files changed, 256 insertions(+), 65 deletions(-) create mode 100644 src/Exception/Handler/ApiExceptionHandler.php delete mode 100644 src/Exception/Handler/GatewayExceptionHandler.php diff --git a/composer.json b/composer.json index 57048d9..8bd4044 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,9 @@ "psr/log": "^2.0 || ^3.0", "hyperf/helper": "^3.1", "hyperf/carbon": "^3.1", - "hyperf/validation": "^3.1" + "hyperf/validation": "^3.1", + "hyperf/http-server": "^3.1", + "hyperf/rpc-client": "^3.1" }, "autoload": { "psr-4": { diff --git a/src/Exception/Handler/ApiExceptionHandler.php b/src/Exception/Handler/ApiExceptionHandler.php new file mode 100644 index 0000000..2ffa306 --- /dev/null +++ b/src/Exception/Handler/ApiExceptionHandler.php @@ -0,0 +1,253 @@ +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; + } +} diff --git a/src/Exception/Handler/GatewayExceptionHandler.php b/src/Exception/Handler/GatewayExceptionHandler.php deleted file mode 100644 index bd9980c..0000000 --- a/src/Exception/Handler/GatewayExceptionHandler.php +++ /dev/null @@ -1,64 +0,0 @@ -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; - } -}