فهرست منبع

feat(api-v13): v3 契约层——原生 Resource 信封 + RFC 9457 异常渲染

v3 与 v2 在后端共存,但契约完全不同:v2 把成败塞进 ok 字段、业务失败也回
HTTP 200;v3 靠状态码与 media type 分流。

响应形状就是 Laravel Resource 的原生形状,控制器里不需要任何 helper:
  单个  {data: {...}}          return XxxV3Resource::make($model)
  列表  {data: [...], meta:{}} return XxxV3Resource::collection($query->paginate($n))
  失败  RFC 9457 Problem Details + application/problem+json

- V3Resource / V3ResourceCollection:所有 *V3Resource 的基类。唯一职责是用官方
  钩子 paginationInformation() 裁掉分页里的绝对 URL(顶层 links、meta.path、
  meta.links 都由 APP_URL 拼出,反代下会拼错,前端也用不到)。用 v3 专属基类
  而非全局 macro,避免波及 v2 的 Resource::collection()
- app/Exceptions/Problem:Problem Details 的唯一构造点,含状态码→稳定英文 title
  的映射。type 用 urn:problem:{slug} 而非 url(),同样是为了避开绝对地址
- app/Exceptions/BusinessException:需要自定义 type / 扩展字段的业务错误,
  自带 render(),优先级高于所有处理器
- bootstrap/app.php:ValidationException→422、AuthenticationException→401、
  兜底按 HttpExceptionInterface 取状态码。**只接管 api/v3/***,v2 的错误形状
  一个字节不变。不为 ModelNotFoundException / AuthorizationException 单独注册——
  prepareException() 已在 render 回调前把它们转成 404/403
- 5xx 的 detail 在生产环境抹掉防泄露,所以「预期的 5xx 且文案必须送达」的场景
  (如停机维护)要用 BusinessException
- 基类只保留 v2 的 ok()/error(),v3 控制器不准用
- 文案走 __(),翻译合并进既有的 site.php,不新建语言文件

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 هفته پیش
والد
کامیت
29eee8871f

+ 57 - 0
api-v13/app/Exceptions/BusinessException.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Exceptions;
+
+use Exception;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * 需要自己的 type / title / 扩展字段的业务错误。
+ *
+ * 普通的 404、403、422 不要用它——直接 abort() 或让 FormRequest 抛
+ * ValidationException,bootstrap/app.php 会统一渲染。只有当这个错误
+ * 是一种**客户端需要分别处理的业务状态**时才定义 slug,例如:
+ *
+ *     throw new BusinessException(
+ *         __('sentence.locked'), 409, 'sentence-locked',
+ *         ['locked_by' => $editor->nickname],
+ *     );
+ *
+ * Laravel 的 Handler 会先看异常有没有 render(),所以这里的实现优先级
+ * 最高,不受 bootstrap/app.php 里那些处理器影响。
+ */
+class BusinessException extends Exception
+{
+    /**
+     * @param  string  $detail  面向人的文案,调用方传已经过 __() 的字符串
+     * @param  string  $slug  进 type 字段的问题类型标识
+     * @param  array<string, mixed>  $extra  扩展成员
+     */
+    public function __construct(
+        string $detail,
+        protected int $status = 409,
+        protected string $slug = 'business-error',
+        protected array $extra = [],
+        protected ?string $problemTitle = null,
+    ) {
+        parent::__construct($detail, $status);
+    }
+
+    public function render(Request $request): ?JsonResponse
+    {
+        // 只接管 v3;v2 的错误形状在 dashboard-v4 下线前不能变
+        if (! $request->is('api/v3/*')) {
+            return null;
+        }
+
+        return Problem::response(
+            $request,
+            $this->status,
+            $this->slug,
+            $this->problemTitle,
+            $this->getMessage(),
+            $this->extra,
+        );
+    }
+}

