Quellcode durchsuchen

feat(api-v13): tipitaka-reading 取代两条阅读端点

/v3/tipitaka-read-para 与 /v3/tipitaka-read-chapter 合并成一条
GET /v3/tipitaka-reading/{channel}。两前端都还没用上这两条(仍在调 v2 的
chapter-content / paragraph-content),所以重塑零风险。

路径判据(写进 skill 的四条):

- 路径开头是「领域-功能」不是通用坐标。channel + book + para 在 wikipali 里是
  通用坐标,sentences / wbw / 批注 / 进度全都用它,放路径开头会让每个新能力来抢
  同一个前缀,还会跟将来真正的 /v3/channels 混(生成器按首段打 tag,中间那版
  被归到了 channels 标签下)
- 必填进路径、可选留查询串。检验方法:砍掉整个查询串,请求还成立吗
- 路径变量最多两个。重新想清楚「只有 channel 必填,book/para 是过滤条件」之后,
  三个变量降到一个,两条端点也随之合并
- 需要归属校验的进路径(->scoped() 只对路径参数生效)

顺带消掉 show 的 {book}-{para} 复合 id——它要手工 explode('-')、拆不出来抛 422,
是纯粹由 URL 形状不对造成的 bug 源。

分页换成 keyset。实测最大的 channel 有 522,718 段跨 217 本书,原来「把段落号
全 pluck 进 PHP 再切片」要 821ms / 峰值 281MB,keyset 取一页 3ms。游标用行值
比较 (book_id, paragraph) > (?, ?) 走复合索引;对客户端不透明,由
meta.next_cursor 给出、原样回传,null 表示取完。游标越界不再报错——keyset 语义
下「在范围之后」就是空结果,游标既然由服务端给,再校验区间没有意义。

total / remaining 只在带了 book 过滤时给:无过滤时结果集是整个 channel,每翻
一页 count 一次要几百毫秒,不划算;那种场景用 next_cursor 判断结束。

参数改名:view → include(v2 的 view= 是必填互斥的业务路径开关,名字撞上会
误导;这个其实是可选带默认的表示选择器),pagesize → page_size(与 meta 对齐)。

控制器是胶水层 6 行,业务逻辑在 Services\V3\TipitakaReadingService。
sliceByBytes 保持纯函数,单元测试直接测它,不必再造控制器的匿名子类。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda vor 3 Wochen
Ursprung
Commit
672576174a

+ 0 - 311
api-v13/app/Http/Controllers/TipitakaReadChapterController.php

@@ -1,311 +0,0 @@
-<?php
-
-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;
-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;
-
-    /**
-     * 按章节读取译文
-     *
-     * `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`)。
-     *
-     * @unauthenticated
-     *
-     * @queryParam book integer required 典籍 id
-     * @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 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' => 'integer|min:1',
-            'unit' => 'string|in:para,byte',
-        ]);
-
-        return $this->chapter(
-            (int) $data['book'],
-            (int) $data['para'],
-            $data['channel'],
-            $data,
-            $paliService
-        );
-    }
-
-    /**
-     * 按 id 读取章节
-     *
-     * 与 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 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)
-    {
-        $arrId = explode('-', $id);
-        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
-        }
-        $channel = $request->input('channel');
-        if (! Str::isUuid($channel)) {
-            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,
-            $data,
-            $paliService
-        );
-    }
-
-    /**
-     * @param  array{from?: int|string, format?: string, view?: string, pagesize?: int|string, unit?: string}  $param
-     */
-    protected function chapter(
-        int $book,
-        int $para,
-        string $channel,
-        array $param,
-        PaliContentService $paliService
-    ) {
-        $format = $param['format'] ?? 'html';
-        $view = $param['view'] ?? 'display';
-        $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));
-
-        $head = PaliText::where('book', $book)->where('paragraph', $para)->first();
-        if (! $head) {
-            abort(404, __('site.not_found'));
-        }
-        $to = $para + max(1, (int) $head->chapter_len) - 1;
-
-        // 游标必须落在本章节的段落区间里,越界是调用方算错了,直接报错而不是
-        // 悄悄钳到边界——断点续传时静默纠偏会把 bug 藏起来
-        if ($cursor < $para || $cursor > $to) {
-            throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]);
-        }
-
-        // 只取段落号,不取句子内容——最大的章节有一万五千段,整章的句子拉回来没有意义。
-        // 整章都要查,因为 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')
-            ->pluck('paragraph')
-            ->map(fn ($paragraph) => (int) $paragraph)
-            ->all();
-
-        $total = count($translated);
-        if ($total === 0) {
-            abort(404, __('site.not_found'));
-        }
-
-        $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 $paragraphNumber) {
-            $paragraph = $paliService->readParagraph($book, $paragraphNumber, $channel, $format);
-            if (empty($paragraph['display'])) {
-                continue;
-            }
-            $items[] = $this->filterView($paragraph, $view);
-        }
-
-        return V3Resource::collection($items)->additional(['meta' => [
-            // 请求的游标 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,
-            '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),
-        ]]);
-    }
-
-    /**
-     * 游标 $cursor 落在升序段落号里的下标:它本身有译文就是它,否则顺延到之后的
-     * 第一段。$cursor 已在章节区间内(调用方先校验过),但可能落在最后一段译文
-     * 之后,那时返回 null。
-     *
-     * @param  array<int, int>  $paragraphs  升序段落号
-     */
-    protected function cursorOffset(array $paragraphs, int $cursor): ?int
-    {
-        foreach ($paragraphs as $offset => $paragraph) {
-            if ($paragraph >= $cursor) {
-                return $offset;
-            }
-        }
-
-        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();
-    }
-
-    /**
-     * 从 $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)));
-        }
-
-        $bytes = 0;
-        $take = 0;
-        $count = count($paragraphs);
-        while ($offset + $take < $count) {
-            $bytes += (int) ($lengths[$paragraphs[$offset + $take]] ?? 0);
-            $take++;
-            if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) {
-                break;
-            }
-        }
-
-        return array_slice($paragraphs, $offset, $take);
-    }
-
-    /**
-     * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
-     *
-     * @param  array{para: int, display: string, sentences: array}  $paragraph
-     */
-    protected function filterView(array $paragraph, string $view): array
-    {
-        return match ($view) {
-            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
-            'all' => $paragraph,
-            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
-        };
-    }
-}

