Răsfoiți Sursa

refactor(api-v13): 既有 v3 端点对齐新契约,补注释,收掉幽灵路由

search / search-suggest / upgrade / progress / tipitaka-read-* 这 5 个早期 v3
端点仍在用 v2 的 {ok, data, message} 信封,而生成器已按新契约把它们声明成
{data}——规格在说谎。dashboard-v6 尚未消费任何 v3 端点,现在对齐成本最低。

- ok()/error() 全部换成直接 return Resource 与抛异常
- ProgressController 改用原生 paginate(),新增 ProgressV3Resource(带
  @return array{} 声明,类型取自 progress_chapters 表)
- 不是 Eloquent 查询的阅读端点用 V3Resource::collection()->additional(['meta']),
  对外形状与框架一致。pagesize=20000b/10p 是领域语法不是整数,单列 page_size;
  from/to 实为段落号,改名 first_para/last_para,不占用框架键名的既定含义
- 路由 25 条收到 11 条:apiResource 全注册但 store/update/destroy 多是空方法体
- 全部方法补 @queryParam/@urlParam/@bodyParam/@unauthenticated 标注,
  记下只有读代码才知道的规则(progress 的 channels 是下划线分隔不是逗号、
  POST /v3/search 存在的理由是数组参数放不进 URL 等)
- 测试按新契约更新:assertJsonPath('ok', false) → assertStatus(422/404) + 断言
  errors 字段

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 săptămâni în urmă
părinte
comite
37692a1c04

+ 21 - 16
api-v13/app/Http/Controllers/ProgressController.php

@@ -2,16 +2,30 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Resources\ProgressV3Resource;
 use App\Models\ProgressChapter;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
+use Illuminate\Validation\ValidationException;
 
 class ProgressController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 列出章节翻译进度
      *
-     * @return Response
+     * 按 channel 查询各章节的翻译完成度。view 目前只支持 channel 一种口径,
+     * 传其它值返回 422。
+     *
+     * @unauthenticated
+     *
+     * @queryParam view string required 查询口径,目前只有 channel。Enum: channel
+     * @queryParam channels string required channel uid 列表,**下划线分隔**(不是逗号)
+     * @queryParam level integer 只返回该层级及以上的章节,需联查 pali_texts
+     * @queryParam lang string 按译文语言过滤。Example: zh-Hans
+     * @queryParam book integer 按典籍 id 过滤
+     * @queryParam order string 排序字段(progress_chapters 的列)。Default: completed_at
+     * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量。Default: 10
      */
     public function index(Request $request)
     {
@@ -33,7 +47,7 @@ class ProgressController extends Controller
                     ->whereIn('progress_chapters.channel_id', explode('_', $request->input('channels', '')));
                 break;
             default:
-                return $this->error('invalid view', 400, 400);
+                throw ValidationException::withMessages(['view' => __('site.invalid_parameter')]);
                 break;
         }
 
@@ -50,23 +64,14 @@ class ProgressController extends Controller
         if ($request->has('book')) {
             $table = $table->where('progress_chapters.book', $request->input('book'));
         }
-        $count = $table->count();
-
         $table = $table->orderBy(
             'progress_chapters.'.$request->input('order', 'completed_at'),
             $request->input('dir', 'desc')
         );
 
-        $table = $table->skip($request->input('offset', 0))
-            ->take($request->input('limit', 10));
-
-        $result = $table->get();
-
-        return $this->ok(
-            [
-                'rows' => $result->toArray(),
-                'total' => $count,
-            ]
+        // 分页与 meta 全由框架算;page 参数 paginate() 自己会读
+        return ProgressV3Resource::collection(
+            $table->paginate($request->integer('per_page', 10))
         );
     }
 

+ 46 - 30
api-v13/app/Http/Controllers/SearchPlusController.php

@@ -3,8 +3,8 @@
 namespace App\Http\Controllers;
 
 use App\DTO\Search\HitItemDTO;
+use App\Http\Resources\V3Resource;
 use App\Services\OpenSearchService;
