TipitakaReadChapterController.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Http\Resources\V3Resource;
  4. use App\Models\PaliText;
  5. use App\Services\PaliContentService;
  6. use Illuminate\Http\Request;
  7. use Illuminate\Support\Str;
  8. use Illuminate\Validation\ValidationException;
  9. class TipitakaReadChapterController extends Controller
  10. {
  11. /**
  12. * 按章节读取译文
  13. *
  14. * 给定 book + 段落号,定位它所属的章节,返回该章节的段落内容并分页。
  15. * 章节不存在或没有内容返回 404。
  16. *
  17. * 分页是手工做的:按字节时从头累加 `pali_texts.lenght` 逐页推进,所以 meta
  18. * 里用 `page_size`(领域语法,非整数)而不是框架的 `per_page`,并附带
  19. * `first_para` / `last_para` / `has_more`。
  20. *
  21. * @unauthenticated
  22. *
  23. * @queryParam book integer required 典籍 id
  24. * @queryParam para integer required 章节内任一段落号,用于定位章节
  25. * @queryParam channel string required 译文 channel 的 uuid
  26. * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
  27. * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
  28. * Enum: display,sentences,all Default: display
  29. * @queryParam pagesize string 每页大小,两种写法:`20000b` 按字节累加段落长度
  30. * (每页至少一段),`10p` 按段落数。Default: 20000b
  31. * @queryParam page integer 页码,从 1 开始。超出范围返回 422。Default: 1
  32. */
  33. public function index(Request $request, PaliContentService $paliService)
  34. {
  35. $data = $request->validate([
  36. 'book' => 'required|integer',
  37. 'para' => 'required|integer',
  38. 'channel' => 'required|uuid',
  39. 'format' => 'string|in:html,markdown,react,text',
  40. 'view' => 'string|in:display,sentences,all',
  41. 'pagesize' => ['string', 'regex:/^\d+[bp]$/'],
  42. 'page' => 'integer|min:1',
  43. ]);
  44. return $this->chapter(
  45. (int) $data['book'],
  46. (int) $data['para'],
  47. $data['channel'],
  48. $data,
  49. $paliService
  50. );
  51. }
  52. /**
  53. * 按 id 读取章节
  54. *
  55. * 与 index 等价,只是把 book 与 para 合成一个路径参数。格式不对或 channel
  56. * 不是 uuid 返回 422。
  57. *
  58. * @unauthenticated
  59. *
  60. * @urlParam tipitaka_read_chapter string required 章节 id,格式 {book}-{para}。Example: 9002-1
  61. *
  62. * @queryParam channel string required 译文 channel 的 uuid
  63. * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
  64. * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
  65. * Enum: display,sentences,all Default: display
  66. * @queryParam pagesize string 每页大小,两种写法:`20000b` 按字节累加段落长度
  67. * (每页至少一段),`10p` 按段落数。Default: 20000b
  68. * @queryParam page integer 页码,从 1 开始。超出范围返回 422。Default: 1
  69. */
  70. public function show(Request $request, string $id, PaliContentService $paliService)
  71. {
  72. $arrId = explode('-', $id);
  73. if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
  74. throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
  75. }
  76. $channel = $request->input('channel');
  77. if (! Str::isUuid($channel)) {
  78. throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
  79. }
  80. return $this->chapter(
  81. (int) $arrId[0],
  82. (int) $arrId[1],
  83. $channel,
  84. $request->only(['format', 'view', 'pagesize', 'page']),
  85. $paliService
  86. );
  87. }
  88. /**
  89. * @param array{format?: string, view?: string, pagesize?: string, page?: int|string} $param
  90. */
  91. protected function chapter(
  92. int $book,
  93. int $para,
  94. string $channel,
  95. array $param,
  96. PaliContentService $paliService
  97. ) {
  98. $format = $param['format'] ?? 'html';
  99. $view = $param['view'] ?? 'display';
  100. $pageSize = $param['pagesize'] ?? '10p';
  101. $page = max(1, (int) ($param['page'] ?? 1));
  102. $chapter = PaliText::where('book', $book)->where('paragraph', $para)->first();
  103. if (! $chapter) {
  104. abort(404, __('site.not_found'));
  105. }
  106. $to = $para + max(1, (int) $chapter->chapter_len) - 1;
  107. /** @var array<int, array{paragraph: int, lenght: int}> $paragraphs */
  108. $paragraphs = PaliText::where('book', $book)
  109. ->whereBetween('paragraph', [$para, $to])
  110. ->orderBy('paragraph')
  111. ->get(['paragraph', 'lenght'])
  112. ->all();
  113. $total = count($paragraphs);
  114. if ($total === 0) {
  115. abort(404, __('site.not_found'));
  116. }
  117. $slice = $this->slice($paragraphs, $pageSize, $page);
  118. if ($slice === null) {
  119. throw ValidationException::withMessages(['page' => __('site.invalid_parameter')]);
  120. }
  121. $items = [];
  122. foreach ($slice as $row) {
  123. $paragraph = $paliService->readParagraph($book, (int) $row->paragraph, $channel, $format);
  124. if (empty($paragraph['display'])) {
  125. continue;
  126. }
  127. $items[] = $this->filterView($paragraph, $view);
  128. }
  129. $first = $slice[0]->paragraph;
  130. $last = $slice[count($slice) - 1]->paragraph;
  131. return V3Resource::collection($items)->additional(['meta' => [
  132. 'current_page' => $page,
  133. 'total' => $total,
  134. // pagesize 是领域特有语法("2p" = 2 个段落,纯数字 = 字节数),
  135. // 不是 Laravel 的整数 per_page,所以单列一个键
  136. 'page_size' => $pageSize,
  137. 'book' => $book,
  138. 'first_para' => (int) $first,
  139. 'last_para' => (int) $last,
  140. 'has_more' => $last < $paragraphs[$total - 1]->paragraph,
  141. ]]);
  142. }
  143. /**
  144. * 取第 $page 批段落。按段落数时直接切片,按字节时从头累加 lenght 逐页推进。
  145. *
  146. * @param array<int, PaliText> $paragraphs
  147. * @return array<int, PaliText>|null 页码越界时返回 null
  148. */
  149. protected function slice(array $paragraphs, string $pageSize, int $page): ?array
  150. {
  151. $limit = (int) substr($pageSize, 0, -1);
  152. $unit = substr($pageSize, -1);
  153. if ($limit < 1) {
  154. return null;
  155. }
  156. if ($unit === 'p') {
  157. $offset = ($page - 1) * $limit;
  158. $slice = array_slice($paragraphs, $offset, $limit);
  159. return $slice === [] ? null : $slice;
  160. }
  161. // 字节模式:每页累加 lenght,超过上限即断页,每页至少一段
  162. $offset = 0;
  163. $count = count($paragraphs);
  164. for ($current = 1; $offset < $count; $current++) {
  165. $bytes = 0;
  166. $take = 0;
  167. while ($offset + $take < $count) {
  168. $bytes += (int) $paragraphs[$offset + $take]->lenght;
  169. $take++;
  170. if ($bytes >= $limit) {
  171. break;
  172. }
  173. }
  174. if ($current === $page) {
  175. return array_slice($paragraphs, $offset, $take);
  176. }
  177. $offset += $take;
  178. }
  179. return null;
  180. }
  181. /**
  182. * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
  183. *
  184. * @param array{para: int, display: string, sentences: array} $paragraph
  185. */
  186. protected function filterView(array $paragraph, string $view): array
  187. {
  188. return match ($view) {
  189. 'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
  190. 'all' => $paragraph,
  191. default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
  192. };
  193. }
  194. }