Przeglądaj źródła

feat(api-v13): tipitaka-read-chapter 改为游标式取块

原来是 page 页码分页 + pagesize 带单位后缀的字符串("20000b" / "10p")。
为支持前端的下载进度条与断点续传,改成游标式,并修掉分页会切出空块的问题。

接口:

- pagesize 拆成整数 pagesize + unit(para / byte),默认 10 段
- page 换成 from 游标,para 仍是章节起始段落号;不传 from 即从章节开头取起,
  from 超出章节区间或其后已无译文返回 422
- meta:current_page / total / has_more 换成 current_para / total_para /
  remaining_para,另加 chapter。进度 = (total_para - remaining_para) / total_para,
  续传把 last_para + 1 当下次的 from

取数区间:

- 段落集合改由 sentences 表决定(该 channel 真正有译文的段落)。多数译文是
  残缺的,原先按 pali_texts 的整段区间切块,落在空白段上的那块会返回空数组
- 字节长度仍取 pali_texts.lenght,sentences.strlen 只用来判断该段有没有译文
- 每块上限:段落 200 段、字节 5000 字节,超出不报错按上限算,
  meta.page_size 回实际生效的值。每段都要单独渲染,块的成本跟段落数成正比

性能(真实库最大章节 book 63,15943 段):段落号查询 33ms(索引命中);
段落模式不再碰 pali_texts,字节模式用 pluck 取长度表 14ms,替掉原先
whereIn 一万五千个值 + hydrate 模型的 153ms。默认 pagesize 整条请求
冷 68ms、热 30ms,翻到最后一块不变慢。

顺带还债:show() 原先用 $request->only 完全不校验查询参数,改走 validate,
两个入口的 422 行为一致;slice() 的 @param 标注原写作 array{paragraph,lenght}
实为模型数组,随重构一并对上;生成器会把 docblock 里的「Default: 等于 para」
当成整数参数的字面默认值写进规格,改成写在描述里。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 tygodni temu
rodzic
commit
b659026b31

+ 168 - 70
api-v13/app/Http/Controllers/TipitakaReadChapterController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Http\Resources\V3Resource;
 use App\Models\PaliText;
+use App\Models\Sentence;
 use App\Services\PaliContentService;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
