Parcourir la source

Merge pull request #2474 from visuddhinanda/development

Development
visuddhinanda il y a 2 semaines
Parent
commit
019d49b421

+ 1 - 0
CLAUDE.md

@@ -313,6 +313,7 @@ Resource / FormRequest / 测试同理;URL 只换版本前缀
 | --- | --- | --- |
 | --- | --- | --- |
 | `like` / `LikeController` | `reactions` / `V3\ReactionController` 等 | `likes` 表早已不只存点赞:`type` 的实际取值有 `like`、`dislike`、`favorite`、`watch`、`bookmark`、`download` 六种 |
 | `like` / `LikeController` | `reactions` / `V3\ReactionController` 等 | `likes` 表早已不只存点赞:`type` 的实际取值有 `like`、`dislike`、`favorite`、`watch`、`bookmark`、`download` 六种 |
 | (无对应 v2 端点) | `tipitaka-reading/{channel}` / `V3\TipitakaReadingController` | 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 `tipitaka-read-para` / `tipitaka-read-chapter`(两条),也短暂叫过 `channels/{c}/books/{b}/paragraphs\|chapters`,2026-09-24 并成一条 |
 | (无对应 v2 端点) | `tipitaka-reading/{channel}` / `V3\TipitakaReadingController` | 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 `tipitaka-read-para` / `tipitaka-read-chapter`(两条),也短暂叫过 `channels/{c}/books/{b}/paragraphs\|chapters`,2026-09-24 并成一条 |
