Selaa lähdekoodia

feat(api): 新增阅读模式章节接口,段落接口改名并移到 v3

TipitakaContentParaController 改名 TipitakaReadParaController,
路由 v2/tipitaka-content-para → v3/tipitaka-read-para,
并限制为 index/show(原 apiResource 暴露了未实现的写方法)。

新增 TipitakaReadChapterController(v3/tipitaka-read-chapter):
按章节起始 book/para 从 pali_texts 的 chapter_len 得出段落区间,
再按 pagesize 分批返回。pagesize 支持 `20000b`(累加 lenght 字段
到上限断页,单段超限时该页仍返回一段)和 `10p`(按段落数)。
format/view/channel 语义与段落接口一致。

makeSentence 测试辅助函数提到 tests/Pest.php 供两个测试共用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeiirQWenFHZ6uexWPvG8
visuddhinanda 2 viikkoa sitten
vanhempi
sitoutus
6f0456f660

+ 183 - 0
api-v13/app/Http/Controllers/TipitakaReadChapterController.php

@@ -0,0 +1,183 @@
+<?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 TipitakaReadChapterController extends Controller
+{
+    /**
+     * 阅读模式章节内容。输入章节起始 book/para,按 pagesize 分批返回段落。
+     *
+     * pagesize 两种写法:
+     *   - `20000b` 按字节,累加 pali_texts.lenght 直到超过上限(每页至少一段)
+     *   - `10p`    按段落数
+     */
+    public function index(Request $request, PaliContentService $paliService): JsonResponse
+    {
+        $data = $request->validate([
+            'book' => 'required|integer',
+            'para' => 'required|integer',
+            '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',
+        ]);
+
+        return $this->chapter(
+            (int) $data['book'],
+            (int) $data['para'],
+            $data['channel'],
+            $data,
+            $paliService
+        );
+    }
+
+    /**
+     * 同 index,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');
+        }
+
+        return $this->chapter(
+            (int) $arrId[0],
+            (int) $arrId[1],
+            $channel,
+            $request->only(['format', 'view', 'pagesize', 'page']),
+            $paliService
+        );
+    }
+
+    /**
+     * @param  array{format?: string, view?: string, pagesize?: string, page?: int|string}  $param
+     */
+    protected function chapter(
+        int $book,
+        int $para,
+        string $channel,
+        array $param,
+        PaliContentService $paliService
+    ): JsonResponse {
+        $format = $param['format'] ?? 'html';
+        $view = $param['view'] ?? 'display';
+        $pageSize = $param['pagesize'] ?? '10p';
+        $page = max(1, (int) ($param['page'] ?? 1));
+
+        $chapter = PaliText::where('book', $book)->where('paragraph', $para)->first();
+        if (! $chapter) {
+            return $this->error('chapter not found');
+        }
+        $to = $para + max(1, (int) $chapter->chapter_len) - 1;
+
+        /** @var array<int, array{paragraph: int, lenght: int}> $paragraphs */
+        $paragraphs = PaliText::where('book', $book)
+            ->whereBetween('paragraph', [$para, $to])
+            ->orderBy('paragraph')
+            ->get(['paragraph', 'lenght'])
+            ->all();
+        $total = count($paragraphs);
+        if ($total === 0) {
+            return $this->error('chapter is empty');
+        }
+
+        $slice = $this->slice($paragraphs, $pageSize, $page);
+        if ($slice === null) {
+            return $this->error('page out of range');
+        }
+
+        $items = [];
+        foreach ($slice as $row) {
+            $paragraph = $paliService->readParagraph($book, (int) $row->paragraph, $channel, $format);
+            if (empty($paragraph['display'])) {
+                continue;
+            }
+            $items[] = $this->filterView($paragraph, $view);
+        }
+
+        $first = $slice[0]->paragraph;
+        $last = $slice[count($slice) - 1]->paragraph;
+
+        return $this->ok([
+            'items' => $items,
+            'pagination' => [
+                'page' => $page,
+                'pageSize' => $pageSize,
+                'total' => $total,
+                'book' => $book,
+                'from' => (int) $first,
+                'to' => (int) $last,
+                'hasMore' => $last < $paragraphs[$total - 1]->paragraph,
+            ],
+        ]);
+    }
+
+    /**
+     * 取第 $page 批段落。按段落数时直接切片,按字节时从头累加 lenght 逐页推进。
+     *
+     * @param  array<int, PaliText>  $paragraphs
+     * @return array<int, PaliText>|null 页码越界时返回 null
+     */
+    protected function slice(array $paragraphs, string $pageSize, int $page): ?array
+    {
+        $limit = (int) substr($pageSize, 0, -1);
+        $unit = substr($pageSize, -1);
+        if ($limit < 1) {
+            return null;
+        }
+
+        if ($unit === 'p') {
+            $offset = ($page - 1) * $limit;
+            $slice = array_slice($paragraphs, $offset, $limit);
+
+            return $slice === [] ? null : $slice;
+        }
+
+        // 字节模式:每页累加 lenght,超过上限即断页,每页至少一段
+        $offset = 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;
+                }
+            }
+            if ($current === $page) {
+                return array_slice($paragraphs, $offset, $take);
+            }
+            $offset += $take;
+        }
+
+        return null;
+    }
+
+    /**
+     * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
+     *
+     * @param  array{para: int, display: string, sentences: array}  $paragraph
+     */
+    protected function filterView(array $paragraph, string $view): array
+    {
+        return match ($view) {
+            'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
+            'all' => $paragraph,
+            default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
+        };
+    }
+}

