فهرست منبع

feat(api-v13): 注释书对应改叫 type=commentary,另加 type=note 普通边注

两种批注走同一个注入口 injectAnnotationNotes,除出处外处理完全一样:

- commentary:content 是下一层句子模板 {{book-para-start-end}},边注正文取这几句
  在当前 channel 的译文,末尾带 <cite>义注</cite> 跳转锚点(原来的 type=note);
- note:content 就是注解正文,边注没有出处。

旧数据由迁移改名:content 整体是句子模板的 note → commentary,其余 note 原样
留着当普通边注。改名前后渲染结果一样,所以迁移走 query builder,不清阅读页缓存。

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

+ 3 - 3
api-v13/app/Http/Controllers/DiscussionController.php

@@ -30,7 +30,7 @@ class DiscussionController extends Controller
      *
      * @queryParam view string required 查询口径。question=按资源查顶级讨论;question-by-topic=按某条讨论所属资源查同资源讨论;answer=查某讨论的回复;res_id=某资源的顶级节点及其直接子节点;topic-by-user=当前用户发表的全部顶级讨论;all=全部顶级讨论。Enum: question,question-by-topic,answer,res_id,topic-by-user,all
      * @queryParam id string required 资源 uid(question/res_id)、讨论 id(question-by-topic/answer)
-     * @queryParam type string 讨论类型。Enum: discussion,qa,help,note Default: discussion
+     * @queryParam type string 讨论类型。Enum: discussion,qa,help,note,commentary Default: discussion
      * @queryParam res_type string 资源类型,影响权限判断与学员提问聚合。Enum: sentence,wbw,article
      * @queryParam status string 状态过滤;res_id 与 topic-by-user 口径支持逗号分隔多选。Enum: active,close Default: active
      * @queryParam course string 课程 uid,配合 show_student 聚合学员提问
@@ -341,7 +341,7 @@ class DiscussionController extends Controller
      * @bodyParam title string required 标题(有 parent 时非必填)
      * @bodyParam content string 正文内容
      * @bodyParam content_type string 正文格式。Default: markdown
-     * @bodyParam type string 讨论类型。Enum: discussion,qa,help,note Default: discussion
+     * @bodyParam type string 讨论类型。Enum: discussion,qa,help,note,commentary Default: discussion
      * @bodyParam tpl_id string 模板 id
      * @bodyParam pos_start integer 锚点在资源文本中的起始偏移,非负整数
      * @bodyParam pos_end integer 锚点在资源文本中的结束偏移,非负整数
@@ -489,7 +489,7 @@ class DiscussionController extends Controller
      * @bodyParam title string 标题,未提交会被清空
      * @bodyParam content string 正文内容,未提交会被清空
      * @bodyParam status string 状态,未提交会重置为 active。Enum: active,close Default: active
-     * @bodyParam type string 讨论类型,仅在提交时更新。Enum: discussion,qa,help,note
+     * @bodyParam type string 讨论类型,仅在提交时更新。Enum: discussion,qa,help,note,commentary
      * @bodyParam pos_start integer 锚点起始偏移,仅在提交时更新
      * @bodyParam pos_end integer 锚点结束偏移,仅在提交时更新
      * @bodyParam quote_exact string 锚定的原文片段,仅在提交时更新

+ 14 - 5
api-v13/app/Models/Discussion.php

@@ -36,16 +36,25 @@ class Discussion extends Model
         'quote_suffix',
     ];
 