+| `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
 
 
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;

+ 64 - 0
api-v13/app/Http/Controllers/V3/TipitakaRelatedParagraphAggregateController.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\AggregateTipitakaRelatedParagraphBookNameRequest;
+use App\Http\Requests\V3\AggregateTipitakaRelatedParagraphRequest;
+use App\Http\Resources\V3\RelatedParagraphBookNameResource;
+use App\Http\Resources\V3\RelatedParagraphCsParaResource;
+use App\Services\V3\TipitakaRelatedParagraphService;
+
+/**
+ * 段落关联关系的聚合端点。
+ *
+ *   - `GET /v3/tipitaka-related-paragraphs/aggregate`          → 所有 SC 缩写
+ *   - `GET /v3/tipitaka-related-paragraphs/aggregate/{book_name}` → 该缩写下的 cs_para
+ *
+ * 聚合逻辑在 {@see TipitakaRelatedParagraphService},这里用 Resource 包成
+ * 标准的 {data, meta} 信封(分页 meta 由 BaseResourceCollection 裁掉绝对 URL)。
+ */
+class TipitakaRelatedParagraphAggregateController extends Controller
+{
+    public function __construct(private readonly TipitakaRelatedParagraphService $related) {}
+
+    /**
+     * 列出所有 SC 缩写
+     *
+     * data 是去空、去重的 SC 缩写,每条形如 {book_name},分页由 Laravel paginator 提供。
+     *
+     * @unauthenticated
+     *
+     * @queryParam file integer 典籍文件号过滤(对应表里的 book 列)。
+     * @queryParam book integer 真书号过滤(对应表里的 book_id 列)。
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function index(AggregateTipitakaRelatedParagraphRequest $request)
+    {
+        return RelatedParagraphBookNameResource::collection(
+            $this->related->bookNames($request->validated())
+        );
+    }
+
+    /**
+     * 列出某个 SC 缩写下的 cs_para
+     *
+     * data 是去重、>0 的 cs_para(升序),每条形如 {cs_para},分页由 Laravel paginator 提供。
+     *
+     * @unauthenticated
+     *
+     * @urlParam book_name string required SC 风格书名缩写(如 dn1、an2)。Example: dn1
+     *
+     * @queryParam file integer 典籍文件号过滤(对应表里的 book 列)。
+     * @queryParam book integer 真书号过滤(对应表里的 book_id 列)。
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function show(AggregateTipitakaRelatedParagraphBookNameRequest $request, string $bookName)
+    {
+        return RelatedParagraphCsParaResource::collection(
+            $this->related->csParas($bookName, $request->validated())
+        );
+    }
+}

+ 43 - 0
api-v13/app/Http/Controllers/V3/TipitakaRelatedParagraphController.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexTipitakaRelatedParagraphRequest;
+use App\Http\Resources\V3\RelatedParagraphResource;
+use App\Services\V3\TipitakaRelatedParagraphService;
+
+/**
+ * 段落关联关系(mūla / aṭṭhakathā / ṭīkā 的注释对应)。
+ *
+ * 取代 `GET /v2/related-paragraph`。按 book_id 分组一本一条,批量联表消除 N+1;
+ * 新增 `book_name`/`cs_para` 直接过滤器。
+ */
+class TipitakaRelatedParagraphController extends Controller
+{
+    public function __construct(private readonly TipitakaRelatedParagraphService $related) {}
+
+    /**
+     * 列出一个段落的关联段落
+     *
+     * 按 book_id 分组,一本书一条,附带书名、标签、目录路径与该书内的段落列表。
+     * 没有关联段落时返回空集(这是正常结果,不是错误)。
+     *
+     * @unauthenticated
+     *
+     * @queryParam book integer 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组。Example: 1
+     * @queryParam para integer 段落号。与 book 一起用。Example: 2
+     * @queryParam book_name string SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组。Example: dn1
+     * @queryParam cs_para integer 书内段落号。与 book_name 一起用。Example: 4
+     * @queryParam book_id integer 真书号(book_titles.sn),可选收窄。
+     *
+     * @meta count integer 命中的书籍条数
+     */
+    public function index(IndexTipitakaRelatedParagraphRequest $request)
+    {
+        $rows = $this->related->related($request->validated());
+
+        return RelatedParagraphResource::collection($rows)
+            ->additional(['meta' => ['count' => count($rows)]]);
+    }
+}

+ 37 - 0
api-v13/app/Http/Requests/V3/AggregateTipitakaRelatedParagraphBookNameRequest.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/tipitaka-related-paragraphs/aggregate/{book_name}` 的入参:
+ * 列出该 SC 缩写下的 cs_para。book_name 在路径上。
+ *
+ * 过滤器沿用 wikipali 的坐标命名:`file` 是典籍文件号(表里的 `book` 列,
+ * 1-217),`book` 是真书号(表里的 `book_id` 列)。其余参数是可选收窄过滤器;
+ * 分页走 Laravel 默认的 page / per_page。
+ */
+class AggregateTipitakaRelatedParagraphBookNameRequest extends FormRequest
+{
+    /**
+     * 公共只读端点,不需要登录。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'file' => ['integer', 'min:1'],
+            'book' => ['integer', 'min:1'],
+            'page' => ['integer', 'min:1'],
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 36 - 0
api-v13/app/Http/Requests/V3/AggregateTipitakaRelatedParagraphRequest.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/tipitaka-related-paragraphs/aggregate` 的入参:列出所有 SC 缩写。
+ *
+ * 过滤器沿用 wikipali 的坐标命名:`file` 是典籍文件号(表里的 `book` 列,
+ * 1-217),`book` 是真书号(表里的 `book_id` 列)。其余参数是可选收窄过滤器;
+ * 分页走 Laravel 默认的 page / per_page。
+ */
+class AggregateTipitakaRelatedParagraphRequest extends FormRequest
+{
+    /**
+     * 公共只读端点,不需要登录。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'file' => ['integer', 'min:1'],
+            'book' => ['integer', 'min:1'],
+            'page' => ['integer', 'min:1'],
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 39 - 0
api-v13/app/Http/Requests/V3/IndexTipitakaRelatedParagraphRequest.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/tipitaka-related-paragraphs` 的入参:查一个段落的关联段落。
+ *
+ * 两种互斥的入口,必须二选一:
+ *   - 原功能:`book` + `para`,先定位锚点 (book_name, cs_para) 再查关联;
+ *   - 新过滤器:直接给 `book_name` + `cs_para` 查关联。
+ *
+ * 这是参数间依赖,用 FormRequest 表达,不是 v2 那种 `view=` 开关。
+ */
+class IndexTipitakaRelatedParagraphRequest extends FormRequest
+{
+    /**
+     * 公共只读端点,不需要登录。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, string>>
+     */
+    public function rules(): array
+    {
+        return [
+            'book' => ['integer', 'min:1', 'required_without:book_name', 'required_with:para', 'prohibits:book_name,cs_para'],
+            'para' => ['integer', 'min:1', 'required_with:book'],
+            'book_name' => ['string', 'required_without:book', 'required_with:cs_para', 'prohibits:book,para'],
+            'cs_para' => ['integer', 'min:1', 'required_with:book_name'],
+            'book_id' => ['integer', 'min:1'],
+        ];
+    }
+}

+ 22 - 0
api-v13/app/Http/Resources/V3/RelatedParagraphBookNameResource.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 聚合端点 `GET /v3/tipitaka-related-paragraphs/aggregate` 的一行:一个 SC 缩写。
+ */
+class RelatedParagraphBookNameResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{book_name: string}
+     */
+    public function toArray($request): array
+    {
+        return [
+            'book_name' => $this->book_name,
+        ];
+    }
+}

+ 22 - 0
api-v13/app/Http/Resources/V3/RelatedParagraphCsParaResource.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 聚合端点 `GET /v3/tipitaka-related-paragraphs/aggregate/{book_name}` 的一行:一个 cs_para。
+ */
+class RelatedParagraphCsParaResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{cs_para: string}
+     */
+    public function toArray($request): array
+    {
+        return [
+            'cs_para' => (string) $this->cs_para,
+        ];
+    }
+}

+ 39 - 0
api-v13/app/Http/Resources/V3/RelatedParagraphResource.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 一本关联书:`GET /v3/tipitaka-related-paragraphs` 的 `data[]` 元素。
+ *
+ * 载荷是 Service 已拼好、补全书名/标签/目录路径的普通数组(不是 Eloquent 模型),
+ * 本类只做纯映射。字段相对 v2 有改名:`book_title_pali`→`title`、`cs6_para`→`cs_para`。
+ */
+class RelatedParagraphResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     book: int,
+     *     book_id: int,
+     *     title: string|null,
+     *     cs_para: int,
+     *     para: list<int>,
+     *     tags: list<string>,
+     *     path: list<array{title: string, level: int}>|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'book' => $this->resource['book'],
+            'book_id' => $this->resource['book_id'],
+            'title' => $this->resource['title'],
+            'cs_para' => $this->resource['cs_para'],
+            'para' => $this->resource['para'],
+            'tags' => $this->resource['tags'],
+            'path' => $this->resource['path'],
+        ];
+    }
+}