+ 1 - 1
api-v13/app/Http/Controllers/TipitakaContentParaController.php → api-v13/app/Http/Controllers/TipitakaReadParaController.php

@@ -7,7 +7,7 @@ use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
 
 
-class TipitakaContentParaController extends Controller
+class TipitakaReadParaController extends Controller
 {
 {
     /**
     /**
      * 阅读模式段落内容列表。指定 book 段落区间和 channel
      * 阅读模式段落内容列表。指定 book 段落区间和 channel

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

@@ -111,7 +111,8 @@ 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\TipitakaReadChapterController;
+use App\Http\Controllers\TipitakaReadParaController;
 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;
@@ -320,7 +321,6 @@ 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']);
@@ -347,4 +347,6 @@ Route::group([
     Route::apiResource('search-suggest', SearchSuggestController::class);
     Route::apiResource('search-suggest', SearchSuggestController::class);
     Route::apiResource('upgrade', UpgradeController::class);
     Route::apiResource('upgrade', UpgradeController::class);
     Route::apiResource('progress', ProgressController::class);
     Route::apiResource('progress', ProgressController::class);
+    Route::apiResource('tipitaka-read-para', TipitakaReadParaController::class)->only(['index', 'show']);
+    Route::apiResource('tipitaka-read-chapter', TipitakaReadChapterController::class)->only(['index', 'show']);
 });
 });

+ 110 - 0
api-v13/tests/Feature/TipitakaReadChapterTest.php

@@ -0,0 +1,110 @@
+<?php
+
+use App\Models\PaliText;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 建一个 pali_texts 段落。第一段带 chapter_len 表示章节长度
+ */
+function makePaliText(int $book, int $para, int $lenght, ?int $chapterLen = null): void
+{
+    (new PaliText)->forceFill([
+        'book' => $book,
+        'paragraph' => $para,
+        'level' => $chapterLen ? 2 : 9,
+        'class' => '',
+        'toc' => '',
+        'text' => '',
+        'html' => '',
+        'lenght' => $lenght,
+        'chapter_len' => $chapterLen,
+        'pcd_book_id' => 0,
+        'uid' => (string) Str::uuid(),
+    ])->save();
+}
+
+/**
+ * book 9002 一个 5 段的章节,每段 100 字节,每段一句
+ */
+function makeChapterFixture(): 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) {
+        makeSentence($channel, 9002, $para, 1, "para {$para} text");
+    }
+
+    return $channel;
+}
+
+it('pages a chapter by paragraph count', function () {
+    $channel = makeChapterFixture();
+    $url = "/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=2p";
+
+    $first = $this->getJson($url)->assertOk()->json('data');
+    expect(array_column($first['items'], 'para'))->toBe([1, 2]);
+    expect($first['pagination'])->toMatchArray([
+        'page' => 1,
+        'pageSize' => '2p',
+        'total' => 5,
+        'from' => 1,
+        'to' => 2,
+        'hasMore' => true,
+    ]);
+
+    $last = $this->getJson($url.'&page=3')->assertOk()->json('data');
+    expect(array_column($last['items'], 'para'))->toBe([5]);
+    expect($last['pagination']['hasMore'])->toBeFalse();
+
+    $this->getJson($url.'&page=4')->assertJsonPath('ok', false);
+});
+
+it('pages a chapter 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";
+
+    $first = $this->getJson($url)->assertOk()->json('data');
+    expect(array_column($first['items'], 'para'))->toBe([1, 2, 3]);
+
+    $second = $this->getJson($url.'&page=2')->assertOk()->json('data');
+    expect(array_column($second['items'], 'para'))->toBe([4, 5]);
+    expect($second['pagination']['hasMore'])->toBeFalse();
+});
+
+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")
+        ->assertOk()
+        ->json('data.items');
+
+    expect(array_column($items, 'para'))->toBe([1]);
+});
+
+it('accepts the id form and the view parameter', function () {
+    $channel = makeChapterFixture();
+    $items = $this->getJson("/api/v3/tipitaka-read-chapter/9002-1?channel={$channel}&pagesize=1p&view=all")
+        ->assertOk()
+        ->json('data.items');
+
+    expect($items)->toHaveCount(1);
+    expect($items[0])->toHaveKeys(['para', 'display', 'sentences']);
+});
+
+it('rejects a bad id, channel, pagesize or unknown chapter', function () {
+    $channel = makeChapterFixture();
+    $this->getJson("/api/v3/tipitaka-read-chapter/bad-id?channel={$channel}")
+        ->assertJsonPath('ok', false);
+    $this->getJson('/api/v3/tipitaka-read-chapter/9002-1?channel=not-a-uuid')
+        ->assertJsonPath('ok', false);
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=99&channel={$channel}")
+        ->assertJsonPath('ok', false);
+    $this->getJson("/api/v3/tipitaka-read-chapter?book=9002&para=1&channel={$channel}&pagesize=20000")
+        ->assertStatus(422);
+});