+    /** 注释书对应:content 是下一层句子模板,渲染时带 <cite> 出处 */
+    public const TYPE_COMMENTARY = 'commentary';
+
+    /** 普通边注:content 就是注解正文,没有出处 */
+    public const TYPE_NOTE = 'note';
+
+    /** 会被注入阅读页的批注类型 */
+    public const INJECTED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE];
+
     /**
-     * 注释记录(type='note')会被注入到所挂句子的阅读页里(PaliContentService::
-     * injectAnnotationNotes),阅读页按 (book, para, channel) 缓存,所以增改删 note
-     * 都要清掉那一段的缓存。改之前是 note、改之后不是(或反过来)的也要清。
+     * 这两类批注会被注入到所挂句子的阅读页里(PaliContentService::
+     * injectAnnotationNotes),阅读页按 (book, para, channel) 缓存,所以增改删它们
+     * 都要清掉那一段的缓存。改之前是、改之后不是(或反过来)的也要清。
      */
     protected static function booted(): void
     {
         $forget = function (Discussion $discussion) {
-            $wasNote = $discussion->getOriginal('type') === 'note';
-            if ($discussion->type !== 'note' && ! $wasNote) {
+            $wasInjected = in_array($discussion->getOriginal('type'), self::INJECTED_TYPES, true);
+            if (! in_array($discussion->type, self::INJECTED_TYPES, true) && ! $wasInjected) {
                 return;
             }
             if ($discussion->res_type !== 'sentence' || empty($discussion->res_id)) {

+ 48 - 30
api-v13/app/Services/PaliContentService.php

@@ -721,10 +721,14 @@ class PaliContentService
     }
 
     /**
-     * 段落注释(义注/复注内嵌):把锚定到该句子的注释记录渲染成 {{note}} 模板,
-     * 按位置插入句子内容,并在其后追加 <cite> 作为跳转锚点。
+     * 段落批注内嵌:把锚定到该句子的批注渲染成 {{note}} 模板,按位置插入句子内容。
      *
-     * 同一句可能有多条注释,按 pos_end 倒序插入 —— 先插靠后的,避免先插入的
+     * 两种批注一起注入,除出处外处理完全一样:
+     * - `type='commentary'`(注释书对应):content 是义注 / 复注的句子模板
+     *   `{{book-para-start-end}}`,渲染成这几句的译文,并追加 <cite> 作为跳转锚点;
+     * - `type='note'`(普通边注):content 就是注解正文(markdown),没有出处,不出 <cite>。
+     *
+     * 同一句可能有多条批注,按 pos_end 倒序插入 —— 先插靠后的,避免先插入的
      * 模板改变后面位置的字符偏移(见 docs/reading-annotations.md §4)。
      */
     protected function injectAnnotationNotes(Sentence $row, string $channelType): array
@@ -733,7 +737,7 @@ class PaliContentService
         $result = ['content' => $content, 'notes' => []];
         $notes = Discussion::where('res_type', 'sentence')
             ->where('res_id', $row->uid)
-            ->where('type', 'note')
+            ->whereIn('type', Discussion::INJECTED_TYPES)
             ->get();
 
         if ($notes->isEmpty()) {
@@ -745,7 +749,8 @@ 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) {
                 $pos = $note->pos_end;
@@ -761,26 +766,35 @@ class PaliContentService
         })->values();
         $collected = [];
         foreach ($notes as $note) {
-            // content 是一个或多个义注句子模板 {{book-para-start-end}}(一个词可能由义注
-            // 多句解释,按顺序并列,如 {{135-404-50-54}}{{135-404-55-65}})。
-            // 不是这种格式的记录不认,跳过——不插角标,也不进脚注列表。
             $noteContent = trim((string) $note->content);
-            if (! preg_match('/^(?:\{\{\d+-\d+-\d+-\d+\}\}\s*)+$/', $noteContent)) {
+            if ($noteContent === '') {
                 continue;
             }
-            preg_match_all('/\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}/', $noteContent, $sents, PREG_SET_ORDER);
-            // 义注实际内容:先用 MdRender 渲染义注句子模板得到,
-            // 再放进 {{note|text=…}} —— 直接嵌套 {{…}} 会被 wiki2xml 的平铺替换破坏。
-            // 用 text 格式渲染义注内容:避免「1.」被 markdown 解释成有序列表,
+            $sents = [];
+            if ($note->type === Discussion::TYPE_COMMENTARY) {
+                // content 是一个或多个义注句子模板 {{book-para-start-end}}(一个词可能由义注
+                // 多句解释,按顺序并列,如 {{135-404-50-54}}{{135-404-55-65}})。
+                // 不是这种格式的记录不认,跳过——不插角标,也不进脚注列表。
+                if (! preg_match('/^(?:\{\{\d+-\d+-\d+-\d+\}\}\s*)+$/', $noteContent)) {
+                    continue;
+                }
+                preg_match_all('/\{\{(\d+)-(\d+)-(\d+)-(\d+)\}\}/', $noteContent, $sents, PREG_SET_ORDER);
+                // 义注正文只需译文(不要巴利原文):把每个裸句模板 {{book-para-start-end}}
+                // 转成 {{sent|id=…|text=translation}},让 sent 模板只输出 translation。
+                $source = implode(' ', array_map(
+                    fn ($s) => '{{sent|id='.$s[1].'-'.$s[2].'-'.$s[3].'-'.$s[4].'|text=translation}}',
+                    $sents
+                ));
+            } else {
+                // 普通边注:content 就是注解正文,照原样渲染。
+                $source = $noteContent;
+            }
+            // 边注实际内容:先用 MdRender 渲染出来,再放进 {{note|text=…}} ——
+            // 直接嵌套 {{…}} 会被 wiki2xml 的平铺替换破坏。
+            // 用 text 格式渲染:避免「1.」被 markdown 解释成有序列表,
             // 产生 <ol></p></p> 这类坏 HTML 把 sidenote 的闭合结构破坏、吞掉后续正文。
-            // 义注正文只需译文(不要巴利原文):把每个裸句模板 {{book-para-start-end}}
-            // 转成 {{sent|id=…|text=translation}},让 sent 模板只输出 translation。
-            $noteTpl = implode(' ', array_map(
-                fn ($s) => '{{sent|id='.$s[1].'-'.$s[2].'-'.$s[3].'-'.$s[4].'|text=translation}}',
-                $sents
-            ));
             $noteHtml = MdRender::render(
-                $noteTpl,
+                $source,
                 [$row->channel_uid],
                 null,
                 'read',
@@ -792,18 +806,22 @@ class PaliContentService
             if ($pos === null || $pos < 0 || $pos > $len) {
                 $pos = $len;
             }
-            // 跳转目标(义注书-段-起-止):同时挂在 <cite> 和角标 <label> 上,
-            // 前者用于「点链接跳页」,后者用于平板双栏「点角标跨栏高亮」。
+            // 出处只有 commentary 有。跳转目标(义注书-段-起-止)同时挂在 <cite> 和角标
+            // <label> 上,前者用于「点链接跳页」,后者用于平板双栏「点角标跨栏高亮」。
+            // 多句时跳转到第一句(义注对这个词的解释从那里开始)。
+            $citeHtml = '';
+            $citeParam = '';
+            if ($sents) {
+                $m = $sents[0];
+                $target = ' data-book="'.$m[1].'" data-para="'.$m[2].'" data-start="'.$m[3].'" data-end="'.$m[4].'"';
+                $citeHtml = '<cite class="anno-jump"'.$target.'>义注</cite>';
+                $citeParam = '|cite=义注'
+                    .'|citelink='.$m[1].'-'.$m[2].'-'.$m[3].'-'.$m[4];
+            }
             // 用 {{note}} 模板渲染 tufte sidenote(label + input + span.sidenote),
             // 复用 render_note() 的结构,不再手拼 sidenote HTML。
-            // text 传已预渲染的纯文本译文(嵌套 {{…}} 会被 wiki2xml 平铺替换破坏)。
-            // 多句时跳转到第一句(义注对这个词的解释从那里开始)。
-            $m = $sents[0];
-            $target = ' data-book="'.$m[1].'" data-para="'.$m[2].'" data-start="'.$m[3].'" data-end="'.$m[4].'"';
-            $citeHtml = '<cite class="anno-jump"'.$target.'>义注</cite>';
-            $noteTplInline = '{{note|text='.$noteHtml
-                .'|cite=义注'
-                .'|citelink='.$m[1].'-'.$m[2].'-'.$m[3].'-'.$m[4].'}}';
+            // text 传已预渲染的纯文本(嵌套 {{…}} 会被 wiki2xml 平铺替换破坏)。
+            $noteTplInline = '{{note|text='.$noteHtml.$citeParam.'}}';
             $content = mb_substr($content, 0, $pos, 'UTF-8')
                 .$noteTplInline
                 .mb_substr($content, $pos, null, 'UTF-8');

+ 34 - 0
api-v13/database/migrations/2026_09_21_100000_rename_note_discussions_to_commentary.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    /**
+     * 注释书对应从 type='note' 改叫 type='commentary',把 note 让给「普通边注」。
+     *
+     * 两者的区别只在出处:commentary 的 content 是下一层的句子模板
+     * `{{book-para-start-end}}`,渲染时带 <cite> 跳转;note 的 content 是注解正文,没有出处。
+     * 所以只把 content 整体是句子模板的旧记录改名,其余 note 原样留着当普通边注。
+     *
+     * 走 query builder 而非模型:不触发 Discussion 的缓存清除事件——改名前后
+     * 这些记录的渲染结果一样,阅读页缓存仍然有效。
+     */
+    private const SENTENCE_TPL = '^(\{\{[0-9]+-[0-9]+-[0-9]+-[0-9]+\}\}[[:space:]]*)+$';
+
+    public function up(): void
+    {
+        DB::table('discussions')
+            ->where('type', 'note')
+            ->where('content', '~', self::SENTENCE_TPL)
+            ->update(['type' => 'commentary']);
+    }
+
+    public function down(): void
+    {
+        DB::table('discussions')
+            ->where('type', 'commentary')
+            ->update(['type' => 'note']);
+    }
+};

+ 4 - 4
api-v13/tests/Feature/DiscussionControllerTest.php

@@ -13,7 +13,7 @@ it('stores the annotation selector fields on create', function () {
     $response = $this->postJson('/api/v2/discussion', [
         'res_id' => $resId,
         'res_type' => 'sentence',
-        'type' => 'note',
+        'type' => 'commentary',
         'title' => '义注',
         'content' => '{{1-2-3-4}}',
         'pos_start' => 10,
@@ -46,7 +46,7 @@ it('leaves annotation selector fields null when not provided', function () {
     $this->postJson('/api/v2/discussion', [
         'res_id' => (string) Str::uuid(),
         'res_type' => 'sentence',
-        'type' => 'note',
+        'type' => 'commentary',
         'title' => '无锚点',
         'content' => '{{1-2-3-4}}',
         'notification' => false,
@@ -81,7 +81,7 @@ it('updates only the annotation fields present in the request', function () {
     $discussion->forceFill([
         'res_id' => (string) Str::uuid(),
         'res_type' => 'sentence',
-        'type' => 'note',
+        'type' => 'commentary',
         'title' => '旧标题',
         'content' => '{{1-2-3-4}}',
         'editor_uid' => $userUid,
@@ -114,7 +114,7 @@ it('allows clearing an annotation field explicitly', function () {
     $discussion->forceFill([
         'res_id' => (string) Str::uuid(),
         'res_type' => 'sentence',
-        'type' => 'note',
+        'type' => 'commentary',
         'title' => '旧标题',
         'editor_uid' => $userUid,
         'quote_exact' => '旧摘录',

+ 97 - 0
api-v13/tests/Feature/ReadingAnnotationsTest.php

@@ -0,0 +1,97 @@
+<?php
+
+use App\Models\Discussion;
+use App\Services\PaliContentService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 阅读页批注内嵌:type='commentary'(注释书对应,带出处)与 type='note'(普通边注,无出处)。
+ */
+
+/** 建 channel + 一句根本译文(book 9101 第 1 段),返回 [channel uid, 句子] */
+function makeAnnotatedSentence(): array
+{
+    $channel = makeChannel(makeStudio('anno-owner'), 'anno channel');
+    $sentence = makeSentence($channel, 9101, 1, 1, 'first sentence');
+
+    return [$channel, $sentence];
+}
+
+function makeAnnotation(string $resId, string $type, string $content, int $posEnd): Discussion
+{
+    $discussion = new Discussion;
+    $discussion->forceFill([
+        'res_id' => $resId,
+        'res_type' => 'sentence',
+        'type' => $type,
+        'title' => $content,
+        'content' => $content,
+        'content_type' => 'markdown',
+        'editor_uid' => makeStudio('anno-'.Str::random(6)),
+        'pos_start' => 0,
+        'pos_end' => $posEnd,
+        'quote_exact' => 'first',
+    ])->save();
+
+    return $discussion;
+}
+
+it('injects a commentary annotation with a cite link', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    // 义注句:commentary 的 content 是它的句子模板,渲染成它在同 channel 的译文
+    makeSentence($channel, 9102, 7, 3, '义注这么说');
+    makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
+
+    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+        ->assertOk()
+        ->json('data.display');
+
+    expect($display)
+        ->toContain('义注这么说')
+        ->toContain('<cite class="anno-jump" data-book="9102" data-para="7" data-start="3" data-end="3">义注</cite>');
+});
+
+it('injects a plain note annotation without a cite link', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    // 普通边注:content 就是注解正文本身
+    makeAnnotation($sentence->uid, 'note', '这里的 dassana 指见到佛陀。', 5);
+
+    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+        ->assertOk()
+        ->json('data.display');
+
+    // 正文照原样进 sidenote,紧跟 </span> 收尾——没有 <cite> 出处
+    expect($display)
+        ->toContain('这里的 dassana 指见到佛陀。</span>')
+        ->not->toContain('anno-jump');
+});
+
+it('injects both kinds on the same sentence, note first at the same position', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    makeSentence($channel, 9102, 7, 3, '义注这么说');
+    makeAnnotation($sentence->uid, 'commentary', '{{9102-7-3-3}}', 5);
+    makeAnnotation($sentence->uid, 'note', '译者按。', 5);
+
+    $display = $this->getJson("/api/v3/tipitaka-read-para/9101-1?channel={$channel}")
+        ->assertOk()
+        ->json('data.display');
+
+    expect(mb_strpos($display, '译者按。'))->toBeLessThan(mb_strpos($display, '义注这么说'));
+});
+
+it('drops the paragraph cache when either kind of annotation changes', function () {
+    [$channel, $sentence] = makeAnnotatedSentence();
+    $url = "/api/v3/tipitaka-read-para/9101-1?channel={$channel}";
+    $this->getJson($url)->assertOk();
+
+    $key = PaliContentService::paragraphCacheKey(9101, 1, $channel, 'html');
+    expect(Cache::has($key))->toBeTrue();
+
+    makeAnnotation($sentence->uid, 'note', '译者按。', 5);
+    expect(Cache::has($key))->toBeFalse();
+    expect($this->getJson($url)->json('data.display'))->toContain('译者按。');
+});