+ 20 - 0
api-v13/app/Models/RelatedParagraph.php

@@ -4,8 +4,28 @@ namespace App\Models;
 
 
 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\Relations\BelongsTo;
 
 
+/**
+ * 段落关联关系(mūla ← aṭṭhakathā ← ṭīkā 的注释对应)。
+ *
+ * 底层表 `related_paragraphs`。匹配键是 (book_name, cs_para):相同 book_name + cs_para
+ * 的段落互为注释关系。book_id 只用于输出书名(对应 book_titles.sn),不参与匹配。
+ */
 class RelatedParagraph extends Model
 class RelatedParagraph extends Model
 {
 {
     use HasFactory;
     use HasFactory;
+
+    protected $table = 'related_paragraphs';
+
+    /**
+     * 相关段落的「真书」:book_id 对应 book_titles.sn(一个 level=1 书 = 一个 sn)。
+     *
+     * 没有数据库外键约束(建表时就没有),且 sn 上只有普通索引、不是唯一键——
+     * 历史数据里同一 sn 可能对应多行,批量查询时用 whereIn('sn') 取。
+     */
+    public function bookTitle(): BelongsTo
+    {
+        return $this->belongsTo(BookTitle::class, 'book_id', 'sn');
+    }
 }
 }

+ 208 - 0
api-v13/app/Services/V3/TipitakaRelatedParagraphService.php

