Browse Source

refactor(api): 段落缓存改用 tag 失效,补 tipitaka-content-para 测试

- readParagraph 去掉 level 参数,内部用 paragraphLevel 从 pali_texts 取标题级别
- 缓存 key 不含 level,段落外壳在缓存外拼接;用 Cache tag 失效,无需遍历 format
- Sentence 的 saved/deleted/restored/forceDeleted 均清除段落缓存
- 新增 TipitakaContentParaTest 覆盖渲染、标题、区间、格式、参数校验与缓存失效

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zSe1kZS4JmKFJ5igD555X
visuddhinanda 2 weeks ago
parent
commit
d3139fedd7

+ 0 - 2
api-v13/app/Console/Commands/IndexTipitaka.php

@@ -331,12 +331,10 @@ class IndexTipitaka extends Command
                 $title = '';
                 foreach ($paraList as $para) {
                     $para = (int) $para;
-                    $level = $para === $start ? $chapter->level : 0;
                     $paragraph = app(PaliContentService::class)->readParagraph(
                         $book,
                         $para,
                         $channel->channel_uid,
-                        $level,
                         'html'
                     );
                     if (empty($paragraph['display'])) {

+ 0 - 16
api-v13/app/Http/Controllers/TipitakaContentParaController.php

@@ -2,7 +2,6 @@
 
 namespace App\Http\Controllers;
 
-use App\Models\PaliText;
 use App\Services\PaliContentService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
@@ -36,7 +35,6 @@ class TipitakaContentParaController extends Controller
                 (int) $data['book'],
                 (int) $para,
                 $data['channel'],
-                $this->paraLevel((int) $data['book'], (int) $para),
                 $format
             );
             if (empty($paragraph['display'])) {
@@ -75,7 +73,6 @@ class TipitakaContentParaController extends Controller
             $book,
             $para,
             $channel,
-            $this->paraLevel($book, $para),
             $request->input('format', 'html')
         );
         if (empty($paragraph['display'])) {
@@ -84,17 +81,4 @@ class TipitakaContentParaController extends Controller
 
         return $this->ok($paragraph);
     }
-
-    /**
-     * 段落是章节标题时返回标题级别,否则 0
-     */
-    protected function paraLevel(int $book, int $para): int
-    {
-        $level = PaliText::where('book', $book)
-            ->where('paragraph', $para)
-            ->where('level', '<', 8)
-            ->value('level');
-
-        return $level ? (int) $level : 0;
-    }
 }

+ 2 - 0
api-v13/app/Models/Sentence.php

@@ -48,6 +48,8 @@ class Sentence extends Model
         };
         static::saved($forget);
         static::deleted($forget);