@@ -11,38 +12,71 @@ use Illuminate\Validation\ValidationException;
 
 class TipitakaReadChapterController extends Controller
 {
+    /**
+     * 字节模式下单块的字节上限。请求超过这个数按这个数算,不报错。
+     */
+    protected const MAX_BYTES_PER_PAGE = 5000;
+
+    /**
+     * 单块最多返回的段落数,两种单位都受它约束。
+     *
+     * 每段要单独渲染一次,块的成本跟段落数成正比而不是跟字节数成正比。段落模式
+     * 直接把 pagesize 压到这个数;字节模式下偈颂体的书平均每段只有 20~30 字节,
+     * 光靠字节上限仍可能切出几百段,所以累加时两个上限谁先到就在哪断块。
+     */
+    protected const MAX_PARAGRAPHS_PER_PAGE = 200;
+
     /**
      * 按章节读取译文
      *
-     * 给定 book + 段落号,定位它所属的章节,返回该章节的段落内容并分页。
-     * 章节不存在或没有内容返回 404。
+     * `para` 定位章节(它是章节的起始段落号,章节长度取那一行的 `chapter_len`),
+     * `from` 是本次取数的起点游标,从它开始取 `pagesize` 大小的一块。章节不存在、
+     * 或该 channel 在这个章节里没有任何译文,返回 404。
+     *
+     * 取数的段落集合来自 sentences 表里该 channel 实际有译文的段落,不是章节的
+     * 全部段落——多数译文是残缺的,按 pali_texts 的整段区间切块会切出空块。
+     * 所以 `pagesize` 数的永远是有内容的段落。
      *
-     * 分页是手工做的:按字节时从头累加 `pali_texts.lenght` 逐页推进,所以 meta
-     * 里用 `page_size`(领域语法,非整数)而不是框架的 `per_page`,并附带
-     * `first_para` / `last_para` / `has_more`。
+     * 游标式分块是为了前端的下载进度条与断点续传,meta 里给三个数:
+     * `total_para` 是分母(该 channel 在本章节有多少段),`remaining_para` 是
+     * `last_para` 之后本章节内还剩多少段,进度即
+     * `(total_para - remaining_para) / total_para`;续传时把上次的
+     * `last_para + 1` 当作下次的 `from` 传回来,`remaining_para` 为 0 即取完。
+     *
+     * `first_para` / `last_para` 是本块实际覆盖的段落闭区间,取自切块结果而不是
+     * `data`:渲染为空的段落会被剔出 `data` 但仍算在块内,所以 `data` 可能比这个
+     * 区间短,而续传必须按 `last_para` 推进,否则会卡在那一段上原地打转。
+     * `first_para` 是游标顺延后的落点,可能大于请求的 `from`(即 `current_para`)。
      *
      * @unauthenticated
      *
      * @queryParam book integer required 典籍 id
-     * @queryParam para integer required 章节内任一段落号,用于定位章节
+     * @queryParam para integer required 章节的起始段落号,决定本次取数的段落区间
+     * @queryParam from integer 起点游标,从这一段开始取;不传则等于 para,即从章节
+     *             开头取起。可以是章节区间内的任意段落号,落在没有译文的段落上就
+     *             顺延到它之后的第一段。超出章节区间、或它之后已经没有译文,
+     *             返回 422。
      * @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
+     * @queryParam pagesize integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
+     *             unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际
+     *             生效的值。Default: 10
+     * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
+     *             两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
      */
     public function index(Request $request, PaliContentService $paliService)
     {
         $data = $request->validate([
             'book' => 'required|integer',
             'para' => 'required|integer',
+            'from' => 'integer|min:1',
             'channel' => 'required|uuid',
             'format' => 'string|in:html,markdown,react,text',
             'view' => 'string|in:display,sentences,all',
-            'pagesize' => ['string', 'regex:/^\d+[bp]$/'],
-            'page' => 'integer|min:1',
+            'pagesize' => 'integer|min:1',
+            'unit' => 'string|in:para,byte',
         ]);
 
         return $this->chapter(
@@ -57,20 +91,24 @@ class TipitakaReadChapterController extends Controller
     /**
      * 按 id 读取章节
      *
-     * 与 index 等价,只是把 book 与 para 合成一个路径参数。格式不对或 channel
-     * 不是 uuid 返回 422。
+     * 与 index 等价,只是把 book 与 para 合成一个路径参数;游标 `from` 仍走
+     * 查询参数。格式不对或 channel 不是 uuid 返回 422。
      *
      * @unauthenticated
      *
      * @urlParam tipitaka_read_chapter string required 章节 id,格式 {book}-{para}。Example: 9002-1
      *
+     * @queryParam from integer 起点游标,从这一段开始取;不传则从章节开头取起。
+     *             超出章节区间返回 422。
      * @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
+     * @queryParam pagesize integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
+     *             unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际
+     *             生效的值。Default: 10
+     * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
+     *             两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
      */
     public function show(Request $request, string $id, PaliContentService $paliService)
     {
@@ -83,17 +121,25 @@ class TipitakaReadChapterController extends Controller
             throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
         }
 
+        $data = $request->validate([
+            'from' => 'integer|min:1',
+            'format' => 'string|in:html,markdown,react,text',
+            'view' => 'string|in:display,sentences,all',
+            'pagesize' => 'integer|min:1',
+            'unit' => 'string|in:para,byte',
+        ]);
+
         return $this->chapter(
             (int) $arrId[0],
             (int) $arrId[1],
             $channel,
-            $request->only(['format', 'view', 'pagesize', 'page']),
+            $data,
             $paliService
         );
     }
 
     /**
-     * @param  array{format?: string, view?: string, pagesize?: string, page?: int|string}  $param
+     * @param  array{from?: int|string, format?: string, view?: string, pagesize?: int|string, unit?: string}  $param
      */
     protected function chapter(
         int $book,
@@ -104,97 +150,149 @@ class TipitakaReadChapterController extends Controller
     ) {
         $format = $param['format'] ?? 'html';
         $view = $param['view'] ?? 'display';
-        $pageSize = $param['pagesize'] ?? '10p';
-        $page = max(1, (int) ($param['page'] ?? 1));
+        $unit = $param['unit'] ?? 'para';
+        // 超上限不报错,按上限算;meta.page_size 回的是实际生效的值
+        $pageSize = min(
+            max(1, (int) ($param['pagesize'] ?? 10)),
+            $unit === 'byte' ? self::MAX_BYTES_PER_PAGE : self::MAX_PARAGRAPHS_PER_PAGE
+        );
+        // 游标默认落在章节开头,即第一次调用不必传 para
+        $cursor = max(1, (int) ($param['from'] ?? $para));
 
-        $chapter = PaliText::where('book', $book)->where('paragraph', $para)->first();
-        if (! $chapter) {
+        $head = PaliText::where('book', $book)->where('paragraph', $para)->first();
+        if (! $head) {
             abort(404, __('site.not_found'));
         }
-        $to = $para + max(1, (int) $chapter->chapter_len) - 1;
+        $to = $para + max(1, (int) $head->chapter_len) - 1;
+
+        // 游标必须落在本章节的段落区间里,越界是调用方算错了,直接报错而不是
+        // 悄悄钳到边界——断点续传时静默纠偏会把 bug 藏起来
+        if ($cursor < $para || $cursor > $to) {
+            throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]);
+        }
 
-        /** @var array<int, array{paragraph: int, lenght: int}> $paragraphs */
-        $paragraphs = PaliText::where('book', $book)
+        // 只取段落号,不取句子内容——最大的章节有一万五千段,整章的句子拉回来没有意义。
+        // 整章都要查,因为 total_para 是进度条的分母,不能只算游标之后的部分。
+        /** @var array<int, int> $translated 升序段落号 */
+        $translated = Sentence::where('book_id', $book)
             ->whereBetween('paragraph', [$para, $to])
+            ->where('channel_uid', $channel)
+            ->where('strlen', '>', 0)
+            ->distinct()
             ->orderBy('paragraph')
-            ->get(['paragraph', 'lenght'])
+            ->pluck('paragraph')
+            ->map(fn ($paragraph) => (int) $paragraph)
             ->all();
-        $total = count($paragraphs);
+
+        $total = count($translated);
         if ($total === 0) {
             abort(404, __('site.not_found'));
         }
 
-        $slice = $this->slice($paragraphs, $pageSize, $page);
-        if ($slice === null) {
-            throw ValidationException::withMessages(['page' => __('site.invalid_parameter')]);
+        $offset = $this->cursorOffset($translated, $cursor);
+        if ($offset === null) {
+            throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]);
         }
 
+        // 只有按字节切块才需要原文长度,按段落切块完全不必碰 pali_texts
+        $lengths = $unit === 'byte' ? $this->paragraphLengths($book, $translated) : [];
+        $slice = $this->slice($translated, $lengths, $pageSize, $unit, $offset);
+
         $items = [];
-        foreach ($slice as $row) {
-            $paragraph = $paliService->readParagraph($book, (int) $row->paragraph, $channel, $format);
+        foreach ($slice as $paragraphNumber) {
+            $paragraph = $paliService->readParagraph($book, $paragraphNumber, $channel, $format);
             if (empty($paragraph['display'])) {
                 continue;
             }
             $items[] = $this->filterView($paragraph, $view);
         }
 
-        $first = $slice[0]->paragraph;
-        $last = $slice[count($slice) - 1]->paragraph;
-
         return V3Resource::collection($items)->additional(['meta' => [
-            'current_page' => $page,
-            'total' => $total,
-            // pagesize 是领域特有语法("2p" = 2 个段落,纯数字 = 字节数),
-            // 不是 Laravel 的整数 per_page,所以单列一个键
+            // 请求的游标 from 原样回显;实际取到的第一段看 first_para(游标
+            // 落在没有译文的段落上时两者不同)
+            'current_para' => $cursor,
+            // 该 channel 在本章节内有译文的段落总数,进度条的分母
+            'total_para' => $total,
+            // 每块大小带单位(段落数或字节数),不是 Laravel 的 per_page,
+            // 所以单列 page_size + page_size_unit 两个键
             'page_size' => $pageSize,
+            'page_size_unit' => $unit,
             'book' => $book,
-            'first_para' => (int) $first,
-            'last_para' => (int) $last,
-            'has_more' => $last < $paragraphs[$total - 1]->paragraph,
+            'chapter' => $para,
+            // 本块实际覆盖的段落区间,两端都是闭区间、都是真实存在的段落号。
+            // 取自切出来的这一块,不是取自 data:段落渲染出来是空的会被剔出
+            // data,但仍算在本块之内,last_para 照样要跨过它——否则续传会卡在
+            // 那一段上原地打转。所以 data 可能比 first_para..last_para 短。
+            // first_para 是游标顺延后的落点,可能大于请求的 current_para;
+            // last_para + 1 就是下一块要传的 from。
+            'first_para' => $slice[0],
+            'last_para' => $slice[count($slice) - 1],
+            // last_para 之后本章节内还剩多少段,0 即取完;续传把 last_para + 1 当 from 传回
+            'remaining_para' => $total - $offset - count($slice),
         ]]);
     }
 
     /**
-     * 取第 $page 批段落。按段落数时直接切片,按字节时从头累加 lenght 逐页推进。
+     * 游标 $cursor 落在升序段落号里的下标:它本身有译文就是它,否则顺延到之后的
+     * 第一段。$cursor 已在章节区间内(调用方先校验过),但可能落在最后一段译文
+     * 之后,那时返回 null。
      *
-     * @param  array<int, PaliText>  $paragraphs
-     * @return array<int, PaliText>|null 页码越界时返回 null
+     * @param  array<int, int>  $paragraphs  升序段落号
      */
-    protected function slice(array $paragraphs, string $pageSize, int $page): ?array
+    protected function cursorOffset(array $paragraphs, int $cursor): ?int
     {
-        $limit = (int) substr($pageSize, 0, -1);
-        $unit = substr($pageSize, -1);
-        if ($limit < 1) {
-            return null;
+        foreach ($paragraphs as $offset => $paragraph) {
+            if ($paragraph >= $cursor) {
+                return $offset;
+            }
         }
 
-        if ($unit === 'p') {
-            $offset = ($page - 1) * $limit;
-            $slice = array_slice($paragraphs, $offset, $limit);
+        return null;
+    }
+
+    /**
+     * 取这些段落的原文字节数。按首尾段落号一次查回整段区间,避免几千个值的
+     * IN 列表;区间里没有译文的段落多查回来几行,比拼 IN 便宜得多。
+     *
+     * @param  array<int, int>  $paragraphs  升序段落号,至少一个
+     * @return array<int, int> 段落号 => 原文字节数
+     */
+    protected function paragraphLengths(int $book, array $paragraphs): array
+    {
+        return PaliText::where('book', $book)
+            ->whereBetween('paragraph', [$paragraphs[0], $paragraphs[count($paragraphs) - 1]])
+            ->pluck('lenght', 'paragraph')
+            ->all();
+    }
 
-            return $slice === [] ? null : $slice;
+    /**
+     * 从 $offset 起取一块段落号。按段落数时直接切片,按字节时累加原文长度,
+     * 字节数或段落数任一到顶即断块,每块至少一段。
+     *
+     * @param  array<int, int>  $paragraphs  升序段落号
+     * @param  array<int, int>  $lengths  段落号 => 原文字节数,只在 byte 模式用得上
+     * @param  string  $unit  para 按段落数,byte 按字节数
+     * @param  int  $offset  起点下标,调用方保证在范围内
+     * @return array<int, int> 非空
+     */
+    protected function slice(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array
+    {
+        if ($unit !== 'byte') {
+            return array_slice($paragraphs, $offset, max(1, min($limit, self::MAX_PARAGRAPHS_PER_PAGE)));
         }
 
-        // 字节模式:每页累加 lenght,超过上限即断页,每页至少一段
-        $offset = 0;
+        $bytes = 0;
+        $take = 0;
         $count = count($paragraphs);
-        for ($current = 1; $offset < $count; $current++) {
-            $bytes = 0;
-            $take = 0;
-            while ($offset + $take < $count) {
-                $bytes += (int) $paragraphs[$offset + $take]->lenght;
-                $take++;
-                if ($bytes >= $limit) {
-                    break;
-                }
+        while ($offset + $take < $count) {
+            $bytes += (int) ($lengths[$paragraphs[$offset + $take]] ?? 0);
+            $take++;
+            if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) {
+                break;
             }
-            if ($current === $page) {
-                return array_slice($paragraphs, $offset, $take);
-            }
-            $offset += $take;
         }
 
-        return null;
+        return array_slice($paragraphs, $offset, $take);
     }
 
     /**

+ 164 - 29
api-v13/tests/Feature/TipitakaReadChapterTest.php

@@ -27,79 +27,201 @@ function makePaliText(int $book, int $para, int $lenght, ?int $chapterLen = null
 }
 
 /**
- * book 9002 一个 5 段的章节,每段 100 字节,每段一句
+ * book 9002 一个 5 段的章节(起始段落号 1),每段 100 字节。$translated 列出
+ * 哪些段落有译文,默认整章都有;传子集即模拟残缺译文。
+ *
+ * @param  array<int, int>  $translated
  */
-function makeChapterFixture(): string
+function makeChapterFixture(array $translated = [1, 2, 3, 4, 5]): string
 {
     $channel = makeChannel(makeStudio('chapter-owner'), 'chapter channel');
     makePaliText(9002, 1, 100, 5);
     foreach (range(2, 5) as $para) {
         makePaliText(9002, $para, 100);
     }
-    foreach (range(1, 5) as $para) {
+    foreach ($translated as $para) {
         makeSentence($channel, 9002, $para, 1, "para {$para} text");
     }
 
     return $channel;
 }
 
-it('pages a chapter by paragraph count', function () {
+it('fetches a block of paragraphs from the cursor, the default unit', function () {
     $channel = makeChapterFixture();
-    $url = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2p";
+    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2";
 
-    $first = $this->getJson($url)->assertOk()->json();
+    // 不传 from,游标默认落在章节开头
+    $first = $this->getJson($base)->assertOk()->json();
     expect(array_column($first['data'], 'para'))->toBe([1, 2]);
     expect($first['meta'])->toMatchArray([
-        'current_page' => 1,
-        'page_size' => '2p',
-        'total' => 5,
+        'current_para' => 1,
+        'total_para' => 5,
+        'page_size' => 2,
+        'page_size_unit' => 'para',
+        'book' => 9002,
+        'chapter' => 1,
         'first_para' => 1,
         'last_para' => 2,
-        'has_more' => true,
+        'remaining_para' => 3,
     ]);
 
-    $last = $this->getJson($url.'&page=3')->assertOk()->json();
+    // 断点续传:把上次的 last_para + 1 当作下次的 from
+    $second = $this->getJson($base.'&from=3')->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([3, 4]);
+    expect($second['meta'])->toMatchArray(['current_para' => 3, 'remaining_para' => 1]);
+
+    $last = $this->getJson($base.'&from=5')->assertOk()->json();
     expect(array_column($last['data'], 'para'))->toBe([5]);
-    expect($last['meta']['has_more'])->toBeFalse();
+    expect($last['meta']['remaining_para'])->toBe(0);
+
+    // 游标越过章节区间(本章节是 1..5)
+    $this->getJson($base.'&from=6')
+        ->assertStatus(422)
+        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
+});
+
+it('rejects a cursor outside the chapter range', function () {
+    $channel = makeChapterFixture();
+
+    // 章节区间是 1..5,超出两端都不行
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&from=99")
+        ->assertStatus(422)
+        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
+
+    // para=3 那一行没有 chapter_len,区间只有它自己;从第 1 段起就在区间之前
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=3&channel={$channel}&from=1")
+        ->assertStatus(422)
+        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
+
+    // 落在区间内就正常取
+    expect($this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=3&channel={$channel}&from=3")
+        ->assertOk()->json('meta'))
+        ->toMatchArray(['chapter' => 3, 'total_para' => 1, 'first_para' => 3, 'remaining_para' => 0]);
+});
+
+it('rejects a cursor that is inside the chapter but past the last translation', function () {
+    // 章节区间 1..5,只译了 1、2、3;游标 5 在区间内但之后没有译文了
+    $channel = makeChapterFixture([1, 2, 3]);
 
-    $this->getJson($url.'&page=4')
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&from=5")
         ->assertStatus(422)
-        ->assertJsonPath('errors.page.0', __('site.invalid_parameter'));
+        ->assertJsonPath('errors.from.0', __('site.invalid_parameter'));
 });
 
-it('pages a chapter by byte size using the lenght column', function () {
+it('advances the cursor to the next translated paragraph', function () {
+    // 只译了 1、3、5;游标落在没有译文的第 2 段,顺延到第 3 段
+    $channel = makeChapterFixture([1, 3, 5]);
+
+    $block = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2&from=2")
+        ->assertOk()
+        ->json();
+
+    expect(array_column($block['data'], 'para'))->toBe([3, 5]);
+    expect($block['meta'])->toMatchArray([
+        'current_para' => 2,   // 请求的游标原样回显
+        'first_para' => 3,     // 实际取到的第一段
+        'last_para' => 5,
+        'total_para' => 3,
+        'remaining_para' => 0,
+    ]);
+});
+
+it('counts only the paragraphs this channel has actually translated', function () {
+    // 5 段的章节里只译了 1、3、5,分块按这 3 段算,不会切出空块
+    $channel = makeChapterFixture([1, 3, 5]);
+    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2";
+
+    $first = $this->getJson($base)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 3]);
+    expect($first['meta'])->toMatchArray([
+        'total_para' => 3,
+        'first_para' => 1,
+        'last_para' => 3,
+        'remaining_para' => 1,
+    ]);
+
+    $second = $this->getJson($base.'&from=4')->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([5]);
+    expect($second['meta']['remaining_para'])->toBe(0);
+});
+
+it('fetches a block by byte size using the lenght column', function () {
     $channel = makeChapterFixture();
-    // 每段 100 字节,250b 累加到第 3 段才超过上限
-    $url = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250b";
+    // 每段 100 字节,250 字节累加到第 3 段才超过上限
+    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250&unit=byte";
 
-    $first = $this->getJson($url)->assertOk()->json();
+    $first = $this->getJson($base)->assertOk()->json();
     expect(array_column($first['data'], 'para'))->toBe([1, 2, 3]);
+    expect($first['meta'])->toMatchArray([
+        'page_size' => 250,
+        'page_size_unit' => 'byte',
+        'remaining_para' => 2,
+    ]);
 
-    $second = $this->getJson($url.'&page=2')->assertOk()->json();
+    $second = $this->getJson($base.'&from=4')->assertOk()->json();
     expect(array_column($second['data'], 'para'))->toBe([4, 5]);
-    expect($second['meta']['has_more'])->toBeFalse();
+    expect($second['meta']['remaining_para'])->toBe(0);
+});
+
+it('counts bytes from pali_texts for the translated paragraphs only', function () {
+    // 只译了 1、3、5;每段原文 100 字节,250b 累加到第三段(para 5)才超限
+    $channel = makeChapterFixture([1, 3, 5]);
+
+    $first = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=250&unit=byte")
+        ->assertOk()
+        ->json();
+
+    expect(array_column($first['data'], 'para'))->toBe([1, 3, 5]);
+    expect($first['meta']['remaining_para'])->toBe(0);
 });
 
 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")
+
+    $items = $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=1&unit=byte")
         ->assertOk()
         ->json('data');
 
     expect(array_column($items, 'para'))->toBe([1]);
 });
 
-it('accepts the id form and the view parameter', function () {
+it('returns 404 when the channel has nothing translated in this chapter', function () {
+    $channel = makeChapterFixture([]);
+
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}")
+        ->assertStatus(404)
+        ->assertJsonPath('title', 'Resource not found.');
+});
+
+it('clamps pagesize to the per-block ceiling and reports the effective value', function () {
     $channel = makeChapterFixture();
-    $items = $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&pagesize=1p&view=all")
+    $base = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}";
+
+    // 段落模式上限 200
+    expect($this->getJson($base.'&pagesize=1000')->assertOk()->json('meta'))
+        ->toMatchArray(['page_size' => 200, 'page_size_unit' => 'para']);
+
+    // 字节模式上限 5000
+    expect($this->getJson($base.'&pagesize=20000&unit=byte')->assertOk()->json('meta'))
+        ->toMatchArray(['page_size' => 5000, 'page_size_unit' => 'byte']);
+
+    // 没到上限的照常返回原值
+    expect($this->getJson($base.'&pagesize=3')->assertOk()->json('meta.page_size'))->toBe(3);
+});
+
+it('accepts the id form, the cursor and the view parameter', function () {
+    $channel = makeChapterFixture();
+
+    $block = $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&pagesize=1&from=2&view=all")
         ->assertOk()
-        ->json('data');
+        ->json();
 
-    expect($items)->toHaveCount(1);
-    expect($items[0])->toHaveKeys(['para', 'display', 'sentences']);
+    expect($block['data'])->toHaveCount(1);
+    expect($block['data'][0])->toHaveKeys(['para', 'display', 'sentences']);
+    expect($block['meta'])->toMatchArray(['chapter' => 1, 'first_para' => 2, 'remaining_para' => 3]);
 });
 
-it('rejects a bad id, channel, pagesize or unknown chapter', function () {
+it('rejects a bad id, channel, pagesize, unit, cursor or unknown chapter', function () {
     $channel = makeChapterFixture();
     // v3 用真实状态码 + RFC 9457 Problem Details,不再看 ok 字段
     $this->getJson("/api/v3/tipitaka-read-chapter/bad-id?channel={$channel}")
@@ -108,9 +230,22 @@ it('rejects a bad id, channel, pagesize or unknown chapter', function () {
     $this->getJson('/api/v3/tipitaka-read-chapter/9002-1?channel=not-a-uuid')
         ->assertStatus(422)
         ->assertJsonPath('errors.channel.0', __('site.invalid_parameter'));
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&channel={$channel}")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['para']]);
     $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=99&channel={$channel}")
         ->assertStatus(404)
         ->assertJsonPath('title', 'Resource not found.');
-    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=20000")
-        ->assertStatus(422);
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=0")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['pagesize']]);
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=10p")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['pagesize']]);
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&unit=bytes")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['unit']]);
+    $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&from=0")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['from']]);
 });

+ 62 - 0
api-v13/tests/Unit/TipitakaReadChapterSliceTest.php

@@ -0,0 +1,62 @@
+<?php
+
+use App\Http\Controllers\TipitakaReadChapterController;
+
+/**
+ * slice() 是纯函数,不碰数据库。段落数上限要造上千段才看得出来,走 HTTP 会真的
+ * 去渲染每一段,所以这里直接对着方法本身测。
+ *
+ * @param  array<int, int>  $paragraphs
+ * @param  array<int, int>  $lengths
+ * @return array<int, int>
+ */
+function sliceParagraphs(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array
+{
+    $controller = new class extends TipitakaReadChapterController
+    {
+        /**
+         * @param  array<int, int>  $paragraphs
+         * @param  array<int, int>  $lengths
+         * @return array<int, int>
+         */
+        public function sliceForTest(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array
+        {
+            return $this->slice($paragraphs, $lengths, $limit, $unit, $offset);
+        }
+    };
+
+    return $controller->sliceForTest($paragraphs, $lengths, $limit, $unit, $offset);
+}
+
+it('caps a byte-mode block at 200 paragraphs however short the paragraphs are', function () {
+    // 1000 段,每段 1 字节;不设段落上限的话 5000b 会把整章切成一块
+    $paragraphs = range(1, 1000);
+    $lengths = array_fill_keys($paragraphs, 1);
+
+    expect(sliceParagraphs($paragraphs, $lengths, 5000, 'byte', 0))->toBe(range(1, 200))
+        ->and(sliceParagraphs($paragraphs, $lengths, 5000, 'byte', 200))->toBe(range(201, 400))
+        ->and(sliceParagraphs($paragraphs, $lengths, 5000, 'byte', 900))->toBe(range(901, 1000));
+});
+
+it('caps a paragraph-mode block at 200 paragraphs', function () {
+    $paragraphs = range(1, 1000);
+
+    expect(sliceParagraphs($paragraphs, [], 1000, 'para', 0))->toBe(range(1, 200))
+        ->and(sliceParagraphs($paragraphs, [], 1000, 'para', 950))->toBe(range(951, 1000));
+});
+
+it('still breaks on the byte limit when paragraphs are long enough', function () {
+    $paragraphs = range(1, 10);
+    $lengths = array_fill_keys($paragraphs, 100);
+
+    expect(sliceParagraphs($paragraphs, $lengths, 250, 'byte', 0))->toBe([1, 2, 3])
+        ->and(sliceParagraphs($paragraphs, $lengths, 250, 'byte', 3))->toBe([4, 5, 6]);
+});
+
+it('never returns an empty byte-mode block when lengths are missing', function () {
+    // pali_texts 里查不到长度时按 0 算,靠段落数上限收口,不会死循环也不会空块
+    $paragraphs = range(1, 1000);
+
+    expect(sliceParagraphs($paragraphs, [], 5000, 'byte', 0))->toBe(range(1, 200))
+        ->and(sliceParagraphs($paragraphs, [], 5000, 'byte', 800))->toBe(range(801, 1000));
+});

+ 30 - 16
dashboard-v6/src/api/schema.d.ts

@@ -7828,11 +7828,21 @@ export interface paths {
         };
         /**
          * 按章节读取译文
-         * @description 给定 book + 段落号,定位它所属的章节,返回该章节的段落内容并分页。
-         *     章节不存在或没有内容返回 404。
-         *     分页是手工做的:按字节时从头累加 `pali_texts.lenght` 逐页推进,所以 meta
-         *     里用 `page_size`(领域语法,非整数)而不是框架的 `per_page`,并附带
-         *     `first_para` / `last_para` / `has_more`。
+         * @description `para` 定位章节(它是章节的起始段落号,章节长度取那一行的 `chapter_len`),
+         *     `from` 是本次取数的起点游标,从它开始取 `pagesize` 大小的一块。章节不存在、
+         *     或该 channel 在这个章节里没有任何译文,返回 404。
+         *     取数的段落集合来自 sentences 表里该 channel 实际有译文的段落,不是章节的
+         *     全部段落——多数译文是残缺的,按 pali_texts 的整段区间切块会切出空块。
+         *     所以 `pagesize` 数的永远是有内容的段落。
+         *     游标式分块是为了前端的下载进度条与断点续传,meta 里给三个数:
+         *     `total_para` 是分母(该 channel 在本章节有多少段),`remaining_para` 是
+         *     `last_para` 之后本章节内还剩多少段,进度即
+         *     `(total_para - remaining_para) / total_para`;续传时把上次的
+         *     `last_para + 1` 当作下次的 `from` 传回来,`remaining_para` 为 0 即取完。
+         *     `first_para` / `last_para` 是本块实际覆盖的段落闭区间,取自切块结果而不是
+         *     `data`:渲染为空的段落会被剔出 `data` 但仍算在块内,所以 `data` 可能比这个
+         *     区间短,而续传必须按 `last_para` 推进,否则会卡在那一段上原地打转。
+         *     `first_para` 是游标顺延后的落点,可能大于请求的 `from`(即 `current_para`)。
          *
          *     实现:`TipitakaReadChapterController@index`
          */
@@ -7854,8 +7864,8 @@ export interface paths {
         };
         /**
          * 按 id 读取章节
-         * @description 与 index 等价,只是把 book 与 para 合成一个路径参数。格式不对或 channel
-         *     不是 uuid 返回 422。
+         * @description 与 index 等价,只是把 book 与 para 合成一个路径参数;游标 `from` 仍走
+         *     查询参数。格式不对或 channel 不是 uuid 返回 422。
          *
          *     实现:`TipitakaReadChapterController@show`
          */
@@ -34160,18 +34170,20 @@ export interface operations {
             query: {
                 /** @description 典籍 id */
                 book: number;
-                /** @description 章节内任一段落号,用于定位章节 */
+                /** @description 章节的起始段落号,决定本次取数的段落区间 */
                 para: number;
+                /** @description 起点游标,从这一段开始取;不传则等于 para,即从章节 开头取起。可以是章节区间内的任意段落号,落在没有译文的段落上就 顺延到它之后的第一段。超出章节区间、或它之后已经没有译文, 返回 422 */
+                from?: number;
                 /** @description 译文 channel 的 uuid */
                 channel: string;
                 /** @description 内容格式 */
                 format?: "html" | "markdown" | "react" | "text";
                 /** @description 输出口径:display 整段合并,sentences 逐句,all 两者都给 */
                 view?: "display" | "sentences" | "all";
-                /** @description 每页大小,两种写法:`20000b` 按字节累加段落长度 (每页至少一段),`10p` 按段落数 */
-                pagesize?: string;
-                /** @description 页码,从 1 开始。超出范围返回 422 */
-                page?: number;
+                /** @description 每块大小,含义由 unit 决定。上限 unit=para 时 200、 unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际 生效的值 */
+                pagesize?: number;
+                /** @description 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 */
+                unit?: "para" | "byte";
             };
             header?: never;
             path?: never;
@@ -34199,14 +34211,16 @@ export interface operations {
             query: {
                 /** @description 译文 channel 的 uuid */
                 channel: string;
+                /** @description 起点游标,从这一段开始取;不传则从章节开头取起。 超出章节区间返回 422 */
+                from?: number;
                 /** @description 内容格式 */
                 format?: "html" | "markdown" | "react" | "text";
                 /** @description 输出口径:display 整段合并,sentences 逐句,all 两者都给 */
                 view?: "display" | "sentences" | "all";
-                /** @description 每页大小,两种写法:`20000b` 按字节累加段落长度 (每页至少一段),`10p` 按段落数 */
-                pagesize?: string;
-                /** @description 页码,从 1 开始。超出范围返回 422 */
-                page?: number;
+                /** @description 每块大小,含义由 unit 决定。上限 unit=para 时 200、 unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际 生效的值 */
+                pagesize?: number;
+                /** @description 每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段 */
+                unit?: "para" | "byte";
             };
             header?: never;
             path: {

+ 18 - 9
openapi/public/assets/protocol/resources/auto/v3-tipitaka-read-chapter-tipitaka-read-chapter.yaml

@@ -5,8 +5,8 @@ get:
     - tipitaka-read-chapter
   operationId: get_api_v3_tipitaka_read_chapter_tipitaka_read_chapter_
   description: |-
-    与 index 等价,只是把 book 与 para 合成一个路径参数。格式不对或 channel
-    不是 uuid 返回 422。
+    与 index 等价,只是把 book 与 para 合成一个路径参数;游标 `from` 仍走
+    查询参数。格式不对或 channel 不是 uuid 返回 422。
 
     实现:`TipitakaReadChapterController@show`
   parameters:
@@ -25,6 +25,12 @@ get:
         type: string
       description: '译文 channel 的 uuid'
       required: true
+    -
+      name: from
+      in: query
+      schema:
+        type: integer
+      description: '起点游标,从这一段开始取;不传则从章节开头取起。 超出章节区间返回 422'
     -
       name: format
       in: query
@@ -52,16 +58,19 @@ get:
       name: pagesize
       in: query
       schema:
-        type: string
-        default: 20000b
-      description: '每页大小,两种写法:`20000b` 按字节累加段落长度 (每页至少一段),`10p` 按段落数'
+        type: integer
+        default: '10'
+      description: '每块大小,含义由 unit 决定。上限 unit=para 时 200、 unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际 生效的值'
     -
-      name: page
+      name: unit
       in: query
       schema:
-        type: integer
-        default: '1'
-      description: '页码,从 1 开始。超出范围返回 422'
+        type: string
+        enum:
+          - para
+          - byte
+        default: para
+      description: '每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段'
   responses:
     200:
       description: 成功

+ 32 - 13
openapi/public/assets/protocol/resources/auto/v3-tipitaka-read-chapter.yaml

@@ -5,11 +5,21 @@ get:
     - tipitaka-read-chapter
   operationId: get_api_v3_tipitaka_read_chapter
   description: |-
-    给定 book + 段落号,定位它所属的章节,返回该章节的段落内容并分页。
-    章节不存在或没有内容返回 404。
-    分页是手工做的:按字节时从头累加 `pali_texts.lenght` 逐页推进,所以 meta
-    里用 `page_size`(领域语法,非整数)而不是框架的 `per_page`,并附带
-    `first_para` / `last_para` / `has_more`。
+    `para` 定位章节(它是章节的起始段落号,章节长度取那一行的 `chapter_len`),
+    `from` 是本次取数的起点游标,从它开始取 `pagesize` 大小的一块。章节不存在、
+    或该 channel 在这个章节里没有任何译文,返回 404。
+    取数的段落集合来自 sentences 表里该 channel 实际有译文的段落,不是章节的
+    全部段落——多数译文是残缺的,按 pali_texts 的整段区间切块会切出空块。
+    所以 `pagesize` 数的永远是有内容的段落。
+    游标式分块是为了前端的下载进度条与断点续传,meta 里给三个数:
+    `total_para` 是分母(该 channel 在本章节有多少段),`remaining_para` 是
+    `last_para` 之后本章节内还剩多少段,进度即
+    `(total_para - remaining_para) / total_para`;续传时把上次的
+    `last_para + 1` 当作下次的 `from` 传回来,`remaining_para` 为 0 即取完。
+    `first_para` / `last_para` 是本块实际覆盖的段落闭区间,取自切块结果而不是
+    `data`:渲染为空的段落会被剔出 `data` 但仍算在块内,所以 `data` 可能比这个
+    区间短,而续传必须按 `last_para` 推进,否则会卡在那一段上原地打转。
+    `first_para` 是游标顺延后的落点,可能大于请求的 `from`(即 `current_para`)。
 
     实现:`TipitakaReadChapterController@index`
   parameters:
@@ -25,8 +35,14 @@ get:
       in: query
       schema:
         type: integer
-      description: 章节内任一段落号,用于定位章节
+      description: 章节的起始段落号,决定本次取数的段落区间
       required: true
+    -
+      name: from
+      in: query
+      schema:
+        type: integer
+      description: '起点游标,从这一段开始取;不传则等于 para,即从章节 开头取起。可以是章节区间内的任意段落号,落在没有译文的段落上就 顺延到它之后的第一段。超出章节区间、或它之后已经没有译文, 返回 422'
     -
       name: channel
       in: query
@@ -61,16 +77,19 @@ get:
       name: pagesize
       in: query
       schema:
-        type: string
-        default: 20000b
-      description: '每页大小,两种写法:`20000b` 按字节累加段落长度 (每页至少一段),`10p` 按段落数'
+        type: integer
+        default: '10'
+      description: '每块大小,含义由 unit 决定。上限 unit=para 时 200、 unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际 生效的值'
     -
-      name: page
+      name: unit
       in: query
       schema:
-        type: integer
-        default: '1'
-      description: '页码,从 1 开始。超出范围返回 422'
+        type: string
+        enum:
+          - para
+          - byte
+        default: para
+      description: '每块大小的单位:para 按段落数,byte 按段落长度累加。 两种单位下每块都至少一段、至多 200 段'
   responses:
     200:
       description: 成功