Просмотр исходного кода

feat: book-title 返回 toc / tags / related_name,并缓存 24 小时

书目清单原本只有 sn/book/paragraph/title,客户端要知道「这是哪一类书」
还得再查几次。现在一次给全:

- toc —— pali_texts.(book, paragraph) 的 toc。title 是丛书名
  (vinayapitaka / samantapāsādikā),toc 才是这一册的具体书名
  ((VN)Cūḷavaggapāḷi / (SP) Cūḷavagga-aṭṭhakathā),两者常不同;
- tags —— pali_texts.uid → tag_maps.anchor_id → tags.name,去重排序后的
  名字数组。mūla / aṭṭhakathā / ṭīkā 就在里面,「长部的复注有哪些」这类
  浏览靠它筛;
- related_name —— related_paragraphs.book_name,即 CST 书名(vin2/an8…)。

related_name 的取法选了 (book, para) 配对,没用 book_id = sn,因为两者
语义不同且各有盲区(实测):
- book_id 给的是「这本书横跨的全部 CST 书」(pācityādiyojanā → vin2..vin5),
  (book, para) 给的是「起始段所在的那一本」(→ vin2);
- 只有配对法能查到 samantapāsādikā(sn=280) 与 Bhikkhunīvibhaṅga(sn=281)
  ——这两本在 related_paragraphs 里没有 book_id = sn 的行;
- 配对法实测每个 (book, para) 至多一个非空 book_name,所以返回标量而非数组。

覆盖率:toc 281/281、tags 281/281、related_name 212/281。缺 related_name
的 69 条基本是语法书、史书、诗颂,本来就不在 CST 三藏结构里。

性能:三张表的连接每次都算太贵,整份结果缓存 24 小时(key
book-titles/with-tags)。缓存的是 resolve() 后的数组而非 Resource 对象,
避开序列化。冷 1.3s → 热 0.25s。查询共 4 条、无 N+1:book_titles 一条、
pali_texts 一条(拿 uid 与 toc)、tag_maps+tags 一条、related_paragraphs
一条。whereIn 取的是 book × paragraph 的笛卡尔超集,最后按精确的
"{book}-{paragraph}" 键取用,多出的行匹配不到。

同时新增 php artisan cache:app.clear 清理这类应用级缓存——否则改了 tag
或 toc 要等 24 小时才生效。名字避开 Laravel 内置的 cache:clear 与
cache:forget;用注册表登记缓存项,以后新增带缓存的接口加一行即可;
Cache::forget 对不存在的 key 也返回 true,所以先 has() 再 forget,才能
如实报告「清了几条」而不是一律显示成功。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 1 неделя назад
Родитель
Сommit
fdabee2152