+ 73 - 0
api-v13/app/Exceptions/Problem.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Exceptions;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * RFC 9457 Problem Details 响应的唯一构造点。
+ *
+ * v3 端点的所有错误都从这里出去:bootstrap/app.php 的异常处理器、
+ * 以及自带 render() 的 BusinessException。控制器不要直接调用它——
+ * 控制器只负责抛异常。
+ */
+class Problem
+{
+    /**
+     * 状态码 → 稳定的英文 title。
+     *
+     * 按 RFC 9457,title 应在同类问题的所有场合保持一致、不随语言变化,
+     * 便于机器识别;面向人的文案放 detail,由调用方传入已 __() 的字符串。
+     */
+    private const TITLES = [
+        400 => 'Bad request.',
+        401 => 'Authentication required.',
+        403 => 'Forbidden.',
+        404 => 'Resource not found.',
+        405 => 'Method not allowed.',
+        409 => 'Conflict.',
+        419 => 'Page expired.',
+        422 => 'Your request is not valid.',
+        429 => 'Too many requests.',
+        500 => 'Server error.',
+        503 => 'Service unavailable.',
+    ];
+
+    public static function titleFor(int $status): string
+    {
+        return self::TITLES[$status] ?? (
+            $status >= 500 ? self::TITLES[500] : self::TITLES[400]
+        );
+    }
+
+    /**
+     * @param  string  $slug  问题类型标识,进 type 字段
+     * @param  array<string, mixed>  $extra  扩展成员,如 ['errors' => [...]]
+     */
+    public static function response(
+        Request $request,
+        int $status,
+        string $slug,
+        ?string $title = null,
+        ?string $detail = null,
+        array $extra = []
+    ): JsonResponse {
+        $body = array_filter([
+            // 刻意用 URN 而不是 url():绝对地址由 APP_URL 拼出,
+            // 反向代理下容易拼错,而且目前没有文档页可供解引用。
+            'type' => "urn:problem:{$slug}",
+            'title' => $title ?? self::titleFor($status),
+            'status' => $status,
+            'detail' => $detail,
+            'instance' => $request->getRequestUri(),
+        ], fn ($v) => $v !== null && $v !== '') + $extra;
+
+        return response()->json(
+            $body,
+            $status,
+            ['Content-Type' => 'application/problem+json'],
+            JSON_UNESCAPED_UNICODE
+        );
+    }
+}

+ 19 - 0
api-v13/app/Http/Controllers/Controller.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Routing\Controller as BaseController;
 
 class Controller extends BaseController
@@ -37,4 +38,22 @@ class Controller extends BaseController
             'message' => $message,
         ], $status, [], JSON_UNESCAPED_UNICODE);
     }
+
+    /*
+    |--------------------------------------------------------------------------
+    | v3 不需要任何响应 helper
+    |--------------------------------------------------------------------------
+    |
+    | 成功:直接 return Resource。JsonResource 默认就包 data,paginator 交给
+    | 框架算分页,形状是 {data: {...}} 与 {data: [...], meta: {...}}。
+    |
+    |     return ChannelV3Resource::make($channel);
+    |     return ChannelV3Resource::collection($query->paginate($n));
+    |
+    | 失败:抛异常。abort()/ValidationException/BusinessException,
+    | 由 bootstrap/app.php 统一渲染成 RFC 9457 Problem Details。
+    |
+    | 上面 ok()/error() 只给 v2 用,v3 控制器里不准出现。
+    |
+    */
 }

+ 33 - 0
api-v13/app/Http/Resources/V3Resource.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+
+/**
+ * v3 资源的基类。所有 `*V3Resource` 都继承它。
+ *
+ * 作用是让 `XxxV3Resource::collection()` 产出 {@see V3ResourceCollection},
+ * 从而裁掉分页里的绝对 URL。除此之外与普通 JsonResource 完全一致——
+ * 响应形状由框架决定:
+ *
+ *     单个:{data: {...}}
+ *     列表:{data: [...], meta: {...}}
+ *
+ * 控制器直接 `return XxxV3Resource::make($model)` 或
+ * `return XxxV3Resource::collection($query->paginate($n))`,不需要任何 helper。
+ *
+ * 载荷是普通数组(不是 Eloquent 模型)时可以直接用本类:
+ *
+ *     return V3Resource::collection($items)->additional(['meta' => [...]]);
+ */
+class V3Resource extends JsonResource
+{
+    /**
+     * @param  mixed  $resource
+     */
+    protected static function newCollection($resource): V3ResourceCollection
+    {
+        return new V3ResourceCollection($resource, static::class);
+    }
+}

+ 30 - 0
api-v13/app/Http/Resources/V3ResourceCollection.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Arr;
+
+/**
+ * v3 列表响应的集合类。
+ *
+ * 只做一件事:裁掉 Laravel 默认分页信息里的绝对 URL。
+ * `links` 与 `meta.path` 都由 APP_URL 拼出,反向代理下容易拼错,前端也用不到。
+ *
+ * 其余一概交给框架:`meta` 里的 current_page / per_page / total / last_page /
+ * from / to 都是 paginator 自己算的。
+ */
+class V3ResourceCollection extends AnonymousResourceCollection
+{
+    /**
+     * @param  array{links: array, meta: array}  $default
+     * @return array{meta: array}
+     */
+    public function paginationInformation($request, array $paginated, array $default): array
+    {
+        unset($default['links']);
+        $default['meta'] = Arr::except($default['meta'], ['path', 'links']);
+
+        return $default;
+    }
+}