+ 12 - 40
api-v13/tests/Feature/TipitakaContentParaTest.php → api-v13/tests/Feature/TipitakaReadParaTest.php

@@ -9,34 +9,6 @@ use Illuminate\Support\Str;
 
 
 uses(RefreshDatabase::class);
 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;
-}
-
 /**
 /**
  * 建测试用的 channel 和句子:book 9001 的第 1 段两句,第 2 段一句。返回 channel uid
  * 建测试用的 channel 和句子:book 9001 的第 1 段两句,第 2 段一句。返回 channel uid
  */
  */
@@ -52,7 +24,7 @@ function makeParagraphFixture(): string
 
 
 it('renders every sentence of a paragraph wrapped in divs', function () {
 it('renders every sentence of a paragraph wrapped in divs', function () {
     $channel = makeParagraphFixture();
     $channel = makeParagraphFixture();
-    $data = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}")
+    $data = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}")
         ->assertOk()
         ->assertOk()
         ->json('data');
         ->json('data');
 
 
@@ -80,7 +52,7 @@ it('wraps a chapter title paragraph in a heading', function () {
         'uid' => (string) Str::uuid(),
         'uid' => (string) Str::uuid(),
     ])->save();
     ])->save();
 
 
-    $display = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}")
+    $display = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}")
         ->assertOk()
         ->assertOk()
         ->json('data.display');
         ->json('data.display');
 
 
