Преглед на файлове

Merge pull request #2472 from visuddhinanda/development

Development
visuddhinanda преди 3 седмици
родител
ревизия
1b9e8de51d

+ 2 - 1
api-v13/app/Http/Controllers/ProgressChapterController.php

@@ -467,9 +467,10 @@ class ProgressChapterController extends Controller
      *
      * @return Response
      */
-    public function show(ProgressChapter $progressChapter)
+    public function show(ProgressChapter $progress)
     {
         //
+        return $this->ok($progress);
     }
 
     /**

+ 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);
     }
 
     /**

+ 19 - 4
api-v13/app/Services/AuthService.php

@@ -7,6 +7,7 @@ use App\Models\AiModel;
 use Firebase\JWT\JWT;
 use Firebase\JWT\Key;
 use Illuminate\Http\Request;
+use Illuminate\Support\Str;
 
 class AuthService
 {
@@ -101,7 +102,8 @@ class AuthService
      * 校验模型身份 token 是否已被撤销。
      *
      * 撤销即把 ai_models.token_version 自增,旧 token 里的 ver 随即对不上。
-     * 模型被删除同样视为失效。人类 token(id 为用户自增主键,恒 > 0)直接放行,不查库。
+     * 模型被删除同样视为失效。人类 token 直接放行,不查库;唯一的例外是
+     * root 管理员(自增主键 id 恰好是 0),见方法内 id === 0 分支的说明。
      */
     private static function modelTokenIsValid(object $jwt): bool
     {
@@ -111,9 +113,22 @@ class AuthService
             return $version !== null && (int) $version === (int) ($jwt->ver ?? 0);
         }
 
-        // 引入版本号之前签出的模型 token(typ 缺失、id 恒为 0)无法撤销,一律作废,
-        // 持有者须重新签发。
-        return (int) ($jwt->id ?? 0) !== 0;
+        // 人类 token(自增主键 id > 0)直接放行,不查库。
+        if ((int) ($jwt->id ?? 0) !== 0) {
+            return true;
+        }
+
+        // id === 0 的 token 有两种来源,不能再只凭 id 判死:
+        //   1. root 管理员的人类 token —— 其自增主键 id 恰好是 0(历史种子数据);
+        //   2. 引入版本号之前签出的旧模型 token(typ 缺失、id 恒为 0)—— 无法撤销。
+        // 旧模型 token 的 uid 一定落在 ai_models 表,root 的 uid 不在其中,
+        // 故用「uid 是否命中 ai_models」区分:命中 → 旧模型 token,作废;未命中 → 人类,放行。
+        // ai_models.uid 是 uuid 列,uid 不是合法 UUID 时查库会报类型错误,先挡掉。
+        if (! Str::isUuid((string) $jwt->uid)) {
+            return false;
+        }
+
+        return ! AiModel::where('uid', $jwt->uid)->exists();
     }
 
     /**

+ 14 - 2
api-v13/app/Services/PaliContentService.php

@@ -702,6 +702,12 @@ class PaliContentService
      */
     private const FORMATS = ['html', 'markdown', 'react', 'text'];
 
+    /**
+     * 义注对应(commentary)批注最多渲染几句。
+     * 一条批注可能挂着十几句义注,全铺进边注会把正文挤没,只取前几句。
+     */
+    private const MAX_COMMENTARY_SENTENCES = 3;
+
     /**
      * 段落阅读模式缓存的 key
      */
@@ -749,7 +755,7 @@ class PaliContentService
         // 插入顺序:按插入点倒序(先插靠后的,免得前面的插入改变后面的偏移);
         // 插入点相同的,按义注原文的先后倒序——同一位置后插入的排在前面,
         // 倒序插入后读起来就是义注原文顺序。插入点与下面的越界处理口径一致