+ 0 - 125
api-v13/app/Http/Controllers/TipitakaReadParaController.php

@@ -1,125 +0,0 @@
-<?php
-
-namespace App\Http\Controllers;
-
-use App\Http\Resources\V3Resource;
-use App\Services\PaliContentService;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
-use Illuminate\Validation\ValidationException;
-
-class TipitakaReadParaController extends Controller
-{
-    /**
-     * 按段落区间读取译文
-     *
-     * 阅读模式下按 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)
-    {
-        $data = $request->validate([
-            'book' => 'required|integer',
-            'para' => 'required|integer',
-            'to' => 'integer',
-            'channel' => 'required|uuid',
-            'format' => 'string|in:html,markdown,react,text',
-            'view' => 'string|in:display,sentences,all', // display:整段合并 sentences:逐句
-        ]);
-
-        $from = $data['para'];
-        $to = $data['to'] ?? $from;
-        if ($to < $from) {
-            throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
-        }
-        $format = $data['format'] ?? 'html';
-        $view = $data['view'] ?? 'display';
-
-        $items = [];
-        foreach (range($from, $to) as $para) {
-            $paragraph = $paliService->readParagraph(
-                (int) $data['book'],
-                (int) $para,
-                $data['channel'],
-                $format
-            );
-            if (empty($paragraph['display'])) {
-                continue;
-            }
-            $items[] = $this->filterView($paragraph, $view);
-        }
-
-        return V3Resource::collection($items)->additional(['meta' => [
-            'current_page' => 1,
-            'per_page' => $to - $from + 1,
-            'total' => count($items),
-            'last_page' => 1,
-        ]]);
-    }
-
-    /**
-     * 读取单个段落
-     *
-     * 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)
-    {
-        $arrId = explode('-', $id);
-        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
-            throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
-        }
-        $channel = $request->input('channel');
-        if (! Str::isUuid($channel)) {
-            throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
-        }
-        $book = (int) $arrId[0];
-        $para = (int) $arrId[1];
-
-        $paragraph = $paliService->readParagraph(
-            $book,
-            $para,
-            $channel,
-            $request->input('format', 'html')
-        );
-        if (empty($paragraph['display'])) {
-            abort(404, __('site.not_found'));
-        }
-
-        return V3Resource::make($this->filterView($paragraph, $request->input('view', 'display')));
-    }
-
-    /**
-     * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
-     *
-     * @param  array{para: int, display: string, sentences: array}  $paragraph
-     */
-    protected function filterView(array $paragraph, string $view): array
-    {
-        return match ($view) {
-            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
-            'all' => $paragraph,
-            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
-        };
-    }
-}

+ 77 - 0
api-v13/app/Http/Controllers/V3/TipitakaReadingController.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\TipitakaReadingRequest;
+use App\Http\Resources\V3\ReadParagraphResource;
+use App\Services\V3\TipitakaReadingService;
+
+/**
+ * 三藏译文的阅读与下载:按 channel 取已翻译的段落,游标式分块。
+ *
+ * 路径开头是「领域-功能」而不是资源坐标:`channel + book + para` 在 wikipali 里是
+ * 通用坐标,sentences、wbw、批注、进度全都用它,放在路径开头会让每个新能力来抢
+ * 同一个前缀,也看不出这条 API 到底干什么。所以路径是 `/v3/tipitaka-reading/{channel}`,
+ * 坐标降成 filter。
+ *
+ * 曾经叫 `/v3/tipitaka-read-para` 与 `/v3/tipitaka-read-chapter`,中间短暂叫过
+ * `/v3/channels/{channel}/books/{book}/…`,见 CLAUDE.md 的「v2 → v3 对照表」。
+ *
+ * **这不是 v2 `chapter-content` / `paragraph-content` 的替代品**:那两个支持多
+ * channel 与 edit 模式、返回 sentenceIds 给编辑器;这里只做单 channel 的只读渲染。
+ */
+class TipitakaReadingController extends Controller
+{
+    public function __construct(private readonly TipitakaReadingService $reading) {}
+
+    /**
+     * 读取译文
+     *
+     * 取这个 channel 已经翻译的段落,按 (book, para) 升序,用游标分块。
+     * 不带任何查询串就是从头取整个 channel——下载场景要的就是这个;
+     * 阅读场景用 `book` + `chapter` 或 `book` + `para`/`to` 把范围收窄。
+     *
+     * 续传把 `meta.next_cursor` 原样传回 `after` 即可,它为 null 表示取完了。
+     * **不要自己拼游标**:它的内部形式(`{book}-{para}`)是实现细节,会变。
+     *
+     * `total` / `remaining` 只在带了 `book` 过滤时才给:不带过滤时结果集是整个
+     * channel(实测最大的有 52 万段、跨 217 本书),每翻一页都去 count 一次不划算。
+     * 无过滤时用 `next_cursor` 是否为 null 判断结束,不要指望百分比。
+     *
+     * @unauthenticated
+     *
+     * @urlParam channel string required 译文 channel 的 uuid
+     *
+     * @queryParam book integer 典籍 id。与 chapter / para / to 一起用时必填。Example: 9002
+     * @queryParam chapter integer 章节起始段落号,服务端按 chapter_len 展开成段落区间。
+     *             与 para / to 互斥
+     * @queryParam para integer 起始段落号
+     * @queryParam to integer 结束段落号(含)。不传则等于 para,即只取一段
+     * @queryParam after string 游标,取 meta.next_cursor 原样回传。Example: 9002-15
+     * @queryParam page_size integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
+     *             unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际生效的值。
+     *             Default: 10
+     * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
+     *             两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
+     * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
+     * @queryParam include string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
+     *             不叫 view 是因为 v2 的 `view=` 是必填互斥的业务路径开关,名字撞上会误导。
+     *             Enum: display,sentences,all Default: display
+     *
+     * @responseStatus 404 指定的 chapter 在 pali_texts 里不存在
+     *
+     * @meta page_size integer 每块大小(按 unit 计)
+     * @meta page_size_unit string 每块大小的单位:para 或 byte
+     * @meta next_cursor string 下一块的游标,原样传回 after;为 null 表示取完
+     * @meta total integer 过滤范围内已翻译的段落总数。仅在带了 book 过滤时出现
+     * @meta remaining integer 本块之后过滤范围内还剩多少段。仅在带了 book 过滤时出现
+     */
+    public function __invoke(TipitakaReadingRequest $request, string $channel)
+    {
+        $page = $this->reading->page($channel, $request->validated());
+
+        return ReadParagraphResource::collection($page['items'])
+            ->additional(['meta' => $page['meta']]);
+    }
+}