+ 81 - 0
api-v13/app/Console/Commands/ClearAppCache.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+
+class ClearAppCache extends Command
+{
+    /**
+     * 名字里不用 cache:clear / cache:forget —— 那两个是 Laravel 内置命令,会撞。
+     *
+     * @var string
+     */
+    protected $signature = 'cache:app.clear {name? : 缓存项名称,不传则列出可清理的项} {--all : 清理全部}';
+
+    /**
+     * @var string
+     */
+    protected $description = '清理由应用代码显式写入的缓存(如书目清单),不影响框架自身的缓存';
+
+    /**
+     * 可清理的缓存项:名称 => 实际的 cache key 列表。
+     *
+     * 新增带缓存的接口时在这里登记一行,命令与提示会自动跟上。
+     *
+     * @var array<string, array{keys: string[], desc: string}>
+     */
+    private const CACHES = [
+        'book-titles' => [
+            'keys' => ['book-titles/with-tags'],
+            'desc' => '书目清单(含 toc 与 tag),TTL 24 小时',
+        ],
+    ];
+
+    public function handle(): int
+    {
+        $name = $this->argument('name');
+
+        if (! $name && ! $this->option('all')) {
+            $this->line('可清理的缓存项:');
+            foreach (self::CACHES as $key => $item) {
+                $cached = collect($item['keys'])->filter(fn ($k) => Cache::has($k))->count();
+                $state = $cached > 0 ? "已缓存 {$cached}/".count($item['keys']) : '未缓存';
+                $this->line(sprintf('  %-14s %-34s [%s]', $key, $item['desc'], $state));
+            }
+            $this->newLine();
+            $this->line('用法:php artisan cache:app.clear <名称>   或   --all');
+
+            return self::SUCCESS;
+        }
+
+        if ($name && ! isset(self::CACHES[$name])) {
+            $this->error("未知的缓存项:{$name}");
+            $this->line('可选:'.implode(' / ', array_keys(self::CACHES)));
+
+            return self::FAILURE;
+        }
+
+        $targets = $name ? [$name => self::CACHES[$name]] : self::CACHES;
+        $cleared = 0;
+        foreach ($targets as $key => $item) {
+            foreach ($item['keys'] as $cacheKey) {
+                // forget 对不存在的 key 也返回 true,所以先问一次才能如实报告清了几条
+                $existed = Cache::has($cacheKey);
+                Cache::forget($cacheKey);
+                if ($existed) {
+                    $cleared++;
+                    $this->info("已清理 {$key}:{$cacheKey}");
+                } else {
+                    $this->line("跳过 {$key}:{$cacheKey}(本来就没有缓存)");
+                }
+            }
+        }
+
+        $this->newLine();
+        $this->info("共清理 {$cleared} 条缓存。下次请求会重新构建。");
+
+        return self::SUCCESS;
+    }
+}

+ 132 - 13
api-v13/app/Http/Controllers/BookTitleController.php

@@ -2,29 +2,152 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Resources\BookTitleResource;
 use App\Models\BookTitle;
 use Illuminate\Http\Request;