@@ -89,19 +61,19 @@ it('wraps a chapter title paragraph in a heading', function () {
 
 
 it('can output only the sentences or both', function () {
 it('can output only the sentences or both', function () {
     $channel = makeParagraphFixture();
     $channel = makeParagraphFixture();
-    $sentences = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}&view=sentences")
+    $sentences = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}&view=sentences")
         ->assertOk()
         ->assertOk()
         ->json('data');
         ->json('data');
     expect($sentences)->not->toHaveKey('display');
     expect($sentences)->not->toHaveKey('display');
     expect($sentences['sentences'])->toHaveCount(2);
     expect($sentences['sentences'])->toHaveCount(2);
     expect($sentences['sentences'][0])->toHaveKeys(['sid', 'html']);
     expect($sentences['sentences'][0])->toHaveKeys(['sid', 'html']);
 
 
-    $all = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}&view=all")
+    $all = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}&view=all")
         ->assertOk()
         ->assertOk()
         ->json('data');
         ->json('data');
     expect($all)->toHaveKeys(['para', 'display', 'sentences']);
     expect($all)->toHaveKeys(['para', 'display', 'sentences']);
 
 
-    $items = $this->getJson("/api/v2/tipitaka-content-para?book=9001&para=1&channel={$channel}&view=sentences")
+    $items = $this->getJson("/api/v3/tipitaka-read-para?book=9001&para=1&channel={$channel}&view=sentences")
         ->assertOk()
         ->assertOk()
         ->json('data.items');
         ->json('data.items');
     expect($items[0])->not->toHaveKey('display');
     expect($items[0])->not->toHaveKey('display');
@@ -110,7 +82,7 @@ it('can output only the sentences or both', function () {
 
 
 it('lists the paragraphs of a range and skips empty ones', function () {
 it('lists the paragraphs of a range and skips empty ones', function () {
     $channel = makeParagraphFixture();
     $channel = makeParagraphFixture();
-    $items = $this->getJson("/api/v2/tipitaka-content-para?book=9001&para=1&to=3&channel={$channel}")
+    $items = $this->getJson("/api/v3/tipitaka-read-para?book=9001&para=1&to=3&channel={$channel}")
         ->assertOk()
         ->assertOk()
         ->json('data.items');
         ->json('data.items');
 
 
@@ -119,7 +91,7 @@ it('lists the paragraphs of a range and skips empty ones', function () {
 
 
 it('outputs one line per sentence for non html formats', function () {
 it('outputs one line per sentence for non html formats', function () {
     $channel = makeParagraphFixture();
     $channel = makeParagraphFixture();
-    $display = $this->getJson("/api/v2/tipitaka-content-para/9001-1?channel={$channel}&format=text")
+    $display = $this->getJson("/api/v3/tipitaka-read-para/9001-1?channel={$channel}&format=text")
         ->assertOk()
         ->assertOk()
         ->json('data.display');
         ->json('data.display');
 
 
@@ -128,17 +100,17 @@ it('outputs one line per sentence for non html formats', function () {
 
 
 it('rejects an invalid id or channel', function () {
 it('rejects an invalid id or channel', function () {
     $channel = makeParagraphFixture();
     $channel = makeParagraphFixture();
-    $this->getJson("/api/v2/tipitaka-content-para/bad-id?channel={$channel}")
+    $this->getJson("/api/v3/tipitaka-read-para/bad-id?channel={$channel}")
         ->assertJsonPath('ok', false);
         ->assertJsonPath('ok', false);
-    $this->getJson('/api/v2/tipitaka-content-para/9001-1?channel=not-a-uuid')
+    $this->getJson('/api/v3/tipitaka-read-para/9001-1?channel=not-a-uuid')
         ->assertJsonPath('ok', false);
         ->assertJsonPath('ok', false);
-    $this->getJson("/api/v2/tipitaka-content-para/9001-9?channel={$channel}")
+    $this->getJson("/api/v3/tipitaka-read-para/9001-9?channel={$channel}")
         ->assertJsonPath('ok', false);
         ->assertJsonPath('ok', false);
 });
 });
 
 
 it('caches the paragraph and drops the cache when a sentence changes', function () {
 it('caches the paragraph and drops the cache when a sentence changes', function () {
     $channel = makeParagraphFixture();
     $channel = makeParagraphFixture();
-    $url = "/api/v2/tipitaka-content-para/9001-1?channel={$channel}";
+    $url = "/api/v3/tipitaka-read-para/9001-1?channel={$channel}";
     $this->getJson($url)->assertOk();
     $this->getJson($url)->assertOk();
 
 
     $tag = PaliContentService::paragraphCacheTag(9001, 1, $channel);
     $tag = PaliContentService::paragraphCacheTag(9001, 1, $channel);
@@ -155,7 +127,7 @@ it('caches the paragraph and drops the cache when a sentence changes', function
 
 
 it('drops the cache when a sentence is added or deleted', function () {
 it('drops the cache when a sentence is added or deleted', function () {
     $channel = makeParagraphFixture();
     $channel = makeParagraphFixture();
-    $url = "/api/v2/tipitaka-content-para/9001-1?channel={$channel}";
+    $url = "/api/v3/tipitaka-read-para/9001-1?channel={$channel}";
     $this->getJson($url)->assertOk();
     $this->getJson($url)->assertOk();
 
 
     makeSentence($channel, 9001, 1, 3, 'third sentence');
     makeSentence($channel, 9001, 1, 3, 'third sentence');

+ 29 - 0
api-v13/tests/Pest.php

@@ -1,6 +1,7 @@
 <?php
 <?php
 
 
 use App\Models\Channel;
 use App\Models\Channel;
+use App\Models\Sentence;
 use App\Models\UserInfo;
 use App\Models\UserInfo;
 use App\Services\AuthService;
 use App\Services\AuthService;
 use Firebase\JWT\JWT;
 use Firebase\JWT\JWT;
@@ -147,3 +148,31 @@ function makeChannel(string $ownerUid, string $name = 'test channel'): string
 
 
     return $uid;
     return $uid;
 }
 }
+
+/**
+ * 建一个句子,返回模型
+ */
+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;
+}