+        static::restored($forget);
+        static::forceDeleted($forget);
     }
 
     protected $dates = [

+ 62 - 38
api-v13/app/Services/PaliContentService.php

@@ -648,46 +648,85 @@ class PaliContentService
      * 直接从 sentences 表取单个 channel 的记录,渲染成 html。
      * 与 paragraphs() 不同:没有译文的句子不保留占位,有多少句子输出多少句子。
      *
-     * @param  int  $level  标题级别,大于 0 时段落用 h{level} 包裹
      * @return array{para: int, display: string, sentences: array<int, array{sid: string, html: string}>}
      */
-    public function readParagraph(int $book, int $para, string $channelUid, int $level = 0, string $format = 'html'): array
+    public function readParagraph(int $book, int $para, string $channelUid, string $format = 'html'): array
     {
-        $version = Cache::get(self::paragraphVersionKey($book, $para, $channelUid), 0);
-        $key = "/read-para/{$book}-{$para}/{$channelUid}/{$level}/{$format}/{$version}";
+        $level = $this->paragraphLevel($book, $para);
+        // 缓存句子,段落外壳与标题级别有关,不进缓存
+        $key = self::paragraphCacheKey($book, $para, $channelUid, $format);
+        $cached = Cache::tags([self::paragraphCacheTag($book, $para, $channelUid)])
+            ->rememberForever($key, function () use ($book, $para, $channelUid, $format) {
+                return $this->renderReadSentences($book, $para, $channelUid, $format);
+            });
+
+        $result = [
+            'para' => $para,
+            'display' => '',
+            'sentences' => $cached['sentences'],
+        ];
+        if (count($cached['display']) === 0) {
+            return $result;
+        }
+
+        if ($format === 'html') {
+            // html 格式加段落外壳
+            $content = implode('', $cached['display']);
+            $inner = $level > 0 ? "<h{$level}>{$content}</h{$level}>" : "<div class='para-block'>{$content}</div>";
+            $result['display'] = "<div class='{$cached['area']}' data-para='{$para}'>{$inner}</div>";
+        } else {
+            // 其他格式一行一句
+            $result['display'] = implode("\n", $cached['display']);
+        }
+
+        return $result;
+    }
+
+    /**
+     * 段落是章节标题时返回标题级别,否则 0
+     */
+    public function paragraphLevel(int $book, int $para): int
+    {
+        $level = PaliText::where('book', $book)
+            ->where('paragraph', $para)
+            ->where('level', '<', 8)
+            ->value('level');
 
-        return Cache::rememberForever($key, function () use ($book, $para, $channelUid, $level, $format) {
-            return $this->renderReadParagraph($book, $para, $channelUid, $level, $format);
-        });
+        return $level ? (int) $level : 0;
     }
 
     /**
-     * 段落缓存版本号的 key。句子有增改删时版本号加一,相关缓存自然失效。
+     * 段落阅读模式缓存的 key
      */
-    public static function paragraphVersionKey(int $book, int $para, string $channelUid): string
+    public static function paragraphCacheKey(int $book, int $para, string $channelUid, string $format): string
     {
-        return "/read-para/version/{$book}-{$para}/{$channelUid}";
+        return "/read-para/{$book}-{$para}/{$channelUid}/{$format}";
     }
 
     /**
-     * 使某个段落的阅读模式缓存失效
+     * 段落缓存的 tag。一个段落一个 channel 的全部格式共用一个 tag
+     */
+    public static function paragraphCacheTag(int $book, int $para, string $channelUid): string
+    {
+        return "read-para:{$book}-{$para}:{$channelUid}";
+    }
+
+    /**
+     * 删除某个段落的阅读模式缓存。句子有增改删时调用,下次 readParagraph 自动重建。
      */
     public static function forgetParagraph(int $book, int $para, string $channelUid): void
     {
-        $key = self::paragraphVersionKey($book, $para, $channelUid);
-        if (Cache::has($key)) {
-            Cache::increment($key);
-        } else {
-            Cache::forever($key, 1);
-        }
+        Cache::tags([self::paragraphCacheTag($book, $para, $channelUid)])->flush();
     }
 
     /**
-     * @return array{para: int, display: string, sentences: array<int, array{sid: string, html: string}>}
+     * 渲染段落里的句子。不含段落外壳,可直接缓存。
+     *
+     * @return array{area: string, display: array<int, string>, sentences: array<int, array{sid: string, html: string}>}
      */
-    protected function renderReadParagraph(int $book, int $para, string $channelUid, int $level, string $format): array
+    protected function renderReadSentences(int $book, int $para, string $channelUid, string $format): array
     {
-        $result = ['para' => $para, 'display' => '', 'sentences' => []];
+        $result = ['area' => 'translation', 'display' => [], 'sentences' => []];
         $channel = Channel::where('uid', $channelUid)
             ->select(['uid', 'type', 'lang', 'name'])->first();
         if (! $channel) {
@@ -695,6 +734,7 @@ class PaliContentService
         }
         $isOrigin = $channel->type === 'original' || $channel->type === 'wbw';
         $channelType = $channel->type === 'nissaya' ? 'nissaya' : 'translation';
+        $result['area'] = $isOrigin ? 'original' : 'translation';
 
         $records = Sentence::select($this->selectCol)
             ->where('book_id', $book)
@@ -703,7 +743,6 @@ class PaliContentService
             ->orderBy('word_start')
             ->get();
 
-        $sentences = [];
         foreach ($records as $row) {
             $html = MdRender::render(
                 $row->content,
@@ -721,27 +760,12 @@ class PaliContentService
             $result['sentences'][] = ['sid' => $sid, 'html' => $html];
             if ($format === 'html') {
                 $class = $isOrigin ? 'sentence origin' : 'sentence';
-                $sentences[] = "<div class='{$class}' data-sid='{$sid}'>{$html}</div>";
+                $result['display'][] = "<div class='{$class}' data-sid='{$sid}'>{$html}</div>";
             } else {
-                $sentences[] = $html;
+                $result['display'][] = $html;
             }
         }
 
-        if (count($sentences) === 0) {
-            return $result;
-        }
-
-        if ($format === 'html') {
-            // html 格式加段落外壳
-            $area = $isOrigin ? 'original' : 'translation';
-            $content = implode('', $sentences);
-            $inner = $level > 0 ? "<h{$level}>{$content}</h{$level}>" : "<div class='para-block'>{$content}</div>";
-            $result['display'] = "<div class='{$area}' data-para='{$para}'>{$inner}</div>";
-        } else {
-            // 其他格式一行一句
-            $result['display'] = implode("\n", $sentences);
-        }
-
         return $result;
     }
 }

+ 131 - 0
api-v13/tests/Feature/TipitakaContentParaTest.php

@@ -0,0 +1,131 @@
+<?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);
+
+/**
+ * 建一个句子,返回模型
+ */
+function makeSentence(string $channelUid, int $book, int $para, int $wordStart, string $content): Sentence
+{
+    $sentence = new Sentence;
+    $sentence->forceFill([
+        // sentences.id 不是自增列,必须显式给值
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'book_id' => $book,
+        'paragraph' => $para,
+        'word_start' => $wordStart,
+        'word_end' => $wordStart,
+        'channel_uid' => $channelUid,
+        'editor_uid' => (string) Str::uuid(),
+        'content' => $content,
+        'content_type' => 'markdown',
+        'strlen' => mb_strlen($content),
+        'status' => 30,
+        'create_time' => time() * 1000,
+        'modify_time' => time() * 1000,
+        'language' => 'zh-Hans',
+    ])->save();
+
+    return $sentence;
+}
+
+beforeEach(function () {
+    $this->channel = makeChannel(makeStudio('para-owner'), 'para channel');
+    makeSentence($this->channel, 9001, 1, 1, 'first sentence');
+    makeSentence($this->channel, 9001, 1, 2, 'second sentence');
+    makeSentence($this->channel, 9001, 2, 1, 'other paragraph');
+});
+
+it('renders every sentence of a paragraph wrapped in divs', function () {
+    $data = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$this->channel}")
+        ->assertOk()
+        ->json('data');
+
+    expect($data['para'])->toBe(1);
+    expect($data['sentences'])->toHaveCount(2);
+    expect($data['display'])
+        ->toContain("<div 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 () {
+    (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/v2/tipitaka-content-para/9001-1?channel={$this->channel}")
+        ->assertOk()
+        ->json('data.display');
+
+    expect($display)->toContain('<h2>')->not->toContain('para-block');
+});
+
+it('lists the paragraphs of a range and skips empty ones', function () {
+    $items = $this->getJson("/api/v2/tipitaka-content-para?book=9001&para=1&to=3&channel={$this->channel}")
+        ->assertOk()
+        ->json('data.items');
+
+    expect(array_column($items, 'para'))->toBe([1, 2]);
+});
+
+it('outputs one line per sentence for non html formats', function () {
+    $display = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$this->channel}&format=text")
+        ->assertOk()
+        ->json('data.display');
+
+    expect($display)->toBe("first sentence\nsecond sentence");
+});
+
+it('rejects an invalid id or channel', function () {
+    $this->getJson("/api/v2/tipitaka-content-para/bad-id?channel={$this->channel}")
+        ->assertJsonPath('ok', false);
+    $this->getJson('/api/v2/tipitaka-content-para/9001-1?channel=not-a-uuid')
+        ->assertJsonPath('ok', false);
+    $this->getJson("/api/v2/tipitaka-content-para/9001-9?channel={$this->channel}")
+        ->assertJsonPath('ok', false);
+});
+
+it('caches the paragraph and drops the cache when a sentence changes', function () {
+    $url = "/api/v2/tipitaka-content-para/9001-1?channel={$this->channel}";
+    $this->getJson($url)->assertOk();
+
+    $tag = PaliContentService::paragraphCacheTag(9001, 1, $this->channel);
+    $key = PaliContentService::paragraphCacheKey(9001, 1, $this->channel, 'html');
+    expect(Cache::tags([$tag])->has($key))->toBeTrue();
+
+    $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first();
+    $sentence->content = 'changed sentence';
+    $sentence->save();
+
+    expect(Cache::tags([$tag])->has($key))->toBeFalse();
+    expect($this->getJson($url)->json('data.display'))->toContain('changed sentence');
+});
+
+it('drops the cache when a sentence is added or deleted', function () {
+    $url = "/api/v2/tipitaka-content-para/9001-1?channel={$this->channel}";
+    $this->getJson($url)->assertOk();
+
+    makeSentence($this->channel, 9001, 1, 3, 'third sentence');
+    expect($this->getJson($url)->json('data.sentences'))->toHaveCount(3);
+
+    Sentence::where('book_id', 9001)->where('paragraph', 1)->where('word_start', 3)->first()->delete();
+    expect($this->getJson($url)->json('data.sentences'))->toHaveCount(2);
+});