5. [请求与响应格式](#5-请求与响应格式)

- [5.1 通用请求头](#51-通用请求头)
   - [5.2 响应格式](#52-响应格式)
6. [错误代码](#6-错误代码)
7. [安全性考虑](#7-安全性考虑)
This commit is contained in:
Jethro Lin 2024-12-04 12:14:43 +08:00
parent 31b69e318a
commit c5258233a8
5 changed files with 500 additions and 0 deletions

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Constants;
class ErrorCode
{
// 通用错误 (1000-1999)
public const VALIDATION_ERROR = 'E1001';
public const UNAUTHORIZED = 'E1002';
public const FORBIDDEN = 'E1003';
public const RESOURCE_NOT_FOUND = 'E1004';
public const METHOD_NOT_ALLOWED = 'E1005';
public const TOO_MANY_REQUESTS = 'E1006';
public const SERVER_ERROR = 'E1007';
public const SERVICE_UNAVAILABLE = 'E1008';
// 认证相关错误 (2000-2999)
public const INVALID_CREDENTIALS = 'E2001';
public const TOKEN_EXPIRED = 'E2002';
public const TOKEN_INVALID = 'E2003';
public const TOKEN_BLACKLISTED = 'E2004';
public const TOKEN_NOT_FOUND = 'E2005';
// 客户相关错误 (3000-3999)
public const CLIENT_NOT_FOUND = 'E3001';
public const CLIENT_INACTIVE = 'E3002';
public const CLIENT_RATE_LIMIT_EXCEEDED = 'E3003';
public const CLIENT_QUOTA_EXCEEDED = 'E3004';
public const CLIENT_HAS_ACTIVE_TOKENS = 'E3005';
// LLM 提供商相关错误 (4000-4999)
public const PROVIDER_NOT_FOUND = 'E4001';
public const PROVIDER_INACTIVE = 'E4002';
public const PROVIDER_ERROR = 'E4003';
public const PROVIDER_TIMEOUT = 'E4004';
public const PROVIDER_RATE_LIMIT = 'E4005';
// 业务逻辑错误 (5000-5999)
public const INVALID_REQUEST_FORMAT = 'E5001';
public const INVALID_RESPONSE_FORMAT = 'E5002';
public const OPERATION_FAILED = 'E5003';
public const RESOURCE_ALREADY_EXISTS = 'E5004';
public const RESOURCE_IN_USE = 'E5005';
/**
* 获取错误代码对应的默认消息
*
* @param string $code 错误代码
* @return string 错误消息
*/
public static function getMessage(string $code): string
{
return match ($code) {
// 通用错误
self::VALIDATION_ERROR => '请求参数验证失败。',
self::UNAUTHORIZED => '未经授权的访问。',
self::FORBIDDEN => '无权访问该资源。',
self::RESOURCE_NOT_FOUND => '请求的资源不存在。',
self::METHOD_NOT_ALLOWED => '请求方法不允许。',
self::TOO_MANY_REQUESTS => '请求过于频繁,请稍后重试。',
self::SERVER_ERROR => '服务器内部错误。',
self::SERVICE_UNAVAILABLE => '服务暂时不可用。',
// 认证相关错误
self::INVALID_CREDENTIALS => '无效的认证凭据。',
self::TOKEN_EXPIRED => '认证令牌已过期。',
self::TOKEN_INVALID => '无效的认证令牌。',
self::TOKEN_BLACKLISTED => '认证令牌已被禁用。',
self::TOKEN_NOT_FOUND => '认证令牌不存在。',
// 客户相关错误
self::CLIENT_NOT_FOUND => '客户不存在。',
self::CLIENT_INACTIVE => '客户状态为非活跃。',
self::CLIENT_RATE_LIMIT_EXCEEDED => '已超过请求速率限制。',
self::CLIENT_QUOTA_EXCEEDED => '已超过配额限制。',
self::CLIENT_HAS_ACTIVE_TOKENS => '客户存在活跃的认证令牌。',
// LLM 提供商相关错误
self::PROVIDER_NOT_FOUND => 'LLM 提供商不存在。',
self::PROVIDER_INACTIVE => 'LLM 提供商状态为非活跃。',
self::PROVIDER_ERROR => 'LLM 提供商服务错误。',
self::PROVIDER_TIMEOUT => 'LLM 提供商服务超时。',
self::PROVIDER_RATE_LIMIT => 'LLM 提供商限制请求速率。',
// 业务逻辑错误
self::INVALID_REQUEST_FORMAT => '无效的请求格式。',
self::INVALID_RESPONSE_FORMAT => '无效的响应格式。',
self::OPERATION_FAILED => '操作失败。',
self::RESOURCE_ALREADY_EXISTS => '资源已存在。',
self::RESOURCE_IN_USE => '资源正在使用中。',
default => '未知错误。',
};
}
}

181
app/Exceptions/Handler.php Normal file
View file

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use App\Constants\ErrorCode;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
/**
* API 路由前缀
*/
protected const API_PREFIX = 'api';
/**
* 不需要报告的异常类型
*
* @var array<int, class-string<Throwable>>
*/
protected $dontReport = [
AuthenticationException::class,
AuthorizationException::class,
ValidationException::class,
ModelNotFoundException::class,
];
/**
* 注册异常处理回调
*/
public function register(): void
{
$this->renderable(function (Throwable $e, Request $request) {
if ($this->isApiRequest($request)) {
return $this->handleApiException($e);
}
});
}
/**
* 处理 API 异常
*
* @param Throwable $e
* @return JsonResponse
*/
protected function handleApiException(Throwable $e): JsonResponse
{
if ($e instanceof ValidationException) {
return $this->error(
ErrorCode::VALIDATION_ERROR,
ErrorCode::getMessage(ErrorCode::VALIDATION_ERROR),
$e->errors(),
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if ($e instanceof AuthenticationException) {
return $this->error(
ErrorCode::UNAUTHORIZED,
ErrorCode::getMessage(ErrorCode::UNAUTHORIZED),
null,
Response::HTTP_UNAUTHORIZED
);
}
if ($e instanceof AuthorizationException) {
return $this->error(
ErrorCode::FORBIDDEN,
ErrorCode::getMessage(ErrorCode::FORBIDDEN),
null,
Response::HTTP_FORBIDDEN
);
}
if ($e instanceof ModelNotFoundException) {
return $this->error(
ErrorCode::RESOURCE_NOT_FOUND,
ErrorCode::getMessage(ErrorCode::RESOURCE_NOT_FOUND),
null,
Response::HTTP_NOT_FOUND
);
}
if ($e instanceof NotFoundHttpException) {
return $this->error(
ErrorCode::RESOURCE_NOT_FOUND,
ErrorCode::getMessage(ErrorCode::RESOURCE_NOT_FOUND),
null,
Response::HTTP_NOT_FOUND
);
}
if ($e instanceof MethodNotAllowedHttpException) {
return $this->error(
ErrorCode::METHOD_NOT_ALLOWED,
ErrorCode::getMessage(ErrorCode::METHOD_NOT_ALLOWED),
null,
Response::HTTP_METHOD_NOT_ALLOWED
);
}
if ($e instanceof TooManyRequestsHttpException) {
return $this->error(
ErrorCode::TOO_MANY_REQUESTS,
ErrorCode::getMessage(ErrorCode::TOO_MANY_REQUESTS),
null,
Response::HTTP_TOO_MANY_REQUESTS
);
}
if ($e instanceof HttpException) {
return $this->error(
ErrorCode::SERVER_ERROR,
$e->getMessage(),
null,
$e->getStatusCode()
);
}
// 记录未处理的异常
$this->report($e);
return $this->error(
ErrorCode::SERVER_ERROR,
ErrorCode::getMessage(ErrorCode::SERVER_ERROR),
config('app.debug') ? [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
] : null,
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
/**
* 判断是否为 API 请求
*
* @param Request $request
* @return bool
*/
protected function isApiRequest(Request $request): bool
{
return $request->is(static::API_PREFIX . '/*') || $request->expectsJson();
}
/**
* 返回错误响应
*
* @param string $error 错误代码
* @param string $message 错误消息
* @param mixed $errors 详细错误信息
* @param int $code HTTP状态码
* @return JsonResponse
*/
protected function error(string $error, string $message, mixed $errors = null, int $code = Response::HTTP_BAD_REQUEST): JsonResponse
{
$response = [
'success' => false,
'error' => $error,
'message' => $message,
];
if ($errors !== null) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
}

50
app/Http/Kernel.php Normal file
View file

@ -0,0 +1,50 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array<string, class-string|string>
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
/**
* The application's middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
\App\Http\Middleware\ValidateHeaders::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Constants\ErrorCode;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ValidateHeaders
{
/**
* 处理请求
*
* @param Request $request
* @param Closure $next
* @return Response
*/
public function handle(Request $request, Closure $next): Response
{
// 检查 Content-Type
if ($request->isMethod('POST') || $request->isMethod('PUT') || $request->isMethod('PATCH')) {
if (!$request->hasHeader('Content-Type') || !str_contains($request->header('Content-Type'), 'application/json')) {
return response()->json([
'success' => false,
'error' => ErrorCode::INVALID_REQUEST_FORMAT,
'message' => '请求头必须包含 Content-Type: application/json',
], Response::HTTP_BAD_REQUEST);
}
}
// 检查 Accept
if (!$request->hasHeader('Accept') || !str_contains($request->header('Accept'), 'application/json')) {
return response()->json([
'success' => false,
'error' => ErrorCode::INVALID_REQUEST_FORMAT,
'message' => '请求头必须包含 Accept: application/json',
], Response::HTTP_BAD_REQUEST);
}
// 检查 API 版本
if (!$request->hasHeader('X-API-Version')) {
return response()->json([
'success' => false,
'error' => ErrorCode::INVALID_REQUEST_FORMAT,
'message' => '请求头必须包含 X-API-Version',
], Response::HTTP_BAD_REQUEST);
}
// 检查客户端标识
if (!$request->hasHeader('X-Client-ID')) {
return response()->json([
'success' => false,
'error' => ErrorCode::INVALID_REQUEST_FORMAT,
'message' => '请求头必须包含 X-Client-ID',
], Response::HTTP_BAD_REQUEST);
}
return $next($request);
}
}

109
app/Traits/ApiResponse.php Normal file
View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
trait ApiResponse
{
/**
* 成功响应
*
* @param mixed $data 响应数据
* @param string|null $message 成功消息
* @param int $code HTTP状态码
* @return JsonResponse
*/
protected function success(mixed $data = null, ?string $message = null, int $code = Response::HTTP_OK): JsonResponse
{
$response = [
'success' => true,
];
if ($data !== null) {
$response['data'] = $data;
}
if ($message !== null) {
$response['message'] = $message;
}
return response()->json($response, $code);
}
/**
* 错误响应
*
* @param string $error 错误代码
* @param string $message 错误消息
* @param mixed $errors 详细错误信息
* @param int $code HTTP状态码
* @return JsonResponse
*/
protected function error(
string $error,
string $message,
mixed $errors = null,
int $code = Response::HTTP_BAD_REQUEST
): JsonResponse {
$response = [
'success' => false,
'error' => $error,
'message' => $message,
];
if ($errors !== null) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
/**
* 分页响应
*
* @param array $items 分页数据
* @param array $meta 分页元数据
* @param string|null $message 成功消息
* @return JsonResponse
*/
protected function paginate(array $items, array $meta, ?string $message = null): JsonResponse
{
$response = [
'success' => true,
'data' => $items,
'meta' => $meta,
];
if ($message !== null) {
$response['message'] = $message;
}
return response()->json($response);
}
/**
* 创建成功响应
*
* @param mixed $data 创建的资源数据
* @param string|null $message 成功消息
* @return JsonResponse
*/
protected function created(mixed $data, ?string $message = null): JsonResponse
{
return $this->success($data, $message, Response::HTTP_CREATED);
}
/**
* 无内容响应
*
* @return JsonResponse
*/
protected function noContent(): JsonResponse
{
return response()->json(null, Response::HTTP_NO_CONTENT);
}
}