-        //(null / 越界都算句尾)。义注坐标取 content 第一句的 book-para-start;
+        // (null / 越界都算句尾)。义注坐标取 content 第一句的 book-para-start;
         // 普通 note 没有坐标,取 0,同一插入点上读起来排在义注对应之前。
         $notes = $notes->sort(function ($a, $b) use ($len) {
             $key = function ($note) use ($len) {
@@ -781,9 +787,11 @@ class PaliContentService
                 preg_match_all('/\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}/', $noteContent, $sents, PREG_SET_ORDER);
                 // 义注正文只需译文(不要巴利原文):把每个裸句模板 {{book-para-start-end}}
                 // 转成 {{sent|id=…|text=translation}},让 sent 模板只输出 translation。
+                // 句子可能有很多条,只渲染前 MAX_COMMENTARY_SENTENCES 句;
+                // 跳转锚点仍指向第一句,点进去能看到完整义注。
                 $source = implode(' ', array_map(
                     fn ($s) => '{{sent|id='.$s[1].'-'.$s[2].'-'.$s[3].'-'.$s[4].'|text=translation}}',
-                    $sents
+                    array_slice($sents, 0, self::MAX_COMMENTARY_SENTENCES)
                 ));
             } else {
                 // 普通边注:content 就是注解正文,照原样渲染。
@@ -802,6 +810,10 @@ class PaliContentService
                 'markdown',
                 'text'
             );
+            // 义注被截断时补省略号,提示后面还有;想看全文点 <cite> 跳过去。
+            if (count($sents) > self::MAX_COMMENTARY_SENTENCES) {
+                $noteHtml = rtrim($noteHtml).'...';
+            }
             $pos = $note->pos_end;
             if ($pos === null || $pos < 0 || $pos > $len) {
                 $pos = $len;

+ 7 - 0
api-v13/tests/Feature/AiModelTokenTest.php

@@ -137,3 +137,10 @@ it('rejects pre-versioning model tokens', function () {
 it('leaves human tokens alone', function () {
     expect(currentUid(userToken('a-user-uid', 42)))->toBe('a-user-uid');
 });
+
+it('leaves the root human token alone even though its id is 0', function () {
+    // root 管理员的自增主键 id 恰好是 0(历史种子数据),
+    // 不能与「旧模型 token(无 typ、id 恒为 0)」混淆而误杀。
+    $uid = (string) Str::uuid();
+    expect(currentUid(userToken($uid, 0)))->toBe($uid);
+});

+ 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));
+});

+ 52 - 26
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`
          */
@@ -16267,7 +16277,7 @@ export interface operations {
                 /** @description 状态过滤;res_id 与 topic-by-user 口径支持逗号分隔多选 */
                 status?: "active" | "close";
                 /** @description 讨论类型 */
-                type?: "discussion" | "qa" | "help" | "note";
+                type?: "discussion" | "qa" | "help" | "note" | "commentary";
                 /** @description 资源类型,影响权限判断与学员提问聚合 */
                 res_type?: "sentence" | "wbw" | "article";
                 /** @description 课程 uid,配合 show_student 聚合学员提问 */
@@ -16357,11 +16367,11 @@ export interface operations {
                      * @default discussion
                      * @enum {string}
                      */
-                    type?: "discussion" | "qa" | "help" | "note";
+                    type?: "discussion" | "qa" | "help" | "note" | "commentary";
                     /** @description 模板 id */
                     tpl_id?: string;
-                    /** @description 标题(有 parent 时非必填) */
-                    title: string;
+                    /** @description 标题(可选) */
+                    title?: string;
                     /** @description 正文内容 */
                     content?: string;
                     /**
@@ -16733,7 +16743,7 @@ export interface operations {
                      * @description 讨论类型,仅在提交时更新
                      * @enum {string}
                      */
-                    type?: "discussion" | "qa" | "help" | "note";
+                    type?: "discussion" | "qa" | "help" | "note" | "commentary";
                     /** @description 锚点起始偏移,仅在提交时更新 */
                     pos_start?: number;
                     /** @description 锚点结束偏移,仅在提交时更新 */
@@ -16843,7 +16853,7 @@ export interface operations {
                      * @description 讨论类型,仅在提交时更新
                      * @enum {string}
                      */
-                    type?: "discussion" | "qa" | "help" | "note";
+                    type?: "discussion" | "qa" | "help" | "note" | "commentary";
                     /** @description 锚点起始偏移,仅在提交时更新 */
                     pos_start?: number;
                     /** @description 锚点结束偏移,仅在提交时更新 */
@@ -32257,7 +32267,10 @@ export interface operations {
             query: {
                 /**
                  * @description 要删除的词条 id 数组的 JSON 字符串
-                 * @example ["123","456"]
+                 * @example [
+                 *       "123",
+                 *       "456"
+                 *     ]
                  */
                 id: string;
             };
@@ -32374,7 +32387,10 @@ export interface operations {
             query?: {
                 /**
                  * @description 要批量删除的词条 id 数组的 JSON 字符串
-                 * @example ["123","456"]
+                 * @example [
+                 *       "123",
+                 *       "456"
+                 *     ]
                  */
                 id?: string;
             };
@@ -33983,12 +33999,18 @@ export interface operations {
                 tags?: string;
                 /**
                  * @description 页码标记
-                 * @example ["V3.81","M3.58"]
+                 * @example [
+                 *       "V3.81",
+                 *       "M3.58"
+                 *     ]
                  */
                 page_refs?: unknown[];
                 /**
                  * @description 关联 id
-                 * @example ["chapter_93-5","m.n. 38"]
+                 * @example [
+                 *       "chapter_93-5",
+                 *       "m.n. 38"
+                 *     ]
                  */
                 related_id?: unknown[];
                 /** @description 按作者或译者过滤 */
@@ -34148,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;
@@ -34187,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: {

+ 26 - 22
dashboard-v6/src/components/users/SignIn.tsx

@@ -49,32 +49,36 @@ const Widget = () => {
             "/api/v2/sign-in",
             user
           );
+          if (!res.ok) {
+            setError("用户名或密码错误");
+            return;
+          }
           //TODO sign-in 同时返回用户信息,之后直接dispatch 无需二次查询
-          if (res.ok) {
-            set(res.data);
-            get<IUserResponse>("/api/v2/auth/current").then((json) => {
-              if (json.ok) {
-                console.debug("获取用户信息成功", json.data);
-                dispatch(signIn([json.data, res.data]));
-                let url: string | null = null;
-                searchParams.forEach((value: string, key: string) => {
-                  if (key === "url") {
-                    url = value;
-                  }
-                });
-                if (url) {
-                  window.location.href = atob(url);
-                } else {
-                  navigate(TO_WORKSPACE);
-                }
-              } else {
-                setError("用户名或密码错误");
-                console.error(json.message);
+          set(res.data);
+          try {
+            const json = await get<IUserResponse>("/api/v2/auth/current");
+            if (!json.ok) {
+              setError("用户名或密码错误");
+              console.error(json.message);
+              return;
+            }
+            console.debug("获取用户信息成功", json.data);
+            dispatch(signIn([json.data, res.data]));
+            let url: string | null = null;
+            searchParams.forEach((value: string, key: string) => {
+              if (key === "url") {
+                url = value;
               }
             });
+            if (url) {
+              window.location.href = atob(url);
+            } else {
+              navigate(TO_WORKSPACE);
+            }
             message.success(intl.formatMessage({ id: "flashes.success" }));
-          } else {
-            setError("用户名或密码错误");
+          } catch (e) {
+            console.error(e);
+            setError("登录失败,请重试");
           }
         }}
       >

+ 2 - 0
openapi/public/assets/protocol/resources/auto/v2-discussion-discussion.yaml

@@ -126,6 +126,7 @@ put:
                 - qa
                 - help
                 - note
+                - commentary
             pos_start:
               type: integer
               description: 锚点起始偏移,仅在提交时更新
@@ -251,6 +252,7 @@ patch:
                 - qa
                 - help
                 - note
+                - commentary
             pos_start:
               type: integer
               description: 锚点起始偏移,仅在提交时更新

+ 3 - 2
openapi/public/assets/protocol/resources/auto/v2-discussion.yaml

@@ -54,6 +54,7 @@ get:
           - qa
           - help
           - note
+          - commentary
         default: discussion
       description: 讨论类型
     -
@@ -185,13 +186,14 @@ post:
                 - qa
                 - help
                 - note
+                - commentary
               default: discussion
             tpl_id:
               type: string
               description: '模板 id'
             title:
               type: string
-              description: '标题(有 parent 时非必填)'
+              description: 标题(可选)
             content:
               type: string
               description: 正文内容
@@ -221,7 +223,6 @@ post:
           required:
             - res_id
             - res_type
-            - title
   responses:
     200:
       description: '成功。业务失败时 HTTP 仍可能为 200,以 ok 字段为准。'

+ 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: 成功