@@ -0,0 +1,208 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\BookTitle;
+use App\Models\PaliText;
+use App\Models\RelatedParagraph;
+use App\Models\Tag;
+use App\Models\TagMap;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+
+/**
+ * 段落关联关系(mūla / aṭṭhakathā / ṭīkā)的查询与聚合。
+ *
+ * 服务 `GET /v3/tipitaka-related-paragraphs`、`…/aggregate` 与 `…/aggregate/{book_name}`。
+ * v2 的 `RelatedParagraphController` 另有一套实现(Resource 里逐行打 5 条 SQL),
+ * 两边不共用——见 CLAUDE.md 铁律 2:契约相关的逻辑 v3 重写。
+ *
+ * 这里把旧 Resource 的逐行查询收敛成常量次批量查询:book_titles → pali_texts →
+ * tag_maps → tags,无论命中几本书都是固定几条 SQL。
+ */
+class TipitakaRelatedParagraphService
+{
+    /**
+     * 查一个段落的关联段落,按 book_id 分组,一本书一条。
+     *
+     * @param  array<string, mixed>  $filters  已经过 FormRequest 校验的查询参数
+     * @return array<int, array{book: int, book_id: int, cs_para: int, para: array<int, int>, title: string|null, tags: array<int, string>, path: array|null}>
+     */
+    public function related(array $filters): array
+    {
+        // 1. 确定匹配键 (book_name, cs_para)
+        if (isset($filters['book'])) {
+            $anchor = RelatedParagraph::query()
+                ->where('book', (int) $filters['book'])
+                ->where('para', (int) $filters['para'])
+                ->where('cs_para', '>', 0)
+                ->select(['book_name', 'cs_para'])
+                ->first();
+
+            // 该段没有 cs6 锚点(约 2% 的段落如此),或 book/para 传错。
+            // 「没有关联段落」是正常结果,不是错误——返回空集
+            if (! $anchor) {
+                return [];
+            }
+
+            $bookName = $anchor->book_name;
+            $csPara = (int) $anchor->cs_para;
+        } else {
+            $bookName = $filters['book_name'];
+            $csPara = (int) $filters['cs_para'];
+        }
+
+        // 2. 取所有命中行,按 book_id 分组
+        $rows = RelatedParagraph::query()
+            ->where('book_name', $bookName)
+            ->where('cs_para', $csPara)
+            ->when(isset($filters['book_id']), fn (Builder $q) => $q->where('book_id', (int) $filters['book_id']))
+            ->orderBy('book_id')
+            ->orderBy('para')
+            ->get(['book', 'book_id', 'para']);
+
+        if ($rows->isEmpty()) {
+            return [];
+        }
+
+        $grouped = [];
+        foreach ($rows as $row) {
+            $bookId = (int) $row->book_id;
+            if (! isset($grouped[$bookId])) {
+                $grouped[$bookId] = [
+                    'book' => (int) $row->book,
+                    'book_id' => $bookId,
+                    'cs_para' => $csPara,
+                    'para' => [],
+                ];
+            }
+            $grouped[$bookId]['para'][] = (int) $row->para;
+        }
+
+        return array_values($this->enrich($grouped));
+    }
+
+    /**
+     * 聚合:列出所有不同的 SC 缩写(去空、去重),分页返回。
+     *
+     * @param  array<string, mixed>  $filters
+     * @return LengthAwarePaginator<int, RelatedParagraph>
+     */
+    public function bookNames(array $filters): LengthAwarePaginator
+    {
+        return RelatedParagraph::query()
+            ->when(isset($filters['file']), fn (Builder $q) => $q->where('book', (int) $filters['file']))
+            ->when(isset($filters['book']), fn (Builder $q) => $q->where('book_id', (int) $filters['book']))
+            ->where('cs_para', '>', 0)
+            ->where('book_name', '!=', '')
+            ->select('book_name')
+            ->groupBy('book_name')
+            ->orderBy('book_name')
+            ->paginate($filters['per_page'] ?? 15);
+    }
+
+    /**
+     * 聚合:列出某个 SC 缩写下的所有 cs_para(去重、>0),分页返回。
+     *
+     * @param  array<string, mixed>  $filters
+     * @return LengthAwarePaginator<int, RelatedParagraph>
+     */
+    public function csParas(string $bookName, array $filters): LengthAwarePaginator
+    {
+        return RelatedParagraph::query()
+            ->where('book_name', $bookName)
+            ->when(isset($filters['file']), fn (Builder $q) => $q->where('book', (int) $filters['file']))
+            ->when(isset($filters['book']), fn (Builder $q) => $q->where('book_id', (int) $filters['book']))
+            ->where('cs_para', '>', 0)
+            ->select('cs_para')
+            ->groupBy('cs_para')
+            ->orderBy('cs_para')
+            ->paginate($filters['per_page'] ?? 15);
+    }
+
+    /**
+     * 批量补全每组 book_id 的书名、标签与目录路径。
+     *
+     * 旧 Resource 里每行打 BookTitle / PaliText(uid) / TagMap / Tag / PaliText(path)
+     * 五条 SQL,这里统一按 id 集合一次查回再内存映射。
+     *
+     * @param  array<int, array<string, mixed>>  $grouped
+     * @return array<int, array<string, mixed>>
+     */
+    private function enrich(array $grouped): array
+    {
+        $bookIds = array_keys($grouped);
+
+        // 书名:book_id(sn) → book_titles
+        $titles = BookTitle::query()
+            ->whereIn('sn', $bookIds)
+            ->get(['sn', 'book', 'paragraph', 'title'])
+            ->keyBy('sn');
+
+        // 需要查 pali_texts 的 (book, paragraph) 坐标:
+        //   - 每组的书名行 → uid(供 tags)
+        //   - 每组第一条 para → path
+        $titleCoordByBookId = [];
+        $pathCoordByBookId = [];
+        $coordBooks = [];
+        $coordParas = [];
+        foreach ($grouped as $bookId => $group) {
+            $title = $titles->get($bookId);
+            if ($title) {
+                $titleCoordByBookId[$bookId] = [(int) $title->book, (int) $title->paragraph];
+            }
+            $pathCoordByBookId[$bookId] = [$group['book'], $group['para'][0]];
+        }
+        foreach (array_merge(array_values($titleCoordByBookId), array_values($pathCoordByBookId)) as [$book, $paragraph]) {
+            $coordBooks[] = $book;
+            $coordParas[] = $paragraph;
+        }
+
+        $paliTexts = collect();
+        if ($coordBooks !== []) {
+            $paliTexts = PaliText::query()
+                ->whereIn('book', array_values(array_unique($coordBooks)))
+                ->whereIn('paragraph', array_values(array_unique($coordParas)))
+                ->get(['book', 'paragraph', 'uid', 'path'])
+                ->keyBy(fn ($pt) => $pt->book.':'.$pt->paragraph);
+        }
+
+        // 标签:pali_texts.uid → tag_maps → tags
+        $uids = $paliTexts->pluck('uid')->filter()->unique()->all();
+        $tagIdsByUid = [];
+        if ($uids !== []) {
+            foreach (TagMap::query()->whereIn('anchor_id', $uids)->get(['anchor_id', 'tag_id']) as $map) {
+                $tagIdsByUid[$map->anchor_id][] = $map->tag_id;
+            }
+        }
+
+        $tagIds = array_unique(array_merge([], ...array_values($tagIdsByUid)));
+        $tags = $tagIds === []
+            ? collect()
+            : Tag::query()->whereIn('id', $tagIds)->get(['id', 'name'])->keyBy('id');
+
+        foreach ($grouped as $bookId => &$group) {
+            $group['title'] = $titles->get($bookId)?->title;
+
+            $group['tags'] = [];
+            if (isset($titleCoordByBookId[$bookId])) {
+                [$titleBook, $titleParagraph] = $titleCoordByBookId[$bookId];
+                $titleText = $paliTexts->get($titleBook.':'.$titleParagraph);
+                if ($titleText) {
+                    foreach ($tagIdsByUid[$titleText->uid] ?? [] as $tagId) {
+                        if ($tags->has($tagId)) {
+                            $group['tags'][] = $tags[$tagId]->name;
+                        }
+                    }
+                }
+            }
+
+            [$pathBook, $pathParagraph] = $pathCoordByBookId[$bookId];
+            $pathText = $paliTexts->get($pathBook.':'.$pathParagraph);
+            $group['path'] = $pathText ? json_decode($pathText->path, true) : null;
+        }
+        unset($group);
+
+        return $grouped;
+    }
+}

+ 10 - 0
api-v13/routes/v3/tipitaka.php

@@ -19,9 +19,19 @@
 */
 */
 
 
 use App\Http\Controllers\V3\TipitakaReadingController;
 use App\Http\Controllers\V3\TipitakaReadingController;