-use App\Http\Resources\BookTitleResource;
+use Illuminate\Http\Response;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
 
 class BookTitleController extends Controller
 {
+    /**
+     * 书目清单基本不变,而每次都要连 pali_texts / tag_maps / tags 三张表,
+     * 所以整份结果缓存 24 小时。
+     */
+    private const CACHE_KEY = 'book-titles/with-tags';
+
+    private const CACHE_TTL = 60 * 60 * 24;
+
     /**
      * Display a listing of the resource.
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function index()
     {
         //
-        $result = BookTitle::orderBy('sn')->get();
-        return $this->ok(["rows"=>BookTitleResource::collection($result),"count"=>count($result)]);
+        $data = Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
+            $result = BookTitle::orderBy('sn')->get();
+            $meta = $this->paliTextMeta($result);
+            $relatedNames = $this->relatedNames($result);
+            foreach ($result as $row) {
+                $key = "{$row->book}-{$row->paragraph}";
+                $row->toc = $meta[$key]['toc'] ?? null;
+                $row->tags = $meta[$key]['tags'] ?? [];
+                $row->related_name = $relatedNames[$key] ?? null;
+            }
+
+            return [
+                'rows' => BookTitleResource::collection($result)->resolve(),
+                'count' => count($result),
+            ];
+        });
+
+        return $this->ok($data);
+    }
+
+    /**
+     * 取每条书目在 pali_texts 里对应的 toc 与 tag 名。
+     *
+     * book_titles 的 (book, paragraph) 指向 pali_texts 的同名字段;toc 直接取自
+     * pali_texts,tag 则再经 pali_texts.uid → tag_maps.anchor_id → tags 一跳。
+     *
+     * 两条查询都按 (book, paragraph) 的取值范围收窄,一次查完在内存里归组,
+     * 避免 281 条书目各查一次。whereIn 取的是两个维度的笛卡尔超集,最后按精确的
+     * "{book}-{paragraph}" 键取用,多出来的行不会被匹配到。
+     *
+     * @param  Collection  $bookTitles
+     * @return array<string, array{toc: ?string, tags: string[]}> 键是 "{book}-{paragraph}"
+     */
+    private function paliTextMeta($bookTitles): array
+    {
+        if ($bookTitles->isEmpty()) {
+            return [];
+        }
+
+        $books = $bookTitles->pluck('book')->unique()->all();
+        $paragraphs = $bookTitles->pluck('paragraph')->unique()->all();
+
+        $texts = DB::table('pali_texts')
+            ->whereIn('book', $books)
+            ->whereIn('paragraph', $paragraphs)
+            ->select('uid', 'book', 'paragraph', 'toc')
+            ->get();
+
+        $meta = [];
+        $keyByUid = [];
+        foreach ($texts as $text) {
+            $key = "{$text->book}-{$text->paragraph}";
+            $meta[$key] = ['toc' => $text->toc, 'tags' => []];
+            $keyByUid[$text->uid] = $key;
+        }
+        if (empty($keyByUid)) {
+            return $meta;
+        }
+
+        $tags = DB::table('tag_maps')
+            ->join('tags', 'tags.id', '=', 'tag_maps.tag_id')
+            ->where('tag_maps.table_name', 'pali_texts')
+            ->whereIn('tag_maps.anchor_id', array_keys($keyByUid))
+            ->select('tag_maps.anchor_id', 'tags.name')
+            ->get();
+
+        foreach ($tags as $tag) {
+            $key = $keyByUid[$tag->anchor_id] ?? null;
+            if ($key === null || in_array($tag->name, $meta[$key]['tags'], true)) {
+                continue;
+            }
+            $meta[$key]['tags'][] = $tag->name;
+        }
+        foreach ($meta as &$item) {
+            sort($item['tags']);
+        }
+
+        return $meta;
+    }
+
+    /**
+     * 取每条书目对应的 CST 书名(related_paragraphs.book_name)。
+     *
+     * 按 (book, para) 配对,而不是 related_paragraphs.book_id = book_titles.sn。
+     * 两者实测差异:book_id 给的是「这本书横跨的全部 CST 书」(pācityādiyojanā →
+     * vin2..vin5),(book, para) 给的是「起始段所在的那一本」(→ vin2),且后者能查到
+     * book_id 归错地方的 samantapāsādikā(sn=280) 与 Bhikkhunīvibhaṅga(sn=281)。
+     *
+     * 实测每个 (book, para) 至多对应一个非空 book_name,故返回标量。
+     *
+     * @param  Collection  $bookTitles
+     * @return array<string, string> 键是 "{book}-{paragraph}"
+     */
+    private function relatedNames($bookTitles): array
+    {
+        if ($bookTitles->isEmpty()) {
+            return [];
+        }
+
+        $rows = DB::table('related_paragraphs')
+            ->whereIn('book', $bookTitles->pluck('book')->unique()->all())
+            ->whereIn('para', $bookTitles->pluck('paragraph')->unique()->all())
+            ->whereNotNull('book_name')
+            ->where('book_name', '<>', '')
+            ->select('book', 'para', 'book_name')
+            ->distinct()
+            ->get();
+
+        $map = [];
+        foreach ($rows as $row) {
+            $map["{$row->book}-{$row->para}"] = $row->book_name;
+        }
+
+        return $map;
     }
 
     /**
      * Store a newly created resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function store(Request $request)
     {
@@ -34,8 +157,7 @@ class BookTitleController extends Controller
     /**
      * Display the specified resource.
      *
-     * @param  \App\Models\BookTitle  $bookTitle
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function show(BookTitle $bookTitle)
     {
@@ -45,9 +167,7 @@ class BookTitleController extends Controller
     /**
      * Update the specified resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \App\Models\BookTitle  $bookTitle
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function update(Request $request, BookTitle $bookTitle)
     {
@@ -57,8 +177,7 @@ class BookTitleController extends Controller
     /**
      * Remove the specified resource from storage.
      *
-     * @param  \App\Models\BookTitle  $bookTitle
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function destroy(BookTitle $bookTitle)
     {