TipitakaRelatedParagraphService.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Models\BookTitle;
  4. use App\Models\PaliText;
  5. use App\Models\RelatedParagraph;
  6. use App\Models\Tag;
  7. use App\Models\TagMap;
  8. use Illuminate\Contracts\Pagination\LengthAwarePaginator;
  9. use Illuminate\Database\Eloquent\Builder;
  10. /**
  11. * 段落关联关系(mūla / aṭṭhakathā / ṭīkā)的查询与聚合。
  12. *
  13. * 服务 `GET /v3/tipitaka-related-paragraphs`、`…/aggregate` 与 `…/aggregate/{book_name}`。
  14. * v2 的 `RelatedParagraphController` 另有一套实现(Resource 里逐行打 5 条 SQL),
  15. * 两边不共用——见 CLAUDE.md 铁律 2:契约相关的逻辑 v3 重写。
  16. *
  17. * 这里把旧 Resource 的逐行查询收敛成常量次批量查询:book_titles → pali_texts →
  18. * tag_maps → tags,无论命中几本书都是固定几条 SQL。
  19. */
  20. class TipitakaRelatedParagraphService
  21. {
  22. /**
  23. * 查一个段落的关联段落,按 book_id 分组,一本书一条。
  24. *
  25. * @param array<string, mixed> $filters 已经过 FormRequest 校验的查询参数
  26. * @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}>
  27. */
  28. public function related(array $filters): array
  29. {
  30. // 1. 确定匹配键 (book_name, cs_para)
  31. if (isset($filters['book'])) {
  32. $anchor = RelatedParagraph::query()
  33. ->where('book', (int) $filters['book'])
  34. ->where('para', (int) $filters['para'])
  35. ->where('cs_para', '>', 0)
  36. ->select(['book_name', 'cs_para'])
  37. ->first();
  38. // 该段没有 cs6 锚点(约 2% 的段落如此),或 book/para 传错。
  39. // 「没有关联段落」是正常结果,不是错误——返回空集
  40. if (! $anchor) {
  41. return [];
  42. }
  43. $bookName = $anchor->book_name;
  44. $csPara = (int) $anchor->cs_para;
  45. } else {
  46. $bookName = $filters['book_name'];
  47. $csPara = (int) $filters['cs_para'];
  48. }
  49. // 2. 取所有命中行,按 book_id 分组
  50. $rows = RelatedParagraph::query()
  51. ->where('book_name', $bookName)
  52. ->where('cs_para', $csPara)
  53. ->when(isset($filters['book_id']), fn (Builder $q) => $q->where('book_id', (int) $filters['book_id']))
  54. ->orderBy('book_id')
  55. ->orderBy('para')
  56. ->get(['book', 'book_id', 'para']);
  57. if ($rows->isEmpty()) {
  58. return [];
  59. }
  60. $grouped = [];
  61. foreach ($rows as $row) {
  62. $bookId = (int) $row->book_id;
  63. if (! isset($grouped[$bookId])) {
  64. $grouped[$bookId] = [
  65. 'book' => (int) $row->book,
  66. 'book_id' => $bookId,
  67. 'cs_para' => $csPara,
  68. 'para' => [],
  69. ];
  70. }
  71. $grouped[$bookId]['para'][] = (int) $row->para;
  72. }
  73. return array_values($this->enrich($grouped));
  74. }
  75. /**
  76. * 聚合:列出所有不同的 SC 缩写(去空、去重),分页返回。
  77. *
  78. * @param array<string, mixed> $filters
  79. * @return LengthAwarePaginator<int, RelatedParagraph>
  80. */
  81. public function bookNames(array $filters): LengthAwarePaginator
  82. {
  83. return RelatedParagraph::query()
  84. ->when(isset($filters['file']), fn (Builder $q) => $q->where('book', (int) $filters['file']))
  85. ->when(isset($filters['book']), fn (Builder $q) => $q->where('book_id', (int) $filters['book']))
  86. ->where('cs_para', '>', 0)
  87. ->where('book_name', '!=', '')
  88. ->select('book_name')
  89. ->groupBy('book_name')
  90. ->orderBy('book_name')
  91. ->paginate($filters['per_page'] ?? 15);
  92. }
  93. /**
  94. * 聚合:列出某个 SC 缩写下的所有 cs_para(去重、>0),分页返回。
  95. *
  96. * @param array<string, mixed> $filters
  97. * @return LengthAwarePaginator<int, RelatedParagraph>
  98. */
  99. public function csParas(string $bookName, array $filters): LengthAwarePaginator
  100. {
  101. return RelatedParagraph::query()
  102. ->where('book_name', $bookName)
  103. ->when(isset($filters['file']), fn (Builder $q) => $q->where('book', (int) $filters['file']))
  104. ->when(isset($filters['book']), fn (Builder $q) => $q->where('book_id', (int) $filters['book']))
  105. ->where('cs_para', '>', 0)
  106. ->select('cs_para')
  107. ->groupBy('cs_para')
  108. ->orderBy('cs_para')
  109. ->paginate($filters['per_page'] ?? 15);
  110. }
  111. /**
  112. * 批量补全每组 book_id 的书名、标签与目录路径。
  113. *
  114. * 旧 Resource 里每行打 BookTitle / PaliText(uid) / TagMap / Tag / PaliText(path)
  115. * 五条 SQL,这里统一按 id 集合一次查回再内存映射。
  116. *
  117. * @param array<int, array<string, mixed>> $grouped
  118. * @return array<int, array<string, mixed>>
  119. */
  120. private function enrich(array $grouped): array
  121. {
  122. $bookIds = array_keys($grouped);
  123. // 书名:book_id(sn) → book_titles
  124. $titles = BookTitle::query()
  125. ->whereIn('sn', $bookIds)
  126. ->get(['sn', 'book', 'paragraph', 'title'])
  127. ->keyBy('sn');
  128. // 需要查 pali_texts 的 (book, paragraph) 坐标:
  129. // - 每组的书名行 → uid(供 tags)
  130. // - 每组第一条 para → path
  131. $titleCoordByBookId = [];
  132. $pathCoordByBookId = [];
  133. $coordBooks = [];
  134. $coordParas = [];
  135. foreach ($grouped as $bookId => $group) {
  136. $title = $titles->get($bookId);
  137. if ($title) {
  138. $titleCoordByBookId[$bookId] = [(int) $title->book, (int) $title->paragraph];
  139. }
  140. $pathCoordByBookId[$bookId] = [$group['book'], $group['para'][0]];
  141. }
  142. foreach (array_merge(array_values($titleCoordByBookId), array_values($pathCoordByBookId)) as [$book, $paragraph]) {
  143. $coordBooks[] = $book;
  144. $coordParas[] = $paragraph;
  145. }
  146. $paliTexts = collect();
  147. if ($coordBooks !== []) {
  148. $paliTexts = PaliText::query()
  149. ->whereIn('book', array_values(array_unique($coordBooks)))
  150. ->whereIn('paragraph', array_values(array_unique($coordParas)))
  151. ->get(['book', 'paragraph', 'uid', 'path'])
  152. ->keyBy(fn ($pt) => $pt->book.':'.$pt->paragraph);
  153. }
  154. // 标签:pali_texts.uid → tag_maps → tags
  155. $uids = $paliTexts->pluck('uid')->filter()->unique()->all();
  156. $tagIdsByUid = [];
  157. if ($uids !== []) {
  158. foreach (TagMap::query()->whereIn('anchor_id', $uids)->get(['anchor_id', 'tag_id']) as $map) {
  159. $tagIdsByUid[$map->anchor_id][] = $map->tag_id;
  160. }
  161. }
  162. $tagIds = array_unique(array_merge([], ...array_values($tagIdsByUid)));
  163. $tags = $tagIds === []
  164. ? collect()
  165. : Tag::query()->whereIn('id', $tagIds)->get(['id', 'name'])->keyBy('id');
  166. foreach ($grouped as $bookId => &$group) {
  167. $group['title'] = $titles->get($bookId)?->title;
  168. $group['tags'] = [];
  169. if (isset($titleCoordByBookId[$bookId])) {
  170. [$titleBook, $titleParagraph] = $titleCoordByBookId[$bookId];
  171. $titleText = $paliTexts->get($titleBook.':'.$titleParagraph);
  172. if ($titleText) {
  173. foreach ($tagIdsByUid[$titleText->uid] ?? [] as $tagId) {
  174. if ($tags->has($tagId)) {
  175. $group['tags'][] = $tags[$tagId]->name;
  176. }
  177. }
  178. }
  179. }
  180. [$pathBook, $pathParagraph] = $pathCoordByBookId[$bookId];
  181. $pathText = $paliTexts->get($pathBook.':'.$pathParagraph);
  182. $group['path'] = $pathText ? json_decode($pathText->path, true) : null;
  183. }
  184. unset($group);
  185. return $grouped;
  186. }
  187. }