-use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 
@@ -16,28 +16,29 @@ class SearchPlusController extends Controller
     public function __construct(protected OpenSearchService $searchService) {}
 
     /**
-     * Display a listing of the resource.
+     * 全文检索
      *
-     * 处理搜索请求,支持 fuzzy / exact / semantic / hybrid 四种模式。
-     * 接收查询参数并调用 OpenSearchService 执行搜索。
+     * 支持 fuzzy / exact / semantic / hybrid 四种检索模式,底层走 OpenSearch。
+     * 同样的参数也可以用 POST /v3/search 提交(参数多、URL 放不下时用)。
      *
-     * 支持 GET 和 POST 请求
+     * @unauthenticated
      *
-     * @param  Request  $request
-     *                            - q (string): 搜索关键词
-     *                            - resource_type (string): 资源类型 (article|term|dictionary|translation|origin_text|nissaya)
-     *                            - granularity (string): 文档颗粒度 (book|chapter|sutta|section|paragraph|sentence)
-     *                            - language (string): 语言,如 pali, zh-Hans, zh-Hant, en-US, my
-     *                            - category (string): 文档分类 (pali|commentary|subcommentary)
-     *                            - tags (array): 标签过滤
-     *                            - page_refs (array): 页码标记 ["V3.81","M3.58"]
-     *                            - related_id (array): 关联 ID,如 ["chapter_93-5","m.n. 38"]
-     *                            - author (string): 作者或译者 (metadata.author)
-     *                            - channel (string): 来源渠道 (metadata.channel)
-     *                            - page (int): 页码,默认 1
-     *                            - page_size (int): 每页数量,默认 20,最大 100
-     *                            - search_mode (string): fuzzy|exact|semantic|hybrid,默认 fuzzy
-     * @return JsonResponse
+     * @queryParam q string required 搜索关键词
+     * @queryParam search_mode string 检索模式。Enum: fuzzy,exact,semantic,hybrid Default: fuzzy
+     * @queryParam resource_type string 资源类型。
+     *             Enum: article,term,dictionary,translation,origin_text,nissaya
+     * @queryParam resource_id string 限定某个资源 id
+     * @queryParam granularity string 文档颗粒度。
+     *             Enum: book,chapter,sutta,section,paragraph,sentence
+     * @queryParam language string 语言。Example: zh-Hans
+     * @queryParam category string 文档分类,逗号分隔。Enum: pali,commentary,subcommentary
+     * @queryParam tags string 标签过滤,逗号分隔
+     * @queryParam page_refs array 页码标记。Example: ["V3.81","M3.58"]
+     * @queryParam related_id array 关联 id。Example: ["chapter_93-5","m.n. 38"]
+     * @queryParam author string 按作者或译者过滤
+     * @queryParam channel string 按来源 channel 过滤
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam page_size integer 每页数量,上限 100。Default: 20
      */
     public function index(Request $request)
     {
@@ -104,14 +105,26 @@ class SearchPlusController extends Controller
     }
 
     /**
-     * Store a newly created resource in storage.
+     * 全文检索(POST)
      *
-     * POST 方式调用搜索接口(与 index 方法功能相同)
+     * 与 `GET /v3/search` 完全等价,只是把参数放在请求体里——参数多、
+     * 尤其是 page_refs / related_id 这类数组时,URL 放不下。
      *
-     * @route POST /api/search
+     * @unauthenticated
      *
-     * @param JSON: 搜索参数(与 index 方法参数相同)
-     * @return JsonResponse
+     * @bodyParam q string required 搜索关键词
+     * @bodyParam search_mode string 检索模式。Enum: fuzzy,exact,semantic,hybrid Default: fuzzy
+     * @bodyParam resource_type string 资源类型
+     * @bodyParam granularity string 文档颗粒度
+     * @bodyParam language string 语言
+     * @bodyParam category string 文档分类,逗号分隔
+     * @bodyParam tags string 标签,逗号分隔
+     * @bodyParam page_refs array 页码标记
+     * @bodyParam related_id array 关联 id
+     * @bodyParam author string 作者或译者
+     * @bodyParam channel string 来源 channel
+     * @bodyParam page integer 页码。Default: 1
+     * @bodyParam page_size integer 每页数量,上限 100。Default: 20
      */
     public function store(Request $request)
     {
@@ -120,18 +133,21 @@ class SearchPlusController extends Controller
     }
 
     /**
-     * Display the specified resource.
+     * 按 id 取单条检索结果
      *
-     * @param  int  $id
-     * @return Response
+     * 直接从 OpenSearch 按文档 id 取回,不走检索。取不到返回 404。
+     *
+     * @unauthenticated
+     *
+     * @urlParam search string required OpenSearch 文档 id
      */
     public function show($id)
     {
         //
         try {
-            return $this->ok(HitItemDTO::fromArray($this->searchService->get($id)));
+            return V3Resource::make(HitItemDTO::fromArray($this->searchService->get($id)));
         } catch (\Throwable $th) {
-            return $this->error("no such index {$id}", 404, 404);
+            abort(404, __('site.not_found'));
         }
     }
 