+ 58 - 0
api-v13/bootstrap/app.php

@@ -1,13 +1,18 @@
 <?php
 
+use App\Exceptions\Problem;
 use App\Http\Middleware\HandleInertiaRequests;
 use App\Http\Middleware\OpsToken;
 use App\Http\Middleware\SetLocale;
 use App\Http\Middleware\UserOperation;
+use Illuminate\Auth\AuthenticationException;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
 use Illuminate\Foundation\Configuration\Middleware;
 use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
 
 return Application::configure(basePath: dirname(__DIR__))
     ->withRouting(
@@ -43,4 +48,57 @@ return Application::configure(basePath: dirname(__DIR__))
         $exceptions->shouldRenderJsonWhen(
             fn ($request, Throwable $e): bool => $request->is('api/*') || $request->expectsJson()
         );
+
+        /*
+        |----------------------------------------------------------------------
+        | v3 的错误一律 RFC 9457 Problem Details
+        |----------------------------------------------------------------------
+        |
+        | 控制器只管抛异常,格式化在这里统一做。**只接管 api/v3/**:v2 的错误
+        | 形状(Laravel 默认的 {message, errors}、以及自写的 {ok:false,…})在
+        | dashboard-v4 下线前一个字节都不能变。
+        |
+        | 不需要为 ModelNotFoundException / AuthorizationException 单独注册:
+        | Handler::prepareException() 在 render 回调之前就把它们分别转成了
+        | NotFoundHttpException(404) 与 AccessDeniedHttpException(403),
+        | 会被下面的兜底按状态码正确处理;给它们写处理器反而是死代码。
+        |
+        | AuthenticationException 则不在那张转换表里,且默认行为会尝试重定向到
+        | login 路由,所以必须显式处理。
+        |
+        */
+        $isV3 = fn (Request $request): bool => $request->is('api/v3/*');
+
+        $exceptions->render(function (ValidationException $e, Request $request) use ($isV3) {
+            if ($isV3($request)) {
+                return Problem::response(
+                    $request, 422, 'validation-error', null,
+                    $e->getMessage(), ['errors' => $e->errors()],
+                );
+            }
+        });
+
+        $exceptions->render(function (AuthenticationException $e, Request $request) use ($isV3) {
+            if ($isV3($request)) {
+                return Problem::response($request, 401, 'unauthenticated');
+            }
+        });
+
+        $exceptions->render(function (Throwable $e, Request $request) use ($isV3) {
+            if (! $isV3($request)) {
+                return null;
+            }
+
+            $status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
+            $slug = match (true) {
+                $status === 404 => 'not-found',
+                $status === 403 => 'forbidden',
+                $status >= 500 => 'server-error',
+                default => 'request-error',
+            };
+            // 生产环境不泄露 5xx 的内部信息
+            $detail = ($status < 500 || config('app.debug')) ? $e->getMessage() : null;
+
+            return Problem::response($request, $status, $slug, null, $detail);
+        });
     })->create();

+ 5 - 0
api-v13/resources/lang/en/site.php

@@ -16,6 +16,11 @@ return [
     'keywords' => ['pali', 'buddhistm'],
     'description' => 'wikipali',
     'copyright' => 'iapt 2022',
+
+    // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
+    'maintenance' => 'The service is under scheduled maintenance.',
+    'not_found' => 'The requested resource does not exist.',
+    'invalid_parameter' => 'Invalid parameter.',
     'author.name' => 'iapt',
     'author.email' => 'visuddhindand@gmail.com',
     'nav' => [

+ 5 - 0
api-v13/resources/lang/zh-Hans/site.php

@@ -16,6 +16,11 @@ return [
     'keywords' => ['巴利语', '佛教'],
     'description' => 'wikipali',
     'copyright' => 'iapt 2022',
+
+    // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
+    'maintenance' => '服务已进入停机维护状态',
+    'not_found' => '请求的资源不存在',
+    'invalid_parameter' => '参数不合法',
     'author.name' => 'iapt',
     'author.email' => 'visuddhindand@gmail.com',
     'nav' => [