+use App\Http\Controllers\V3\TipitakaRelatedParagraphAggregateController;
+use App\Http\Controllers\V3\TipitakaRelatedParagraphController;
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Facades\Route;
 
 
 // 只有 channel 必填,所以路径上只有它一个变量;book / chapter / para 全是 filter。
 // 只有 channel 必填,所以路径上只有它一个变量;book / chapter / para 全是 filter。
 // 不带任何查询串 = 取这个 channel 的全部译文,下载场景要的就是这个。
 // 不带任何查询串 = 取这个 channel 的全部译文,下载场景要的就是这个。
 Route::get('tipitaka-reading/{channel}', TipitakaReadingController::class)
 Route::get('tipitaka-reading/{channel}', TipitakaReadingController::class)
     ->whereUuid('channel');
     ->whereUuid('channel');
+
+// 段落关联关系:查询(原 v2 related-paragraph)+ 聚合(book_name 列表 / 某 book_name 的 cs_para)。
+Route::get('tipitaka-related-paragraphs/aggregate', [TipitakaRelatedParagraphAggregateController::class, 'index'])
+    ->name('tipitaka-related-paragraphs.aggregate');
+Route::get('tipitaka-related-paragraphs/aggregate/{book_name}', [TipitakaRelatedParagraphAggregateController::class, 'show'])
+    ->name('tipitaka-related-paragraphs.aggregate.show');
+Route::get('tipitaka-related-paragraphs', [TipitakaRelatedParagraphController::class, 'index'])
+    ->name('tipitaka-related-paragraphs.index');

+ 220 - 0
api-v13/tests/Feature/TipitakaRelatedParagraphV3Test.php