+ 10 - 12
api-v13/app/Http/Controllers/SearchSuggestController.php

@@ -5,7 +5,6 @@
 namespace App\Http\Controllers;
 
 use App\Services\OpenSearchService;
-use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 
 /**
@@ -75,19 +74,18 @@ GET /api/v2/suggest?q=M.1&fields=page_refs&language=pali&limit=5
 GET /api/v2/suggest?q=dhamma&fields[]=title&fields[]=content&limit=10
      */
     /**
-     * 自动建议接口
+     * 搜索建议(自动补全)
      *
-     * 基于 OpenSearch completion suggester,支持从不同字段获取建议。
+     * 按前缀给出补全候选,供搜索框实时提示。每个字段各自返回一组建议。
+     * q 为空时返回 400。
      *
-     * @param  Request  $request
-     *                            - q (string): 输入的部分文本(必填)
-     *                            - fields (string|array): 要查询的字段,可选值:
-     *                            - 不传:查询所有字段 (title, content, page_refs)
-     *                            - 单个字段:'title' | 'content' | 'page_refs'
-     *                            - 多个字段:'title,content' 或 ['title', 'content']
-     *                            - language (string): 语言过滤,可选(如:pali, zh, en)
-     *                            - limit (int): 每个字段返回的建议数量,默认 10,最大 50
-     * @return JsonResponse
+     * @unauthenticated
+     *
+     * @queryParam q string required 已输入的查询文本
+     * @queryParam fields string 在哪些字段上补全,逗号分隔;不传则全查。
+     *             Enum: title,content,page_refs
+     * @queryParam language string 语言过滤。Example: pali
+     * @queryParam limit integer 每个字段返回的建议数,上限 50。Default: 10
      */
     public function index(Request $request)
     {

+ 56 - 26
api-v13/app/Http/Controllers/TipitakaReadChapterController.php

@@ -2,22 +2,38 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Resources\V3Resource;
 use App\Models\PaliText;
 use App\Services\PaliContentService;
-use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
 
 class TipitakaReadChapterController extends Controller
 {
     /**
-     * 阅读模式章节内容。输入章节起始 book/para,按 pagesize 分批返回段落。
+     * 按章节读取译文
      *
-     * pagesize 两种写法:
-     *   - `20000b` 按字节,累加 pali_texts.lenght 直到超过上限(每页至少一段)
-     *   - `10p`    按段落数
+     * 给定 book + 段落号,定位它所属的章节,返回该章节的段落内容并分页。
+     * 章节不存在或没有内容返回 404。
+     *
+     * 分页是手工做的:按字节时从头累加 `pali_texts.lenght` 逐页推进,所以 meta
+     * 里用 `page_size`(领域语法,非整数)而不是框架的 `per_page`,并附带
+     * `first_para` / `last_para` / `has_more`。
+     *
+     * @unauthenticated
+     *
+     * @queryParam book integer required 典籍 id
+     * @queryParam para integer required 章节内任一段落号,用于定位章节
+     * @queryParam channel string required 译文 channel 的 uuid
+     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
+     * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
+     *             Enum: display,sentences,all Default: display
+     * @queryParam pagesize string 每页大小,两种写法:`20000b` 按字节累加段落长度
+     *             (每页至少一段),`10p` 按段落数。Default: 20000b
+     * @queryParam page integer 页码,从 1 开始。超出范围返回 422。Default: 1
      */
-    public function index(Request $request, PaliContentService $paliService): JsonResponse
+    public function index(Request $request, PaliContentService $paliService)
     {
         $data = $request->validate([
             'book' => 'required|integer',
@@ -39,17 +55,32 @@ class TipitakaReadChapterController extends Controller
     }
 
     /**
-     * 同 index,id 格式 {book}-{para}
+     * 按 id 读取章节
+     *
+     * 与 index 等价,只是把 book 与 para 合成一个路径参数。格式不对或 channel
+     * 不是 uuid 返回 422。
+     *
+     * @unauthenticated
+     *
+     * @urlParam tipitaka_read_chapter string required 章节 id,格式 {book}-{para}。Example: 9002-1
+     *
+     * @queryParam channel string required 译文 channel 的 uuid
+     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
+     * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
+     *             Enum: display,sentences,all Default: display
+     * @queryParam pagesize string 每页大小,两种写法:`20000b` 按字节累加段落长度
+     *             (每页至少一段),`10p` 按段落数。Default: 20000b
+     * @queryParam page integer 页码,从 1 开始。超出范围返回 422。Default: 1
      */
-    public function show(Request $request, string $id, PaliContentService $paliService): JsonResponse
+    public function show(Request $request, string $id, PaliContentService $paliService)
     {
         $arrId = explode('-', $id);
         if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            return $this->error('invalid id');
+            throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
         }
         $channel = $request->input('channel');
         if (! Str::isUuid($channel)) {
-            return $this->error('invalid channel');
+            throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
         }
 
         return $this->chapter(
@@ -70,7 +101,7 @@ class TipitakaReadChapterController extends Controller
         string $channel,
         array $param,
         PaliContentService $paliService
-    ): JsonResponse {
+    ) {
         $format = $param['format'] ?? 'html';
         $view = $param['view'] ?? 'display';
         $pageSize = $param['pagesize'] ?? '10p';
@@ -78,7 +109,7 @@ class TipitakaReadChapterController extends Controller
 
         $chapter = PaliText::where('book', $book)->where('paragraph', $para)->first();
         if (! $chapter) {
-            return $this->error('chapter not found');
+            abort(404, __('site.not_found'));
         }
         $to = $para + max(1, (int) $chapter->chapter_len) - 1;
 
@@ -90,12 +121,12 @@ class TipitakaReadChapterController extends Controller
             ->all();
         $total = count($paragraphs);
         if ($total === 0) {
-            return $this->error('chapter is empty');
+            abort(404, __('site.not_found'));
         }
 
         $slice = $this->slice($paragraphs, $pageSize, $page);
         if ($slice === null) {
-            return $this->error('page out of range');
+            throw ValidationException::withMessages(['page' => __('site.invalid_parameter')]);
         }
 
         $items = [];
@@ -110,18 +141,17 @@ class TipitakaReadChapterController extends Controller
         $first = $slice[0]->paragraph;
         $last = $slice[count($slice) - 1]->paragraph;
 
-        return $this->ok([
-            'items' => $items,
-            'pagination' => [
-                'page' => $page,
-                'pageSize' => $pageSize,
-                'total' => $total,
-                'book' => $book,
-                'from' => (int) $first,
-                'to' => (int) $last,
-                'hasMore' => $last < $paragraphs[$total - 1]->paragraph,
-            ],
-        ]);
+        return V3Resource::collection($items)->additional(['meta' => [
+            'current_page' => $page,
+            'total' => $total,
+            // pagesize 是领域特有语法("2p" = 2 个段落,纯数字 = 字节数),
+            // 不是 Laravel 的整数 per_page,所以单列一个键
+            'page_size' => $pageSize,
+            'book' => $book,
+            'first_para' => (int) $first,
+            'last_para' => (int) $last,
+            'has_more' => $last < $paragraphs[$total - 1]->paragraph,
+        ]]);
     }
 
     /**

+ 43 - 18
api-v13/app/Http/Controllers/TipitakaReadParaController.php

@@ -2,17 +2,33 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Resources\V3Resource;
 use App\Services\PaliContentService;
-use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
 
 class TipitakaReadParaController extends Controller
 {
     /**
-     * 阅读模式段落内容列表。指定 book 段落区间和 channel
+     * 按段落区间读取译文
+     *
+     * 阅读模式下按 book + 段落区间取内容,空段落会被跳过。区间由 para 到 to,
+     * 不传 to 则只取一段;to 小于 para 返回 422。
+     *
+     * 这个接口不走 Eloquent 分页,meta 是手工给的:一次请求即一页。
+     *
+     * @unauthenticated
+     *
+     * @queryParam book integer required 典籍 id
+     * @queryParam para integer required 起始段落号
+     * @queryParam to integer 结束段落号(含)。不传则等于 para
+     * @queryParam channel string required 译文 channel 的 uuid
+     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
+     * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
+     *             Enum: display,sentences,all Default: display
      */
-    public function index(Request $request, PaliContentService $paliService): JsonResponse
+    public function index(Request $request, PaliContentService $paliService)
     {
         $data = $request->validate([
             'book' => 'required|integer',
@@ -26,7 +42,7 @@ class TipitakaReadParaController extends Controller
         $from = $data['para'];
         $to = $data['to'] ?? $from;
         if ($to < $from) {
-            return $this->error('invalid paragraph range');
+            throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
         }
         $format = $data['format'] ?? 'html';
         $view = $data['view'] ?? 'display';
@@ -45,28 +61,37 @@ class TipitakaReadParaController extends Controller
             $items[] = $this->filterView($paragraph, $view);
         }
 
-        return $this->ok([
-            'items' => $items,
-            'pagination' => [
-                'page' => 1,
-                'pageSize' => $to - $from + 1,
-                'total' => count($items),
-            ],
-        ]);
+        return V3Resource::collection($items)->additional(['meta' => [
+            'current_page' => 1,
+            'per_page' => $to - $from + 1,
+            'total' => count($items),
+            'last_page' => 1,
+        ]]);
     }
 
     /**
-     * 单个段落内容。id 格式 {book}-{para}
+     * 读取单个段落
+     *
+     * id 是 `{book}-{para}` 复合形式,例如 `9001-1`。格式不对或 channel 不是
+     * uuid 返回 422;段落无内容返回 404。
+     *
+     * @unauthenticated
+     *
+     * @urlParam tipitaka_read_para string required 段落 id,格式 {book}-{para}。Example: 9001-1
+     *
+     * @queryParam channel string required 译文 channel 的 uuid
+     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
+     * @queryParam view string 输出口径。Enum: display,sentences,all Default: display
      */
-    public function show(Request $request, string $id, PaliContentService $paliService): JsonResponse
+    public function show(Request $request, string $id, PaliContentService $paliService)
     {
         $arrId = explode('-', $id);
         if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            return $this->error('invalid id');
+            throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
         }
         $channel = $request->input('channel');
         if (! Str::isUuid($channel)) {
-            return $this->error('invalid channel');
+            throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
         }
         $book = (int) $arrId[0];
         $para = (int) $arrId[1];
@@ -78,10 +103,10 @@ class TipitakaReadParaController extends Controller
             $request->input('format', 'html')
         );
         if (empty($paragraph['display'])) {
-            return $this->error('no data');
+            abort(404, __('site.not_found'));
         }
 
-        return $this->ok($this->filterView($paragraph, $request->input('view', 'display')));
+        return V3Resource::make($this->filterView($paragraph, $request->input('view', 'display')));
     }
 
     /**

+ 7 - 2
api-v13/app/Http/Controllers/UpgradeController.php

@@ -4,17 +4,22 @@ namespace App\Http\Controllers;
 
 use App\Http\Requests\StoreUpgradeRequest;
 use App\Http\Requests\UpdateUpgradeRequest;
+use App\Http\Resources\V3Resource;
 use App\Models\Upgrade;
 
 class UpgradeController extends Controller
 {
     /**
-     * Display a listing of the resource.
+     * 客户端升级检查
+     *
+     * 目前只回报服务可用,尚未接入版本比对逻辑。
+     *
+     * @unauthenticated
      */
     public function index()
     {
         //
-        return $this->ok(['data' => 'ok']);
+        return V3Resource::make(['status' => 'ok']);
     }
 
     /**

+ 43 - 0
api-v13/app/Http/Resources/ProgressV3Resource.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+
+/**
+ * 章节翻译进度。
+ *
+ * 类型取自 progress_chapters 表:progress 是 double,book / para 是整数,
+ * 时间字段可能为 null(尚未完成)。
+ */
+class ProgressV3Resource extends V3Resource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     book: int,
+     *     para: int,
+     *     lang: string,
+     *     progress: float,
+     *     channel_id: string,
+     *     title: string|null,
+     *     last_chapter_completed_at: string|null,
+     *     completed_at: string|null,
+     *     updated_at: string
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'book' => (int) $this->book,
+            'para' => (int) $this->para,
+            'lang' => $this->lang,
+            'progress' => (float) $this->progress,
+            'channel_id' => $this->channel_id,
+            'title' => $this->title,
+            'last_chapter_completed_at' => $this->last_chapter_completed_at,
+            'completed_at' => $this->completed_at,
+            'updated_at' => $this->updated_at,
+        ];
+    }
+}

