TipitakaReadingService.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Models\PaliText;
  4. use App\Models\Sentence;
  5. use App\Services\PaliContentService;
  6. use Illuminate\Database\Eloquent\Builder;
  7. use Illuminate\Validation\ValidationException;
  8. /**
  9. * 三藏译文的阅读与下载:按 channel 取已翻译的段落,游标式分块。
  10. *
  11. * 服务 `GET /v3/tipitaka-reading/{channel}`。控制器只负责把 validated() 传进来、
  12. * 把结果包成 Resource;这里是全部业务逻辑——过滤收窄、章节展开、keyset 游标、
  13. * 字节切块、进度统计、输出裁剪。
  14. *
  15. * 渲染本身交给 {@see PaliContentService}(v2/v3 共享的纯能力),这里不重复实现。
  16. */
  17. class TipitakaReadingService
  18. {
  19. /**
  20. * 字节模式下单块的字节上限。请求超过这个数按这个数算,不报错。
  21. */
  22. public const MAX_BYTES_PER_PAGE = 5000;
  23. /**
  24. * 单块最多返回的段落数,两种单位都受它约束。
  25. *
  26. * 每段要单独渲染一次,块的成本跟段落数成正比而不是跟字节数成正比。段落模式
  27. * 直接把 page_size 压到这个数;字节模式下偈颂体的书平均每段只有 20~30 字节,
  28. * 光靠字节上限仍可能切出几百段,所以累加时两个上限谁先到就在哪断块。
  29. */
  30. public const MAX_PARAGRAPHS_PER_PAGE = 200;
  31. public function __construct(private readonly PaliContentService $paliService) {}
  32. /**
  33. * 取一块译文。
  34. *
  35. * @param array<string, mixed> $filters 已经过 FormRequest 校验的查询参数
  36. * @return array{items: array<int, array<string, mixed>>, meta: array<string, mixed>}
  37. */
  38. public function page(string $channel, array $filters): array
  39. {
  40. $unit = $filters['unit'] ?? 'para';
  41. $pageSize = min(
  42. max(1, (int) ($filters['page_size'] ?? 10)),
  43. $unit === 'byte' ? self::MAX_BYTES_PER_PAGE : self::MAX_PARAGRAPHS_PER_PAGE
  44. );
  45. $scope = $this->scopedQuery($channel, $filters);
  46. // 多取一行用来判断后面还有没有,不必再查一次
  47. $want = $unit === 'byte'
  48. ? self::MAX_PARAGRAPHS_PER_PAGE
  49. : min($pageSize, self::MAX_PARAGRAPHS_PER_PAGE);
  50. $candidates = (clone $scope)
  51. ->when($filters['after'] ?? null, fn (Builder $q, string $after) => $this->applyCursor($q, $after))
  52. ->distinct()
  53. ->select(['book_id', 'paragraph'])
  54. ->orderBy('book_id')
  55. ->orderBy('paragraph')
  56. ->limit($want + 1)
  57. ->get()
  58. ->map(fn ($row) => ['book' => (int) $row->book_id, 'para' => (int) $row->paragraph])
  59. ->all();
  60. $hasMoreBeyondWant = count($candidates) > $want;
  61. $candidates = array_slice($candidates, 0, $want);
  62. $slice = $unit === 'byte'
  63. ? $this->sliceByBytes($candidates, $this->paragraphLengths($candidates), $pageSize)
  64. : $candidates;
  65. $hasMore = $hasMoreBeyondWant || count($slice) < count($candidates);
  66. $format = $filters['format'] ?? 'html';
  67. $include = $filters['include'] ?? 'display';
  68. $items = [];
  69. foreach ($slice as $coord) {
  70. $paragraph = $this->paliService->readParagraph($coord['book'], $coord['para'], $channel, $format);
  71. if (empty($paragraph['display'])) {
  72. continue;
  73. }
  74. // 结果可以跨书,所以每条都要带 book——只给 para 在跨书时不唯一
  75. $items[] = ['book' => $coord['book']] + $this->filterInclude($paragraph, $include);
  76. }
  77. $last = $slice === [] ? null : $slice[count($slice) - 1];
  78. return [
  79. 'items' => $items,
  80. 'meta' => [
  81. 'page_size' => $pageSize,
  82. 'page_size_unit' => $unit,
  83. // 本块覆盖的最后一段。渲染为空的段落会被剔出 data 但仍算在块内,
  84. // 所以游标按 slice 推进而不是按 data 推进,否则会卡在那一段上打转
  85. 'next_cursor' => $hasMore && $last ? "{$last['book']}-{$last['para']}" : null,
  86. ] + $this->progressMeta($scope, $filters, $last),
  87. ];
  88. }
  89. /**
  90. * 按 filter 收窄的**基础查询**:只有 where,不带 select / distinct / order。
  91. *
  92. * 分页取数和 count 两边对它的要求不一样(一个要去重的两列,一个要聚合),
  93. * 混在一起会拼出 `select distinct book_id, paragraph, count(...)` 这种非法 SQL。
  94. * 所以基础查询保持干净,各自 clone 之后再加自己那套。
  95. *
  96. * @param array<string, mixed> $filters
  97. * @return Builder<Sentence>
  98. */
  99. private function scopedQuery(string $channel, array $filters): Builder
  100. {
  101. $query = Sentence::query()
  102. ->where('channel_uid', $channel)
  103. ->where('strlen', '>', 0);
  104. if (! isset($filters['book'])) {
  105. return $query;
  106. }
  107. $book = (int) $filters['book'];
  108. $query->where('book_id', $book);
  109. if (isset($filters['chapter'])) {
  110. [$from, $to] = $this->chapterRange($book, (int) $filters['chapter']);
  111. return $query->whereBetween('paragraph', [$from, $to]);
  112. }
  113. if (isset($filters['para'])) {
  114. $from = (int) $filters['para'];
  115. $to = (int) ($filters['to'] ?? $from);
  116. if ($to < $from) {
  117. throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
  118. }
  119. return $query->whereBetween('paragraph', [$from, $to]);
  120. }
  121. return $query;
  122. }
  123. /**
  124. * 章节起始段落号 → 段落闭区间,长度取 pali_texts 那一行的 chapter_len。
  125. *
  126. * @return array{int, int}
  127. */
  128. private function chapterRange(int $book, int $chapter): array
  129. {
  130. $head = PaliText::where('book', $book)->where('paragraph', $chapter)->first();
  131. if (! $head) {
  132. abort(404, __('site.not_found'));
  133. }
  134. return [$chapter, $chapter + max(1, (int) $head->chapter_len) - 1];
  135. }
  136. /**
  137. * 游标 `{book}-{para}`:取排在它之后的行。
  138. *
  139. * 用行值比较 `(book_id, paragraph) > (?, ?)` 而不是 `book > ? or (book = ? and para > ?)`,
  140. * 一是短,二是 Postgres 能直接走 (book_id, paragraph, …) 那条复合索引。
  141. *
  142. * @param Builder<Sentence> $query
  143. * @return Builder<Sentence>
  144. */
  145. private function applyCursor(Builder $query, string $after): Builder
  146. {
  147. [$book, $para] = array_map('intval', explode('-', $after, 2));
  148. return $query->whereRaw('(book_id, paragraph) > (?, ?)', [$book, $para]);
  149. }
  150. /**
  151. * 按原文字节数累加切块,到上限或到 200 段为止,每块至少一段。
  152. *
  153. * 纯函数:长度由调用方查好传进来,这里不碰数据库。段落数上限要造上千段才看得
  154. * 出来,走 HTTP 会真的去渲染每一段,所以单元测试直接对着它测。
  155. *
  156. * @param array<int, array{book: int, para: int}> $candidates
  157. * @param array<int, array<int, int>> $lengths book => (para => 字节数)
  158. * @return array<int, array{book: int, para: int}>
  159. */
  160. public function sliceByBytes(array $candidates, array $lengths, int $limit): array
  161. {
  162. $bytes = 0;
  163. $take = 0;
  164. foreach ($candidates as $coord) {
  165. $bytes += (int) ($lengths[$coord['book']][$coord['para']] ?? 0);
  166. $take++;
  167. if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) {
  168. break;
  169. }
  170. }
  171. return array_slice($candidates, 0, $take);
  172. }
  173. /**
  174. * 取这些段落的原文字节数。按书分组、每本用首尾段落号一次查回整段区间,
  175. * 避免几百个值的 IN 列表;区间里多查回来几行比拼 IN 便宜。
  176. *
  177. * @param array<int, array{book: int, para: int}> $candidates
  178. * @return array<int, array<int, int>> book => (para => 字节数)
  179. */
  180. private function paragraphLengths(array $candidates): array
  181. {
  182. $byBook = [];
  183. foreach ($candidates as $coord) {
  184. $byBook[$coord['book']][] = $coord['para'];
  185. }
  186. $lengths = [];
  187. foreach ($byBook as $book => $paras) {
  188. $lengths[$book] = PaliText::where('book', $book)
  189. ->whereBetween('paragraph', [min($paras), max($paras)])
  190. ->pluck('lenght', 'paragraph')
  191. ->all();
  192. }
  193. return $lengths;
  194. }
  195. /**
  196. * 进度用的 total / remaining,**只在带了 book 过滤时给**。
  197. *
  198. * 不带过滤时结果集是整个 channel(实测最大的 52 万段),每页 count 一次要
  199. * 八百毫秒、两百多 MB,不值得。那种场景用 next_cursor 判断结束。
  200. *
  201. * @param Builder<Sentence> $scope
  202. * @param array<string, mixed> $filters
  203. * @param array{book: int, para: int}|null $last
  204. * @return array<string, int>
  205. */
  206. private function progressMeta(Builder $scope, array $filters, ?array $last): array
  207. {
  208. if (! isset($filters['book'])) {
  209. return [];
  210. }
  211. $countIn = fn (Builder $q): int => (int) (
  212. $q->selectRaw('count(distinct (book_id, paragraph)) as aggregate')->first()?->aggregate ?? 0
  213. );
  214. $total = $countIn(clone $scope);
  215. $remaining = $last === null
  216. ? 0
  217. : $countIn($this->applyCursor(clone $scope, "{$last['book']}-{$last['para']}"));
  218. return ['total' => $total, 'remaining' => $remaining];
  219. }
  220. /**
  221. * 按 include 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
  222. *
  223. * @param array{para: int, display: string, sentences: array} $paragraph
  224. * @return array<string, mixed>
  225. */
  226. private function filterInclude(array $paragraph, string $include): array
  227. {
  228. return match ($include) {
  229. 'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
  230. 'all' => $paragraph,
  231. default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
  232. };
  233. }
  234. }