@@ -0,0 +1,220 @@
+<?php
+
+use App\Models\BookTitle;
+use App\Models\PaliText;
+use App\Models\RelatedParagraph;
+use App\Models\Tag;
+use App\Models\TagMap;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * GET /v3/tipitaka-related-paragraphs 与 /aggregate —— 段落关联关系的查询与聚合。
+ *
+ * 取代 v2 的 related-paragraph:保留 book+para 锚点定位,新增 book_name/cs_para
+ * 过滤器与按维度聚合;同时把旧 Resource 的逐行 N+1 收敛成批量查询。
+ */
+function makeRelatedParagraph(int $book, int $para, int $bookId, int $csPara, string $bookName): RelatedParagraph
+{
+    $row = new RelatedParagraph;
+    $row->forceFill([
+        'book' => $book,
+        'para' => $para,
+        'book_id' => $bookId,
+        'cs_para' => $csPara,
+        'book_name' => $bookName,
+    ])->save();
+
+    return $row;
+}
+
+function makeBookTitle(int $sn, int $book, int $paragraph, string $title): BookTitle
+{
+    $row = new BookTitle;
+    $row->forceFill(['sn' => $sn, 'book' => $book, 'paragraph' => $paragraph, 'title' => $title])->save();
+
+    return $row;
+}
+
+function makeRelatedPaliText(int $book, int $paragraph, array $attrs = []): PaliText
+{
+    $row = new PaliText;
+    $row->forceFill(array_merge([
+        'book' => $book,
+        'paragraph' => $paragraph,
+        'level' => 1,
+        'class' => '',
+        'toc' => '',
+        'text' => '',
+        'html' => '',
+        'uid' => (string) Str::uuid(),
+    ], $attrs))->save();
+
+    return $row;
+}
+
+function makeTag(string $name, int $color = 0): Tag
+{
+    $tag = new Tag;
+    $tag->forceFill(['id' => (string) Str::uuid(), 'name' => $name, 'color' => $color, 'owner_id' => (string) Str::uuid()])->save();
+
+    return $tag;
+}
+
+it('lists related paragraphs grouped by book with title, tags and path', function () {
+    makeRelatedParagraph(1, 2, 10, 4, 'dn1');
+    makeRelatedParagraph(3, 5, 20, 4, 'dn1');
+
+    makeBookTitle(10, 1, 100, 'dīghanikāyapāḷi');
+    makeBookTitle(20, 3, 200, 'sumaṅgalavilāsinī');
+    $root = makeRelatedPaliText(1, 100);          // book_id=10 的书名行 → uid 供 tags
+    $otherRoot = makeRelatedPaliText(3, 200);     // book_id=20 的书名行
+    makeRelatedPaliText(1, 2, ['path' => json_encode([['title' => 'path-a', 'level' => 1]])]);
+    makeRelatedPaliText(3, 5, ['path' => json_encode([['title' => 'path-b', 'level' => 1]])]);
+
+    $pali = makeTag('pāḷi', 1);
+    $attha = makeTag('aṭṭhakathā', 2);
+    (new TagMap)->forceFill(['id' => (string) Str::uuid(), 'table_name' => 'pali_texts', 'anchor_id' => $root->uid, 'tag_id' => $pali->id])->save();
+    (new TagMap)->forceFill(['id' => (string) Str::uuid(), 'table_name' => 'pali_texts', 'anchor_id' => $otherRoot->uid, 'tag_id' => $attha->id])->save();
+
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=2')->assertOk()->json();
+
+    expect($json['data'])->toHaveCount(2)
+        ->and($json['meta']['count'])->toBe(2)
+        ->and($json['data'][0])->toMatchArray([
+            'book' => 1,
+            'book_id' => 10,
+            'title' => 'dīghanikāyapāḷi',
+            'cs_para' => 4,
+            'para' => [2],
+        ])
+        ->and($json['data'][0]['tags'])->toBe(['pāḷi'])
+        ->and($json['data'][0]['path'][0]['title'])->toBe('path-a')
+        ->and($json['data'][1]['title'])->toBe('sumaṅgalavilāsinī')
+        ->and($json['data'][1]['tags'])->toBe(['aṭṭhakathā']);
+});
+
+it('filters directly by book_name and cs_para', function () {
+    makeRelatedParagraph(1, 2, 10, 4, 'dn1');
+    makeRelatedParagraph(3, 5, 20, 4, 'dn1');
+    makeRelatedParagraph(3, 6, 20, 5, 'dn1'); // 不同 cs_para,不该命中
+
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs?book_name=dn1&cs_para=4')->assertOk()->json();
+
+    expect($json['data'])->toHaveCount(2)
+        ->and($json['meta']['count'])->toBe(2);
+});
+
+it('returns an empty set when the paragraph has no anchor', function () {
+    makeRelatedParagraph(1, 2, 10, 0, 'dn1'); // cs_para=0:无锚点(书前页)
+
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=2')->assertOk()->json();
+
+    expect($json['data'])->toBe([])
+        ->and($json['meta']['count'])->toBe(0);
+});
+
+it('lists distinct book names', function () {
+    makeRelatedParagraph(100, 2, 110, 4, 'an2');
+    makeRelatedParagraph(100, 3, 110, 4, 'an3');
+    makeRelatedParagraph(101, 2, 111, 5, 'an2');
+
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate')->assertOk()->json();
+
+    expect($json['data'])->toBe([['book_name' => 'an2'], ['book_name' => 'an3']])
+        ->and($json['meta']['total'])->toBe(2);
+});
+
+it('lists cs_para of a book name', function () {
+    makeRelatedParagraph(100, 2, 110, 4, 'an2');
+    makeRelatedParagraph(100, 3, 110, 5, 'an2');
+    makeRelatedParagraph(101, 2, 111, 4, 'an2');
+    makeRelatedParagraph(101, 3, 111, 5, 'an3');
+
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2')->assertOk()->json();
+
+    expect($json['data'])->toBe([['cs_para' => '4'], ['cs_para' => '5']])
+        ->and($json['meta']['total'])->toBe(2);
+});
+
+it('filters aggregate book names by file and book', function () {
+    makeRelatedParagraph(100, 2, 110, 4, 'an2');
+    makeRelatedParagraph(100, 3, 110, 5, 'an3');
+    makeRelatedParagraph(101, 2, 111, 4, 'an2');
+
+    // file 过滤 book 列:只剩 book=100 的两条
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?file=100')->assertOk()->json();
+    expect($json['data'])->toBe([['book_name' => 'an2'], ['book_name' => 'an3']]);
+
+    // book 过滤 book_id 列:只剩 book_id=110 的两条
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?book=110')->assertOk()->json();
+    expect($json['data'])->toBe([['book_name' => 'an2'], ['book_name' => 'an3']]);
+
+    // book 过滤 book_id 列:只剩 book_id=111 的一条
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?book=111')->assertOk()->json();
+    expect($json['data'])->toBe([['book_name' => 'an2']]);
+});
+
+it('filters aggregate cs_para by file and book', function () {
+    makeRelatedParagraph(100, 2, 110, 4, 'an2');
+    makeRelatedParagraph(100, 3, 110, 5, 'an2');
+    makeRelatedParagraph(101, 2, 111, 4, 'an2');
+
+    // file 过滤 book 列:只剩 book=100 的两条
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2?file=100')->assertOk()->json();
+    expect($json['data'])->toBe([['cs_para' => '4'], ['cs_para' => '5']]);
+
+    // book 过滤 book_id 列:只剩 book_id=111 的一条
+    $json = $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2?book=111')->assertOk()->json();
+    expect($json['data'])->toBe([['cs_para' => '4']]);
+});
+
+it('validates the mutually exclusive lookup filters', function () {
+    $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1')
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['para']]);
+
+    $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=2&book_name=dn1')
+        ->assertStatus(422);
+
+    $this->getJson('/api/v3/tipitaka-related-paragraphs')
+        ->assertStatus(422);
+});
+
+it('validates the aggregate endpoint pagination', function () {
+    $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate?per_page=100000')
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['per_page']]);
+
+    $this->getJson('/api/v3/tipitaka-related-paragraphs/aggregate/an2?per_page=100000')
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['per_page']]);
+});
+
+it('issues a constant number of queries regardless of book count', function () {
+    makeRelatedParagraph(1, 1, 10, 7, 'dn1');
+    makeRelatedParagraph(2, 1, 20, 7, 'dn1');
+    makeRelatedParagraph(3, 1, 30, 7, 'dn1');
+    makeBookTitle(10, 1, 100, 'a');
+    makeBookTitle(20, 2, 100, 'b');
+    makeBookTitle(30, 3, 100, 'c');
+
+    DB::flushQueryLog();
+    DB::enableQueryLog();
+    $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=1')->assertOk();
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    // 再加一本:旧实现会每本多打 5 条 SQL,这里查询数必须保持常量
+    makeRelatedParagraph(4, 1, 40, 7, 'dn1');
+    makeBookTitle(40, 4, 100, 'd');
+
+    DB::flushQueryLog();
+    DB::enableQueryLog();
+    $this->getJson('/api/v3/tipitaka-related-paragraphs?book=1&para=1')->assertOk();
+    expect(count(DB::getQueryLog()))->toBe($queries);
+    DB::disableQueryLog();
+});