+ 9 - 4
api-v13/routes/api.php

@@ -49,6 +49,7 @@ use App\Http\Controllers\GroupController;
 use App\Http\Controllers\GroupMemberController;
 use App\Http\Controllers\HealthCheckController;
 use App\Http\Controllers\HeartbeatController;
+use App\Http\Controllers\HeartbeatV3Controller;
 use App\Http\Controllers\InteractiveController;
 use App\Http\Controllers\InviteController;
 use App\Http\Controllers\LikeController;
@@ -343,10 +344,14 @@ Route::group([
     'prefix' => 'v3',
     'as' => 'v3.',
 ], function () {
-    Route::apiResource('search', SearchPlusController::class);
-    Route::apiResource('search-suggest', SearchSuggestController::class);
-    Route::apiResource('upgrade', UpgradeController::class);
-    Route::apiResource('progress', ProgressController::class);
+    // 只注册真正实现了的动作——空方法会在 OpenAPI 里变成幽灵端点
+    Route::apiResource('search', SearchPlusController::class)->only(['index', 'store', 'show']);
+    Route::apiResource('search-suggest', SearchSuggestController::class)->only(['index']);
+    Route::apiResource('upgrade', UpgradeController::class)->only(['index']);
+    Route::apiResource('progress', ProgressController::class)->only(['index']);
     Route::apiResource('tipitaka-read-para', TipitakaReadParaController::class)->only(['index', 'show']);
     Route::apiResource('tipitaka-read-chapter', TipitakaReadChapterController::class)->only(['index', 'show']);
+
+    // 存活检查是单例资源(只有一个实例),不是集合
+    Route::apiSingleton('heartbeat', HeartbeatV3Controller::class)->only(['show']);
 });

