浏览代码

feat(api): 新增 tipitaka-content-para 阅读模式段落接口

index 按 book + 段落区间 + channel 返回段落列表,show 返回单个段落(id 为 {book}-{para})。
readParagraph 加永久缓存,key 带段落版本号;Sentence 保存或删除时版本号加一使缓存失效。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zSe1kZS4JmKFJ5igD555X
visuddhinanda 2 周之前
父节点
当前提交
9ab3c77abd

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

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\PaliText;
+use App\Services\PaliContentService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+
+class TipitakaContentParaController extends Controller
+{
+    /**
+     * 阅读模式段落内容列表。指定 book 段落区间和 channel
+     */
+    public function index(Request $request, PaliContentService $paliService): JsonResponse
+    {
+        $data = $request->validate([
+            'book' => 'required|integer',
+            'para' => 'required|integer',
+            'to' => 'integer',
+            'channel' => 'required|uuid',
+            'format' => 'string|in:html,markdown,react,text',
+        ]);
+
+        $from = $data['para'];
+        $to = $data['to'] ?? $from;
+        if ($to < $from) {
+            return $this->error('invalid paragraph range');
+        }
+        $format = $data['format'] ?? 'html';
+
+        $items = [];
+        foreach (range($from, $to) as $para) {
+            $paragraph = $paliService->readParagraph(
+                (int) $data['book'],
+                (int) $para,
+                $data['channel'],
+                $this->paraLevel((int) $data['book'], (int) $para),
+                $format
+            );
+            if (empty($paragraph['display'])) {
+                continue;
+            }
+            $items[] = $paragraph;
+        }
+
+        return $this->ok([
+            'items' => $items,
+            'pagination' => [
+                'page' => 1,
+                'pageSize' => $to - $from + 1,
+                'total' => count($items),
+            ],
+        ]);
+    }
+
+    /**
+     * 单个段落内容。id 格式 {book}-{para}
+     */
+    public function show(Request $request, string $id, PaliContentService $paliService): JsonResponse
+    {
+        $arrId = explode('-', $id);
+        if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
+            return $this->error('invalid id');
+        }
+        $channel = $request->input('channel');
+        if (! Str::isUuid($channel)) {
+            return $this->error('invalid channel');
+        }
+        $book = (int) $arrId[0];
+        $para = (int) $arrId[1];
+
+        $paragraph = $paliService->readParagraph(
+            $book,
+            $para,
+            $channel,
+            $this->paraLevel($book, $para),
+            $request->input('format', 'html')
+        );
+        if (empty($paragraph['display'])) {
+            return $this->error('no data');
+        }
+
+        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;
+    }
+}

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

@@ -2,6 +2,7 @@
 
 
 namespace App\Models;
 namespace App\Models;
 
 
+use App\Services\PaliContentService;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\SoftDeletes;
@@ -36,6 +37,19 @@ class Sentence extends Model
         'channel_uid' => 'string',
         'channel_uid' => 'string',
     ];
     ];
 
 
+    protected static function booted(): void
+    {
+        $forget = function (Sentence $sentence) {
+            PaliContentService::forgetParagraph(
+                (int) $sentence->book_id,
+                (int) $sentence->paragraph,
+                (string) $sentence->channel_uid
+            );
+        };
+        static::saved($forget);
+        static::deleted($forget);
+    }
+
     protected $dates = [
     protected $dates = [
         'created_at',
         'created_at',
         'updated_at',
         'updated_at',

+ 34 - 0
api-v13/app/Services/PaliContentService.php

@@ -652,6 +652,40 @@ class PaliContentService
      * @return array{para: int, display: string, sentences: array<int, array{sid: string, html: string}>}
      * @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, int $level = 0, string $format = 'html'): array
+    {
+        $version = Cache::get(self::paragraphVersionKey($book, $para, $channelUid), 0);
+        $key = "/read-para/{$book}-{$para}/{$channelUid}/{$level}/{$format}/{$version}";
+
+        return Cache::rememberForever($key, function () use ($book, $para, $channelUid, $level, $format) {
+            return $this->renderReadParagraph($book, $para, $channelUid, $level, $format);
+        });
+    }
+
+    /**
+     * 段落缓存版本号的 key。句子有增改删时版本号加一,相关缓存自然失效。
+     */
+    public static function paragraphVersionKey(int $book, int $para, string $channelUid): string
+    {
+        return "/read-para/version/{$book}-{$para}/{$channelUid}";
+    }
+
+    /**
+     * 使某个段落的阅读模式缓存失效
+     */
+    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);
+        }
+    }
+
+    /**
+     * @return array{para: int, display: string, sentences: array<int, array{sid: string, html: string}>}
+     */
+    protected function renderReadParagraph(int $book, int $para, string $channelUid, int $level, string $format): array
     {
     {
         $result = ['para' => $para, 'display' => '', 'sentences' => []];
         $result = ['para' => $para, 'display' => '', 'sentences' => []];
         $channel = Channel::where('uid', $channelUid)
         $channel = Channel::where('uid', $channelUid)

+ 2 - 0
api-v13/routes/api.php

@@ -111,6 +111,7 @@ use App\Http\Controllers\TermExportController;
 use App\Http\Controllers\TermSummaryController;
 use App\Http\Controllers\TermSummaryController;
 use App\Http\Controllers\TermVocabularyController;
 use App\Http\Controllers\TermVocabularyController;
 use App\Http\Controllers\TipitakaContentController;
 use App\Http\Controllers\TipitakaContentController;
+use App\Http\Controllers\TipitakaContentParaController;
 use App\Http\Controllers\TransferController;
 use App\Http\Controllers\TransferController;
 use App\Http\Controllers\UpdatePaliSynonymsController;
 use App\Http\Controllers\UpdatePaliSynonymsController;
 use App\Http\Controllers\UpgradeController;
 use App\Http\Controllers\UpgradeController;
@@ -319,6 +320,7 @@ Route::group([
     Route::apiResource('paragraph-content', ParagraphContentController::class);
     Route::apiResource('paragraph-content', ParagraphContentController::class);
     Route::apiResource('heartbeat', HeartbeatController::class);
     Route::apiResource('heartbeat', HeartbeatController::class);
     Route::apiResource('tipitaka-content', TipitakaContentController::class);
     Route::apiResource('tipitaka-content', TipitakaContentController::class);
+    Route::apiResource('tipitaka-content-para', TipitakaContentParaController::class);
 
 
     Route::post('mock/openai/chat/completions', [MockOpenAIController::class, 'chatCompletions']);
     Route::post('mock/openai/chat/completions', [MockOpenAIController::class, 'chatCompletions']);
     Route::post('mock/openai/completions', [MockOpenAIController::class, 'completions']);
     Route::post('mock/openai/completions', [MockOpenAIController::class, 'completions']);