+ 204 - 0
dashboard-v6/src/api/schema.d.ts

@@ -7948,6 +7948,73 @@ export interface paths {
         patch?: never;
         patch?: never;
         trace?: never;
         trace?: never;
     };
     };
+    "/v3/tipitaka-related-paragraphs": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出一个段落的关联段落
+         * @description 按 book_id 分组,一本书一条,附带书名、标签、目录路径与该书内的段落列表。
+         *     没有关联段落时返回空集(这是正常结果,不是错误)。
+         *
+         *     实现:`V3\TipitakaRelatedParagraphController@index`
+         */
+        get: operations["get_api_v3_tipitaka_related_paragraphs"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/tipitaka-related-paragraphs/aggregate": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出所有 SC 缩写
+         * @description data 是去空、去重的 SC 缩写,每条形如 {book_name},分页由 Laravel paginator 提供。
+         *
+         *     实现:`V3\TipitakaRelatedParagraphAggregateController@index`
+         */
+        get: operations["get_api_v3_tipitaka_related_paragraphs_aggregate"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
+    "/v3/tipitaka-related-paragraphs/aggregate/{book_name}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出某个 SC 缩写下的 cs_para
+         * @description data 是去重、>0 的 cs_para(升序),每条形如 {cs_para},分页由 Laravel paginator 提供。
+         *
+         *     实现:`V3\TipitakaRelatedParagraphAggregateController@show`
+         */
+        get: operations["get_api_v3_tipitaka_related_paragraphs_aggregate_book_name_"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/upgrade": {
     "/v3/upgrade": {
         parameters: {
         parameters: {
             query?: never;
             query?: never;
@@ -34573,6 +34640,143 @@ export interface operations {
             422: components["responses"]["ProblemValidation"];
             422: components["responses"]["ProblemValidation"];
         };
         };
     };
     };
+    get_api_v3_tipitaka_related_paragraphs: {
+        parameters: {
+            query?: {
+                /**
+                 * @description 典籍文件号(1-217)。与 para 一起用,prohibits 下面那组
+                 * @example 1
+                 */
+                book?: number;
+                /**
+                 * @description 段落号。与 book 一起用
+                 * @example 2
+                 */
+                para?: number;
+                /**
+                 * @description SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组
+                 * @example dn1
+                 */
+                book_name?: string;
+                /**
+                 * @description 书内段落号。与 book_name 一起用
+                 * @example 4
+                 */
+                cs_para?: number;
+                /** @description 真书号(book_titles.sn),可选收窄 */
+                book_id?: number;
+            };
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            book?: number;
+                            book_id?: number;
+                            title?: string | null;
+                            cs_para?: number;
+                            para?: number[];
+                            tags?: string[];
+                            path?: {
+                                title?: string;
+                                level?: number;
+                            }[] | null;
+                        }[];
+                        meta?: {
+                            /** @description 命中的书籍条数 */
+                            count?: number;
+                        };
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    get_api_v3_tipitaka_related_paragraphs_aggregate: {
+        parameters: {
+            query?: {
+                /** @description 典籍文件号过滤(对应表里的 book 列) */
+                file?: number;
+                /** @description 真书号过滤(对应表里的 book_id 列) */
+                book?: number;
+                /** @description 页码 */
+                page?: number;
+                /** @description 每页数量,最大 200 */
+                per_page?: number;
+            };
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            book_name?: string;
+                        }[];
+                        meta?: components["schemas"]["PaginationMeta"];
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
+    get_api_v3_tipitaka_related_paragraphs_aggregate_book_name_: {
+        parameters: {
+            query?: {
+                /** @description 典籍文件号过滤(对应表里的 book 列) */
+                file?: number;
+                /** @description 真书号过滤(对应表里的 book_id 列) */
+                book?: number;
+                /** @description 页码 */
+                page?: number;
+                /** @description 每页数量,最大 200 */
+                per_page?: number;
+            };
+            header?: never;
+            path: {
+                /**
+                 * @description SC 风格书名缩写(如 dn1、an2)
+                 * @example dn1
+                 */
+                book_name: string;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            cs_para?: string;
+                        }[];
+                        meta?: components["schemas"]["PaginationMeta"];
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
     get_api_v3_upgrade: {
     get_api_v3_upgrade: {
         parameters: {
         parameters: {
             query?: never;
             query?: never;

+ 7 - 1
openapi/public/assets/protocol/main.yaml

@@ -7,7 +7,7 @@ info:
 
 
     由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。
     由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。
     响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
     响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
-  version: 2026.9.24
+  version: 2026.9.28
 servers:
 servers:
   -
   -
     url: /api
     url: /api
@@ -695,5 +695,11 @@ paths:
     $ref: ./resources/auto/v3-search-search.yaml
     $ref: ./resources/auto/v3-search-search.yaml
   '/v3/tipitaka-reading/{channel}':
   '/v3/tipitaka-reading/{channel}':
     $ref: ./resources/auto/v3-tipitaka-reading-channel.yaml
     $ref: ./resources/auto/v3-tipitaka-reading-channel.yaml
+  /v3/tipitaka-related-paragraphs:
+    $ref: ./resources/auto/v3-tipitaka-related-paragraphs.yaml
+  /v3/tipitaka-related-paragraphs/aggregate:
+    $ref: ./resources/auto/v3-tipitaka-related-paragraphs-aggregate.yaml
+  '/v3/tipitaka-related-paragraphs/aggregate/{book_name}':
+    $ref: ./resources/auto/v3-tipitaka-related-paragraphs-aggregate-book-name.yaml
   /v3/upgrade:
   /v3/upgrade:
     $ref: ./resources/auto/v3-upgrade.yaml
     $ref: ./resources/auto/v3-upgrade.yaml

+ 64 - 0
openapi/public/assets/protocol/resources/auto/v3-tipitaka-related-paragraphs-aggregate-book-name.yaml

@@ -0,0 +1,64 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-tipitaka-related-paragraphs-aggregate-book-name.yaml
+get:
+  summary: '列出某个 SC 缩写下的 cs_para'
+  tags:
+    - tipitaka-related-paragraphs
+  operationId: get_api_v3_tipitaka_related_paragraphs_aggregate_book_name_
+  description: |-
+    data 是去重、>0 的 cs_para(升序),每条形如 {cs_para},分页由 Laravel paginator 提供。
+
+    实现:`V3\TipitakaRelatedParagraphAggregateController@show`
+  parameters:
+    -
+      name: book_name
+      in: path
+      required: true
+      schema:
+        type: string
+      description: 'SC 风格书名缩写(如 dn1、an2)'
+      example: dn1
+    -
+      name: file
+      in: query
+      schema:
+        type: integer
+      description: '典籍文件号过滤(对应表里的 book 列)'
+    -
+      name: book
+      in: query
+      schema:
+        type: integer
+      description: '真书号过滤(对应表里的 book_id 列)'
+    -
+      name: page
+      in: query
+      schema:
+        type: integer
+        default: 1
+      description: 页码
+    -
+      name: per_page
+      in: query
+      schema:
+        type: integer
+        default: 15
+      description: '每页数量,最大 200'
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    cs_para:
+                      type: string
+              meta:
+                $ref: '../../main.yaml#/components/schemas/PaginationMeta'
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'

+ 56 - 0
openapi/public/assets/protocol/resources/auto/v3-tipitaka-related-paragraphs-aggregate.yaml

@@ -0,0 +1,56 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-tipitaka-related-paragraphs-aggregate.yaml
+get:
+  summary: '列出所有 SC 缩写'
+  tags:
+    - tipitaka-related-paragraphs
+  operationId: get_api_v3_tipitaka_related_paragraphs_aggregate
+  description: |-
+    data 是去空、去重的 SC 缩写,每条形如 {book_name},分页由 Laravel paginator 提供。
+
+    实现:`V3\TipitakaRelatedParagraphAggregateController@index`
+  parameters:
+    -
+      name: file
+      in: query
+      schema:
+        type: integer
+      description: '典籍文件号过滤(对应表里的 book 列)'
+    -
+      name: book
+      in: query
+      schema:
+        type: integer
+      description: '真书号过滤(对应表里的 book_id 列)'
+    -
+      name: page
+      in: query
+      schema:
+        type: integer
+        default: 1
+      description: 页码
+    -
+      name: per_page
+      in: query
+      schema:
+        type: integer
+        default: 15
+      description: '每页数量,最大 200'
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    book_name:
+                      type: string
+              meta:
+                $ref: '../../main.yaml#/components/schemas/PaginationMeta'
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'

+ 86 - 0
openapi/public/assets/protocol/resources/auto/v3-tipitaka-related-paragraphs.yaml

@@ -0,0 +1,86 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-tipitaka-related-paragraphs.yaml
+get:
+  summary: 列出一个段落的关联段落
+  tags:
+    - tipitaka-related-paragraphs
+  operationId: get_api_v3_tipitaka_related_paragraphs
+  description: |-
+    按 book_id 分组,一本书一条,附带书名、标签、目录路径与该书内的段落列表。
+    没有关联段落时返回空集(这是正常结果,不是错误)。
+
+    实现:`V3\TipitakaRelatedParagraphController@index`
+  parameters:
+    -
+      name: book
+      in: query
+      schema:
+        type: integer
+      description: '典籍文件号(1-217)。与 para 一起用,prohibits 下面那组'
+      example: 1
+    -
+      name: para
+      in: query
+      schema:
+        type: integer
+      description: '段落号。与 book 一起用'
+      example: 2
+    -
+      name: book_name
+      in: query
+      schema:
+        type: string
+      description: 'SC 风格书名缩写(如 dn1、an2)。与 cs_para 一起用,prohibits 上面那组'
+      example: dn1
+    -
+      name: cs_para
+      in: query
+      schema:
+        type: integer
+      description: '书内段落号。与 book_name 一起用'
+      example: 4
+    -
+      name: book_id
+      in: query
+      schema:
+        type: integer
+      description: 真书号(book_titles.sn),可选收窄
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    book:
+                      type: integer
+                    book_id:
+                      type: integer
+                    title:
+                      type: string
+                      nullable: true
+                    cs_para:
+                      type: integer
+                    para:
+                      type: array
+                      items: { type: integer }
+                    tags:
+                      type: array
+                      items: { type: string }
+                    path:
+                      type: array
+                      items: { type: object, properties: { title: { type: string }, level: { type: integer } } }
+                      nullable: true
+              meta:
+                type: object
+                properties:
+                  count:
+                    type: integer
+                    description: 命中的书籍条数
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'