+ 28 - 22
api-v13/tests/Feature/TipitakaReadChapterTest.php

@@ -47,22 +47,24 @@ it('pages a chapter by paragraph count', function () {
     $channel = makeChapterFixture();
     $url = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2p";
 
-    $first = $this->getJson($url)->assertOk()->json('data');
-    expect(array_column($first['items'], 'para'))->toBe([1, 2]);
-    expect($first['pagination'])->toMatchArray([
-        'page' => 1,
-        'pageSize' => '2p',
+    $first = $this->getJson($url)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 2]);
+    expect($first['meta'])->toMatchArray([
+        'current_page' => 1,
+        'page_size' => '2p',
         'total' => 5,
-        'from' => 1,
-        'to' => 2,
-        'hasMore' => true,
+        'first_para' => 1,
+        'last_para' => 2,
+        'has_more' => true,
     ]);
 
-    $last = $this->getJson($url.'&page=3')->assertOk()->json('data');
-    expect(array_column($last['items'], 'para'))->toBe([5]);
-    expect($last['pagination']['hasMore'])->toBeFalse();
+    $last = $this->getJson($url.'&page=3')->assertOk()->json();
+    expect(array_column($last['data'], 'para'))->toBe([5]);
+    expect($last['meta']['has_more'])->toBeFalse();
 
-    $this->getJson($url.'&page=4')->assertJsonPath('ok', false);
+    $this->getJson($url.'&page=4')
+        ->assertStatus(422)
+        ->assertJsonPath('errors.page.0', __('site.invalid_parameter'));
 });
 
 it('pages a chapter by byte size using the lenght column', function () {
@@ -70,19 +72,19 @@ it('pages a chapter by byte size using the lenght column', function () {
     // 每段 100 字节,250b 累加到第 3 段才超过上限
     $url = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250b";
 
-    $first = $this->getJson($url)->assertOk()->json('data');
-    expect(array_column($first['items'], 'para'))->toBe([1, 2, 3]);
+    $first = $this->getJson($url)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 2, 3]);
 
-    $second = $this->getJson($url.'&page=2')->assertOk()->json('data');
-    expect(array_column($second['items'], 'para'))->toBe([4, 5]);
-    expect($second['pagination']['hasMore'])->toBeFalse();
+    $second = $this->getJson($url.'&page=2')->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([4, 5]);
+    expect($second['meta']['has_more'])->toBeFalse();
 });
 
 it('returns at least one paragraph when it alone exceeds the byte size', function () {
     $channel = makeChapterFixture();
     $items = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=1b")
         ->assertOk()
-        ->json('data.items');
+        ->json('data');
 
     expect(array_column($items, 'para'))->toBe([1]);
 });