+ 47 - 0
api-v13/app/Http/Requests/V3/TipitakaReadingRequest.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/tipitaka-reading/{channel}` 的入参。
+ *
+ * **只有 channel 是必填的,而且它在路径上。** 这里全是可选的过滤与分页参数,
+ * 所以不带任何查询串就能出结果——那就是「取这个 channel 的全部译文」,
+ * 下载场景要的正是这个。
+ *
+ * `book` 只在跟 `chapter` / `para` 一起用时才必填:那两个是书内的段落号,
+ * 没有 book 就没有意义。这是唯一一条参数间依赖,不是 v2 那种 `view=` 开关。
+ */
+class TipitakaReadingRequest extends FormRequest
+{
+    /**
+     * 公开的阅读接口,不需要登录。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'book' => ['integer', 'min:1', 'required_with:chapter,para,to'],
+            // 章节起始段落号;服务端按 pali_texts 的 chapter_len 展开成段落区间
+            'chapter' => ['integer', 'min:1', 'prohibits:para,to'],
+            'para' => ['integer', 'min:1'],
+            'to' => ['integer', 'min:1'],
+            // 游标,形如 "9002-15"(book-para)。由 meta.next_cursor 给出,
+            // 客户端原样回传即可,不要自己拼
+            'after' => ['string', 'regex:/^\d+-\d+$/'],
+            'page_size' => ['integer', 'min:1'],
+            'unit' => ['string', 'in:para,byte'],
+            'format' => ['string', 'in:html,markdown,react,text'],
+            'include' => ['string', 'in:display,sentences,all'],
+        ];
+    }
+}

+ 28 - 0
api-v13/app/Http/Resources/V3/ReadParagraphResource.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 阅读接口逐段返回的正文条目,即 `/v3/tipitaka-reading/{channel}` 的 `data[]` 元素。
+ *
+ * 结果可以跨书,所以每条都带 `book`——只给 `para` 在跨书时不唯一。
+ *
+ * 载荷是普通数组而非 Eloquent 模型,`toArray` 直接透传;`@return` 声明的是
+ * `view=display` 口径下的形状(para + display)。
+ */
+class ReadParagraphResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     para: int,
+     *     display: string
+     * }
+     */
+    public function toArray($request)
+    {
+        return parent::toArray($request);
+    }
+}

+ 269 - 0
api-v13/app/Services/V3/TipitakaReadingService.php

@@ -0,0 +1,269 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * 三藏译文的阅读与下载:按 channel 取已翻译的段落,游标式分块。
+ *
+ * 服务 `GET /v3/tipitaka-reading/{channel}`。控制器只负责把 validated() 传进来、
+ * 把结果包成 Resource;这里是全部业务逻辑——过滤收窄、章节展开、keyset 游标、
+ * 字节切块、进度统计、输出裁剪。
+ *
+ * 渲染本身交给 {@see PaliContentService}(v2/v3 共享的纯能力),这里不重复实现。
+ */
+class TipitakaReadingService
+{
+    /**
+     * 字节模式下单块的字节上限。请求超过这个数按这个数算,不报错。
+     */
+    public const MAX_BYTES_PER_PAGE = 5000;
+
+    /**
+     * 单块最多返回的段落数,两种单位都受它约束。
+     *
+     * 每段要单独渲染一次,块的成本跟段落数成正比而不是跟字节数成正比。段落模式
+     * 直接把 page_size 压到这个数;字节模式下偈颂体的书平均每段只有 20~30 字节,
+     * 光靠字节上限仍可能切出几百段,所以累加时两个上限谁先到就在哪断块。
+     */
+    public const MAX_PARAGRAPHS_PER_PAGE = 200;
+
+    public function __construct(private readonly PaliContentService $paliService) {}
+
+    /**
+     * 取一块译文。
+     *
+     * @param  array<string, mixed>  $filters  已经过 FormRequest 校验的查询参数
+     * @return array{items: array<int, array<string, mixed>>, meta: array<string, mixed>}
+     */
+    public function page(string $channel, array $filters): array
+    {
+        $unit = $filters['unit'] ?? 'para';
+        $pageSize = min(
+            max(1, (int) ($filters['page_size'] ?? 10)),
+            $unit === 'byte' ? self::MAX_BYTES_PER_PAGE : self::MAX_PARAGRAPHS_PER_PAGE
+        );
+
+        $scope = $this->scopedQuery($channel, $filters);
+
+        // 多取一行用来判断后面还有没有,不必再查一次
+        $want = $unit === 'byte'
+            ? self::MAX_PARAGRAPHS_PER_PAGE
+            : min($pageSize, self::MAX_PARAGRAPHS_PER_PAGE);
+
+        $candidates = (clone $scope)
+            ->when($filters['after'] ?? null, fn (Builder $q, string $after) => $this->applyCursor($q, $after))
+            ->distinct()
+            ->select(['book_id', 'paragraph'])
+            ->orderBy('book_id')
+            ->orderBy('paragraph')
+            ->limit($want + 1)
+            ->get()
+            ->map(fn ($row) => ['book' => (int) $row->book_id, 'para' => (int) $row->paragraph])
+            ->all();
+
+        $hasMoreBeyondWant = count($candidates) > $want;
+        $candidates = array_slice($candidates, 0, $want);
+
+        $slice = $unit === 'byte'
+            ? $this->sliceByBytes($candidates, $this->paragraphLengths($candidates), $pageSize)
+            : $candidates;
+        $hasMore = $hasMoreBeyondWant || count($slice) < count($candidates);
+
+        $format = $filters['format'] ?? 'html';
+        $include = $filters['include'] ?? 'display';
+
+        $items = [];
+        foreach ($slice as $coord) {
+            $paragraph = $this->paliService->readParagraph($coord['book'], $coord['para'], $channel, $format);
+            if (empty($paragraph['display'])) {
+                continue;
+            }
+            // 结果可以跨书,所以每条都要带 book——只给 para 在跨书时不唯一
+            $items[] = ['book' => $coord['book']] + $this->filterInclude($paragraph, $include);
+        }
+
+        $last = $slice === [] ? null : $slice[count($slice) - 1];
+
+        return [
+            'items' => $items,
+            'meta' => [
+                'page_size' => $pageSize,
+                'page_size_unit' => $unit,
+                // 本块覆盖的最后一段。渲染为空的段落会被剔出 data 但仍算在块内,
+                // 所以游标按 slice 推进而不是按 data 推进,否则会卡在那一段上打转
+                'next_cursor' => $hasMore && $last ? "{$last['book']}-{$last['para']}" : null,
+            ] + $this->progressMeta($scope, $filters, $last),
+        ];
+    }
+
+    /**
+     * 按 filter 收窄的**基础查询**:只有 where,不带 select / distinct / order。
+     *
+     * 分页取数和 count 两边对它的要求不一样(一个要去重的两列,一个要聚合),
+     * 混在一起会拼出 `select distinct book_id, paragraph, count(...)` 这种非法 SQL。
+     * 所以基础查询保持干净,各自 clone 之后再加自己那套。
+     *
+     * @param  array<string, mixed>  $filters
+     * @return Builder<Sentence>
+     */
+    private function scopedQuery(string $channel, array $filters): Builder
+    {
+        $query = Sentence::query()
+            ->where('channel_uid', $channel)
+            ->where('strlen', '>', 0);
+
+        if (! isset($filters['book'])) {
+            return $query;
+        }
+        $book = (int) $filters['book'];
+        $query->where('book_id', $book);
+
+        if (isset($filters['chapter'])) {
+            [$from, $to] = $this->chapterRange($book, (int) $filters['chapter']);
+
+            return $query->whereBetween('paragraph', [$from, $to]);
+        }
+        if (isset($filters['para'])) {
+            $from = (int) $filters['para'];
+            $to = (int) ($filters['to'] ?? $from);
+            if ($to < $from) {
+                throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
+            }
+
+            return $query->whereBetween('paragraph', [$from, $to]);
+        }
+
+        return $query;
+    }
+
+    /**
+     * 章节起始段落号 → 段落闭区间,长度取 pali_texts 那一行的 chapter_len。
+     *
+     * @return array{int, int}
+     */
+    private function chapterRange(int $book, int $chapter): array
+    {
+        $head = PaliText::where('book', $book)->where('paragraph', $chapter)->first();
+        if (! $head) {
+            abort(404, __('site.not_found'));
+        }
+
+        return [$chapter, $chapter + max(1, (int) $head->chapter_len) - 1];
+    }
+
+    /**
+     * 游标 `{book}-{para}`:取排在它之后的行。
+     *
+     * 用行值比较 `(book_id, paragraph) > (?, ?)` 而不是 `book > ? or (book = ? and para > ?)`,
+     * 一是短,二是 Postgres 能直接走 (book_id, paragraph, …) 那条复合索引。
+     *
+     * @param  Builder<Sentence>  $query
+     * @return Builder<Sentence>
+     */
+    private function applyCursor(Builder $query, string $after): Builder
+    {
+        [$book, $para] = array_map('intval', explode('-', $after, 2));
+
+        return $query->whereRaw('(book_id, paragraph) > (?, ?)', [$book, $para]);
+    }
+
+    /**
+     * 按原文字节数累加切块,到上限或到 200 段为止,每块至少一段。
+     *
+     * 纯函数:长度由调用方查好传进来,这里不碰数据库。段落数上限要造上千段才看得
+     * 出来,走 HTTP 会真的去渲染每一段,所以单元测试直接对着它测。
+     *
+     * @param  array<int, array{book: int, para: int}>  $candidates
+     * @param  array<int, array<int, int>>  $lengths  book => (para => 字节数)
+     * @return array<int, array{book: int, para: int}>
+     */
+    public function sliceByBytes(array $candidates, array $lengths, int $limit): array
+    {
+        $bytes = 0;
+        $take = 0;
+        foreach ($candidates as $coord) {
+            $bytes += (int) ($lengths[$coord['book']][$coord['para']] ?? 0);
+            $take++;
+            if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) {
+                break;
+            }
+        }
+
+        return array_slice($candidates, 0, $take);
+    }
+
+    /**
+     * 取这些段落的原文字节数。按书分组、每本用首尾段落号一次查回整段区间,
+     * 避免几百个值的 IN 列表;区间里多查回来几行比拼 IN 便宜。
+     *
+     * @param  array<int, array{book: int, para: int}>  $candidates
+     * @return array<int, array<int, int>> book => (para => 字节数)
+     */
+    private function paragraphLengths(array $candidates): array
+    {
+        $byBook = [];
+        foreach ($candidates as $coord) {
+            $byBook[$coord['book']][] = $coord['para'];
+        }
+
+        $lengths = [];
+        foreach ($byBook as $book => $paras) {
+            $lengths[$book] = PaliText::where('book', $book)
+                ->whereBetween('paragraph', [min($paras), max($paras)])
+                ->pluck('lenght', 'paragraph')
+                ->all();
+        }
+
+        return $lengths;
+    }
+
+    /**
+     * 进度用的 total / remaining,**只在带了 book 过滤时给**。
+     *
+     * 不带过滤时结果集是整个 channel(实测最大的 52 万段),每页 count 一次要
+     * 八百毫秒、两百多 MB,不值得。那种场景用 next_cursor 判断结束。
+     *
+     * @param  Builder<Sentence>  $scope
+     * @param  array<string, mixed>  $filters
+     * @param  array{book: int, para: int}|null  $last
+     * @return array<string, int>
+     */
+    private function progressMeta(Builder $scope, array $filters, ?array $last): array
+    {
+        if (! isset($filters['book'])) {
+            return [];
+        }
+
+        $countIn = fn (Builder $q): int => (int) (
+            $q->selectRaw('count(distinct (book_id, paragraph)) as aggregate')->first()?->aggregate ?? 0
+        );
+
+        $total = $countIn(clone $scope);
+        $remaining = $last === null
+            ? 0
+            : $countIn($this->applyCursor(clone $scope, "{$last['book']}-{$last['para']}"));
+
+        return ['total' => $total, 'remaining' => $remaining];
+    }
+
+    /**
+     * 按 include 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
+     *
+     * @param  array{para: int, display: string, sentences: array}  $paragraph
+     * @return array<string, mixed>
+     */
+    private function filterInclude(array $paragraph, string $include): array
+    {
+        return match ($include) {
+            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
+            'all' => $paragraph,
+            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
+        };
+    }
+}

+ 8 - 8
api-v13/tests/Feature/ReadingAnnotationsTest.php

@@ -46,9 +46,9 @@ it('injects a commentary annotation with a cite link', function () {
     makeSentence($channel, 9102, 7, 3, '义注这么说');
     makeSentence($channel, 9102, 7, 3, '义注这么说');
     makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
     makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
 
 
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
         ->assertOk()
         ->assertOk()
-        ->json('data.display');
+        ->json('data.0.display');
 
 
     expect($display)
     expect($display)
         ->toContain('义注这么说')
         ->toContain('义注这么说')
@@ -60,9 +60,9 @@ it('injects a plain note annotation without a cite link', function () {
     // 普通边注:content 就是注解正文本身
     // 普通边注:content 就是注解正文本身
     makeAnnotation($sentence->uid, 'note', '这里的 dassana 指见到佛陀。', 5);
     makeAnnotation($sentence->uid, 'note', '这里的 dassana 指见到佛陀。', 5);
 
 
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
         ->assertOk()
         ->assertOk()
-        ->json('data.display');
+        ->json('data.0.display');
 
 
     // 正文照原样进 sidenote,紧跟 </span> 收尾——没有 <cite> 出处
     // 正文照原样进 sidenote,紧跟 </span> 收尾——没有 <cite> 出处
     expect($display)
     expect($display)
@@ -76,16 +76,16 @@ it('injects both kinds on the same sentence, note first at the same position', f
     makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
     makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
     makeAnnotation($sentence->uid, 'note', '译者按。', 5);
     makeAnnotation($sentence->uid, 'note', '译者按。', 5);
 
 
-    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9101&para=1")
         ->assertOk()
         ->assertOk()
-        ->json('data.display');
+        ->json('data.0.display');
 
 
     expect(mb_strpos($display, '译者按。'))->toBeLessThan(mb_strpos($display, '义注这么说'));
     expect(mb_strpos($display, '译者按。'))->toBeLessThan(mb_strpos($display, '义注这么说'));
 });
 });
 
 
 it('drops the paragraph cache when either kind of annotation changes', function () {
 it('drops the paragraph cache when either kind of annotation changes', function () {
     [$channel, $sentence] = makeAnnotatedSentence();
     [$channel, $sentence] = makeAnnotatedSentence();
-    $url = "/api/v3/tipitaka-read-para/9101-1?channel={$channel}";
+    $url = "/api/v3/tipitaka-reading/{$channel}?book=9101&para=1";
     $this->getJson($url)->assertOk();
     $this->getJson($url)->assertOk();
 
 
     $key = PaliContentService::paragraphCacheKey(9101, 1, $channel, 'html');
     $key = PaliContentService::paragraphCacheKey(9101, 1, $channel, 'html');
@@ -93,5 +93,5 @@ it('drops the paragraph cache when either kind of annotation changes', function
 
 
     makeAnnotation($sentence->uid, 'note', '译者按。', 5);
     makeAnnotation($sentence->uid, 'note', '译者按。', 5);
     expect(Cache::has($key))->toBeFalse();
     expect(Cache::has($key))->toBeFalse();
-    expect($this->getJson($url)->json('data.display'))->toContain('译者按。');
+    expect($this->getJson($url)->json('data.0.display'))->toContain('译者按。');
 });
 });

+ 234 - 0
api-v13/tests/Feature/TipitakaReadingChunkTest.php

@@ -0,0 +1,234 @@
+<?php
+
+use App\Models\PaliText;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 建一个 pali_texts 段落。第一段带 chapter_len 表示章节长度
+ */
+function makePaliText(int $book, int $para, int $lenght, ?int $chapterLen = null): void
+{
+    (new PaliText)->forceFill([
+        'book' => $book,
+        'paragraph' => $para,
+        'level' => $chapterLen ? 2 : 9,
+        'class' => '',
+        'toc' => '',
+        'text' => '',
+        'html' => '',
+        'lenght' => $lenght,
+        'chapter_len' => $chapterLen,
+        'pcd_book_id' => 0,
+        'uid' => (string) Str::uuid(),
+    ])->save();
+}
+
+/**
+ * book 9002 一个 5 段的章节(起始段落号 1),每段 100 字节。$translated 列出
+ * 哪些段落有译文,默认整章都有;传子集即模拟残缺译文。
+ *
+ * @param  array<int, int>  $translated
+ */
+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 ($translated as $para) {
+        makeSentence($channel, 9002, $para, 1, "para {$para} text");
+    }
+
+    return $channel;
+}
+
+it('fetches a block from the start and follows next_cursor', function () {
+    $channel = makeChapterFixture();
+    $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2";
+
+    // 不带 after,从章节开头取起
+    $first = $this->getJson($base)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 2])
+        ->and($first['meta'])->toMatchArray([
+            'page_size' => 2,
+            'page_size_unit' => 'para',
+            'total' => 5,
+            'remaining' => 3,
+            'next_cursor' => '9002-2',
+        ]);
+
+    // 断点续传:把 next_cursor 原样传回 after
+    $second = $this->getJson($base.'&after='.$first['meta']['next_cursor'])->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([3, 4])
+        ->and($second['meta'])->toMatchArray(['remaining' => 1, 'next_cursor' => '9002-4']);
+
+    // 最后一块:next_cursor 为 null 即取完
+    $last = $this->getJson($base.'&after='.$second['meta']['next_cursor'])->assertOk()->json();
+    expect(array_column($last['data'], 'para'))->toBe([5])
+        ->and($last['meta']['remaining'])->toBe(0)
+        ->and($last['meta']['next_cursor'])->toBeNull();
+});
+
+it('returns an empty block when the cursor is past the end', function () {
+    $channel = makeChapterFixture();
+
+    // keyset 语义:游标在范围之后就是空结果,不是错误。游标由服务端给出,
+    // 客户端不自己拼,所以不必再校验它落在哪个区间
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&after=9002-5")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toBe([])
+        ->and($json['meta']['next_cursor'])->toBeNull()
+        ->and($json['meta']['remaining'])->toBe(0);
+});
+
+it('scopes the chapter by chapter_len', function () {
+    $channel = makeChapterFixture();
+
+    // para=3 那一行没有 chapter_len,区间只有它自己
+    expect($this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=3")
+        ->assertOk()->json('meta'))
+        ->toMatchArray(['total' => 1, 'remaining' => 0, 'next_cursor' => null]);
+});
+
+it('skips paragraphs that have no translation', function () {
+    // 只译了 1、3、5;游标停在第 1 段之后,下一块直接跳到 3 和 5
+    $channel = makeChapterFixture([1, 3, 5]);
+
+    $block = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=2&after=9002-1")
+        ->assertOk()
+        ->json();
+
+    expect(array_column($block['data'], 'para'))->toBe([3, 5])
+        ->and($block['meta'])->toMatchArray([
+            'total' => 3,
+            'remaining' => 0,
+            'next_cursor' => null,
+        ]);
+});
+
+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-reading/{$channel}?book=9002&chapter=1&page_size=2";
+
+    $first = $this->getJson($base)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 3])
+        ->and($first['meta'])->toMatchArray(['total' => 3, 'remaining' => 1, 'next_cursor' => '9002-3']);
+
+    $second = $this->getJson($base.'&after=9002-3')->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([5])
+        ->and($second['meta']['remaining'])->toBe(0);
+});
+
+it('fetches a block by byte size using the lenght column', function () {
+    $channel = makeChapterFixture();
+    // 每段 100 字节,250 字节累加到第 3 段才超过上限
+    $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=250&unit=byte";
+
+    $first = $this->getJson($base)->assertOk()->json();
+    expect(array_column($first['data'], 'para'))->toBe([1, 2, 3])
+        ->and($first['meta'])->toMatchArray([
+            'page_size' => 250,
+            'page_size_unit' => 'byte',
+            'remaining' => 2,
+        ]);
+
+    $second = $this->getJson($base.'&after='.$first['meta']['next_cursor'])->assertOk()->json();
+    expect(array_column($second['data'], 'para'))->toBe([4, 5])
+        ->and($second['meta']['remaining'])->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-reading/{$channel}?book=9002&chapter=1&page_size=250&unit=byte")
+        ->assertOk()
+        ->json();
+
+    expect(array_column($first['data'], 'para'))->toBe([1, 3, 5])
+        ->and($first['meta']['remaining'])->toBe(0);
+});
+
+it('returns at least one paragraph when it alone exceeds the byte size', function () {
+    $channel = makeChapterFixture();
+
+    $items = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=1&unit=byte")
+        ->assertOk()
+        ->json('data');
+
+    expect(array_column($items, 'para'))->toBe([1]);
+});
+
+it('returns an empty collection when the channel has nothing translated here', function () {
+    $channel = makeChapterFixture([]);
+
+    // 集合端点,没内容就是空集合;章节本身存在,不是 404
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toBe([])
+        ->and($json['meta'])->toMatchArray(['total' => 0, 'remaining' => 0, 'next_cursor' => null]);
+});
+
+it('clamps page_size to the per-block ceiling and reports the effective value', function () {
+    $channel = makeChapterFixture();
+    $base = "/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1";
+
+    // 段落模式上限 200
+    expect($this->getJson($base.'&page_size=1000')->assertOk()->json('meta'))
+        ->toMatchArray(['page_size' => 200, 'page_size_unit' => 'para']);
+
+    // 字节模式上限 5000
+    expect($this->getJson($base.'&page_size=20000&unit=byte')->assertOk()->json('meta'))
+        ->toMatchArray(['page_size' => 5000, 'page_size_unit' => 'byte']);
+
+    // 没到上限的照常返回原值
+    expect($this->getJson($base.'&page_size=3')->assertOk()->json('meta.page_size'))->toBe(3);
+});
+
+it('accepts the cursor together with the include parameter', function () {
+    $channel = makeChapterFixture();
+
+    $block = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=1&after=9002-1&include=all")
+        ->assertOk()
+        ->json();
+
+    expect($block['data'])->toHaveCount(1)
+        ->and($block['data'][0])->toHaveKeys(['book', 'para', 'display', 'sentences'])
+        ->and($block['data'][0]['para'])->toBe(2);
+});
+
+it('rejects bad filters and an unknown chapter', function () {
+    $channel = makeChapterFixture();
+
+    // 路径上只有 channel,格式不对匹配不上路由 → 404
+    $this->getJson('/api/v3/tipitaka-reading/not-a-uuid')->assertStatus(404);
+
+    // chapter 在 pali_texts 里不存在 → 404
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=99")
+        ->assertStatus(404)
+        ->assertJsonPath('title', 'Resource not found.');
+
+    // 其余都是 filter,走 FormRequest → 422
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=0")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['page_size']]);
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&page_size=10p")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['page_size']]);
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&unit=bytes")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['unit']]);
+    // 游标格式不对
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9002&chapter=1&after=not-a-cursor")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['after']]);
+});

+ 180 - 0
api-v13/tests/Feature/TipitakaReadingRenderTest.php

@@ -0,0 +1,180 @@
+<?php
+
+use App\Models\PaliText;
+use App\Models\Sentence;
+use App\Services\PaliContentService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 建测试用的 channel 和句子:book 9001 的第 1 段两句,第 2 段一句。返回 channel uid
+ */
+function makeParagraphFixture(): string
+{
+    $channel = makeChannel(makeStudio('para-owner'), 'para channel');
+    makeSentence($channel, 9001, 1, 1, 'first sentence');
+    makeSentence($channel, 9001, 1, 2, 'second sentence');
+    makeSentence($channel, 9001, 2, 1, 'other paragraph');
+
+    return $channel;
+}
+
+it('renders every sentence of a paragraph wrapped in divs', function () {
+    $channel = makeParagraphFixture();
+    $data = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1")
+        ->assertOk()
+        ->json('data.0');
+
+    expect($data['para'])->toBe(1);
+    // 默认只输出 display
+    expect($data)->not->toHaveKey('sentences');
+    expect($data['display'])
+        ->toContain("<div id='para-1' class='translation' data-para='1'>")
+        ->toContain("<div class='sentence' data-sid='9001-1-1-1'>")
+        ->toContain("<div class='sentence' data-sid='9001-1-2-2'>")
+        ->toContain("<div class='para-block'>");
+});
+
+it('wraps a chapter title paragraph in a heading', function () {
+    $channel = makeParagraphFixture();
+    (new PaliText)->forceFill([
+        'book' => 9001,
+        'paragraph' => 1,
+        'level' => 2,
+        'class' => '',
+        'toc' => '',
+        'text' => '',
+        'html' => '',
+        'pcd_book_id' => 0,
+        'uid' => (string) Str::uuid(),
+    ])->save();
+
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1")
+        ->assertOk()
+        ->json('data.0.display');
+
+    expect($display)->toContain('<h2>')->not->toContain('para-block');
+});
+
+it('can output only the sentences or both', function () {
+    $channel = makeParagraphFixture();
+    $sentences = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&include=sentences")
+        ->assertOk()
+        ->json('data.0');
+    expect($sentences)->not->toHaveKey('display');
+    expect($sentences['sentences'])->toHaveCount(2);
+    expect($sentences['sentences'][0])->toHaveKeys(['sid', 'html']);
+
+    $all = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&include=all")
+        ->assertOk()
+        ->json('data.0');
+    expect($all)->toHaveKeys(['para', 'display', 'sentences']);
+});
+
+it('lists the paragraphs of a range and skips empty ones', function () {
+    $channel = makeParagraphFixture();
+    $items = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&to=3")
+        ->assertOk()
+        ->json('data');
+
+    expect(array_column($items, 'para'))->toBe([1, 2]);
+});
+
+it('outputs one line per sentence for non html formats', function () {
+    $channel = makeParagraphFixture();
+    $display = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=1&format=text")
+        ->assertOk()
+        ->json('data.0.display');
+
+    expect($display)->toBe("first sentence\nsecond sentence");
+});
+
+it('rejects a malformed channel in the path but validates filters as 422', function () {
+    $channel = makeParagraphFixture();
+
+    // 路径参数由路由约束把关:不是 uuid 就匹配不上任何路由 → 404
+    $this->getJson('/api/v3/tipitaka-reading/not-a-uuid')->assertStatus(404);
+
+    // filter 是查询参数,走 FormRequest → 422
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=not-a-number")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['para']]);
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=not-a-number")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['book']]);
+
+    // book 只在跟 chapter / para / to 一起用时必填
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?para=1")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['book']]);
+
+    // chapter 与 para 互斥
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&chapter=1&para=1")
+        ->assertStatus(422);
+});
+
+it('fetches the whole channel when no filter is given', function () {
+    $channel = makeParagraphFixture();
+
+    // 不带任何查询串 = 取这个 channel 的全部译文,下载场景要的就是这个
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}")->assertOk()->json();
+
+    expect(array_column($json['data'], 'para'))->toBe([1, 2])
+        ->and($json['data'][0]['book'])->toBe(9001)
+        // 无 book 过滤时不给 total / remaining,靠 next_cursor 判断结束
+        ->and($json['meta'])->not->toHaveKey('total')
+        ->and($json['meta']['next_cursor'])->toBeNull();
+});
+
+it('returns an empty collection for a paragraph with no content', function () {
+    $channel = makeParagraphFixture();
+
+    // 这里是集合端点,没内容就是空集合而不是 404——区间口径下空段落本来就被跳过,
+    // 单段只是区间长度为 1 的特例,两者口径要一致
+    $json = $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=9")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toBe([])
+        ->and($json['meta'])->toMatchArray(['total' => 0, 'remaining' => 0])
+        ->and($json['meta']['next_cursor'])->toBeNull();
+});
+
+it('rejects a range that ends before it starts', function () {
+    $channel = makeParagraphFixture();
+
+    $this->getJson("/api/v3/tipitaka-reading/{$channel}?book=9001&para=3&to=1")
+        ->assertStatus(422)
+        ->assertJsonPath('errors.to.0', __('site.invalid_parameter'));
+});
+
+it('caches the paragraph and drops the cache when a sentence changes', function () {
+    $channel = makeParagraphFixture();
+    $url = "/api/v3/tipitaka-reading/{$channel}?book=9001&para=1";
+    $this->getJson($url)->assertOk();
+
+    $key = PaliContentService::paragraphCacheKey(9001, 1, $channel, 'html');
+    expect(Cache::has($key))->toBeTrue();
+
+    $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first();
+    $sentence->content = 'changed sentence';
+    $sentence->save();
+
+    expect(Cache::has($key))->toBeFalse();
+    expect($this->getJson($url)->json('data.0.display'))->toContain('changed sentence');
+});
+
+it('drops the cache when a sentence is added or deleted', function () {
+    $channel = makeParagraphFixture();
+    $url = "/api/v3/tipitaka-reading/{$channel}?book=9001&para=1";
+    $this->getJson($url)->assertOk();
+
+    makeSentence($channel, 9001, 1, 3, 'third sentence');
+    expect($this->getJson($url.'&include=sentences')->json('data.0.sentences'))->toHaveCount(3);
+
+    Sentence::where('book_id', 9001)->where('paragraph', 1)->where('word_start', 3)->first()->delete();
+    expect($this->getJson($url.'&include=sentences')->json('data.0.sentences'))->toHaveCount(2);
+});

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

@@ -1,62 +0,0 @@
-<?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));
-});

+ 50 - 0
api-v13/tests/Unit/TipitakaReadingSliceTest.php

@@ -0,0 +1,50 @@
+<?php
+
+use App\Services\V3\TipitakaReadingService;
+
+/**
+ * sliceByBytes() 是纯函数,不碰数据库。段落数上限要造上千段才看得出来,走 HTTP
+ * 会真的去渲染每一段,所以这里直接对着方法本身测。
+ *
+ * 逻辑在 Service 里,所以不必再为了拿到它去造控制器的匿名子类——
+ * 这正是「业务逻辑一律进 Service」买到的好处之一。
+ *
+ * @param  array<int, int>  $paras  段落号,统一当作 book 9000
+ * @param  array<int, int>  $lengths  段落号 => 字节数
+ * @return array<int, int> 切出来的段落号
+ */
+function sliceParagraphs(array $paras, array $lengths, int $limit): array
+{
+    $candidates = array_map(fn (int $para) => ['book' => 9000, 'para' => $para], $paras);
+    $slice = app(TipitakaReadingService::class)->sliceByBytes($candidates, [9000 => $lengths], $limit);
+
+    return array_column($slice, 'para');
+}
+
+it('caps a byte-mode block at 200 paragraphs however short the paragraphs are', function () {
+    // 1000 段,每段 1 字节;不设段落上限的话 5000b 会把整章切成一块
+    $paras = range(1, 1000);
+    $lengths = array_fill_keys($paras, 1);
+
+    expect(sliceParagraphs($paras, $lengths, 5000))->toBe(range(1, 200))
+        ->and(sliceParagraphs(range(201, 1000), $lengths, 5000))->toBe(range(201, 400))
+        ->and(sliceParagraphs(range(901, 1000), $lengths, 5000))->toBe(range(901, 1000));
+});
+
+it('still breaks on the byte limit when paragraphs are long enough', function () {
+    $paras = range(1, 10);
+    $lengths = array_fill_keys($paras, 100);
+
+    expect(sliceParagraphs($paras, $lengths, 250))->toBe([1, 2, 3])
+        ->and(sliceParagraphs(range(4, 10), $lengths, 250))->toBe([4, 5, 6]);
+});
+
+it('never returns an empty byte-mode block when lengths are missing', function () {
+    // pali_texts 里查不到长度时按 0 算,靠段落数上限收口,不会死循环也不会空块
+    expect(sliceParagraphs(range(1, 1000), [], 5000))->toBe(range(1, 200))
+        ->and(sliceParagraphs(range(801, 1000), [], 5000))->toBe(range(801, 1000));
+});
+
+it('returns an empty block for no candidates', function () {
+    expect(sliceParagraphs([], [], 5000))->toBe([]);
+});