@@ -91,7 +93,7 @@ it('accepts the id form and the view parameter', function () {
     $channel = makeChapterFixture();
     $items = $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&pagesize=1p&view=all")
         ->assertOk()
-        ->json('data.items');
+        ->json('data');
 
     expect($items)->toHaveCount(1);
     expect($items[0])->toHaveKeys(['para', 'display', 'sentences']);
@@ -99,12 +101,16 @@ it('accepts the id form and the view parameter', function () {
 
 it('rejects a bad id, channel, pagesize or unknown chapter', function () {
     $channel = makeChapterFixture();
+    // v3 用真实状态码 + RFC 9457 Problem Details,不再看 ok 字段
     $this->getJson("/api/v3/tipitaka-read-chapter/bad-id?channel={$channel}")
-        ->assertJsonPath('ok', false);
+        ->assertStatus(422)
+        ->assertJsonStructure(['type', 'title', 'status', 'detail']);
     $this->getJson('/api/v3/tipitaka-read-chapter/9002-1?channel=not-a-uuid')
-        ->assertJsonPath('ok', false);
+        ->assertStatus(422)
+        ->assertJsonPath('errors.channel.0', __('site.invalid_parameter'));
     $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=99&channel={$channel}")
-        ->assertJsonPath('ok', false);
+        ->assertStatus(404)
+        ->assertJsonPath('title', 'Resource not found.');
     $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=20000")
         ->assertStatus(422);
 });

+ 10 - 5
api-v13/tests/Feature/TipitakaReadParaTest.php

@@ -75,7 +75,7 @@ it('can output only the sentences or both', function () {
 
     $items = $this->getJson("/api/v3/tipitaka-read-para?book=9001&para=1&channel={$channel}&view=sentences")
         ->assertOk()
-        ->json('data.items');
+        ->json('data');
     expect($items[0])->not->toHaveKey('display');
     expect($items[0]['sentences'])->toHaveCount(2);
 });
@@ -84,7 +84,7 @@ it('lists the paragraphs of a range and skips empty ones', function () {
     $channel = makeParagraphFixture();
     $items = $this->getJson("/api/v3/tipitaka-read-para?book=9001&para=1&to=3&channel={$channel}")
         ->assertOk()
-        ->json('data.items');
+        ->json('data');
 
     expect(array_column($items, 'para'))->toBe([1, 2]);
 });
@@ -100,12 +100,17 @@ it('outputs one line per sentence for non html formats', function () {
 
 it('rejects an invalid id or channel', function () {
     $channel = makeParagraphFixture();
+    // v3 用真实状态码 + RFC 9457 Problem Details,不再看 ok 字段
     $this->getJson("/api/v3/tipitaka-read-para/bad-id?channel={$channel}")
-        ->assertJsonPath('ok', false);
+        ->assertStatus(422)
+        ->assertJsonStructure(['type', 'title', 'status', 'detail'])
+        ->assertJsonPath('errors.id.0', __('site.invalid_parameter'));
     $this->getJson('/api/v3/tipitaka-read-para/9001-1?channel=not-a-uuid')
-        ->assertJsonPath('ok', false);
+        ->assertStatus(422)
+        ->assertJsonPath('errors.channel.0', __('site.invalid_parameter'));
     $this->getJson("/api/v3/tipitaka-read-para/9001-9?channel={$channel}")
-        ->assertJsonPath('ok', false);
+        ->assertStatus(404)
+        ->assertJsonPath('title', 'Resource not found.');
 });
 
 it('caches the paragraph and drops the cache when a sentence changes', function () {