TipitakaReadChapterController.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Http\Resources\V3Resource;
  4. use App\Models\PaliText;
  5. use App\Models\Sentence;
  6. use App\Services\PaliContentService;
  7. use Illuminate\Http\Request;
  8. use Illuminate\Support\Str;
  9. use Illuminate\Validation\ValidationException;
  10. class TipitakaReadChapterController extends Controller
  11. {
  12. /**
  13. * 字节模式下单块的字节上限。请求超过这个数按这个数算,不报错。
  14. */
  15. protected const MAX_BYTES_PER_PAGE = 5000;
  16. /**
  17. * 单块最多返回的段落数,两种单位都受它约束。
  18. *
  19. * 每段要单独渲染一次,块的成本跟段落数成正比而不是跟字节数成正比。段落模式
  20. * 直接把 pagesize 压到这个数;字节模式下偈颂体的书平均每段只有 20~30 字节,
  21. * 光靠字节上限仍可能切出几百段,所以累加时两个上限谁先到就在哪断块。
  22. */
  23. protected const MAX_PARAGRAPHS_PER_PAGE = 200;
  24. /**
  25. * 按章节读取译文
  26. *
  27. * `para` 定位章节(它是章节的起始段落号,章节长度取那一行的 `chapter_len`),
  28. * `from` 是本次取数的起点游标,从它开始取 `pagesize` 大小的一块。章节不存在、
  29. * 或该 channel 在这个章节里没有任何译文,返回 404。
  30. *
  31. * 取数的段落集合来自 sentences 表里该 channel 实际有译文的段落,不是章节的
  32. * 全部段落——多数译文是残缺的,按 pali_texts 的整段区间切块会切出空块。
  33. * 所以 `pagesize` 数的永远是有内容的段落。
  34. *
  35. * 游标式分块是为了前端的下载进度条与断点续传,meta 里给三个数:
  36. * `total_para` 是分母(该 channel 在本章节有多少段),`remaining_para` 是
  37. * `last_para` 之后本章节内还剩多少段,进度即
  38. * `(total_para - remaining_para) / total_para`;续传时把上次的
  39. * `last_para + 1` 当作下次的 `from` 传回来,`remaining_para` 为 0 即取完。
  40. *
  41. * `first_para` / `last_para` 是本块实际覆盖的段落闭区间,取自切块结果而不是
  42. * `data`:渲染为空的段落会被剔出 `data` 但仍算在块内,所以 `data` 可能比这个
  43. * 区间短,而续传必须按 `last_para` 推进,否则会卡在那一段上原地打转。
  44. * `first_para` 是游标顺延后的落点,可能大于请求的 `from`(即 `current_para`)。
  45. *
  46. * @unauthenticated
  47. *
  48. * @queryParam book integer required 典籍 id
  49. * @queryParam para integer required 章节的起始段落号,决定本次取数的段落区间
  50. * @queryParam from integer 起点游标,从这一段开始取;不传则等于 para,即从章节
  51. * 开头取起。可以是章节区间内的任意段落号,落在没有译文的段落上就
  52. * 顺延到它之后的第一段。超出章节区间、或它之后已经没有译文,
  53. * 返回 422。
  54. * @queryParam channel string required 译文 channel 的 uuid
  55. * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
  56. * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
  57. * Enum: display,sentences,all Default: display
  58. * @queryParam pagesize integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
  59. * unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际
  60. * 生效的值。Default: 10
  61. * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
  62. * 两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
  63. */
  64. public function index(Request $request, PaliContentService $paliService)
  65. {
  66. $data = $request->validate([
  67. 'book' => 'required|integer',
  68. 'para' => 'required|integer',
  69. 'from' => 'integer|min:1',
  70. 'channel' => 'required|uuid',
  71. 'format' => 'string|in:html,markdown,react,text',
  72. 'view' => 'string|in:display,sentences,all',
  73. 'pagesize' => 'integer|min:1',
  74. 'unit' => 'string|in:para,byte',
  75. ]);
  76. return $this->chapter(
  77. (int) $data['book'],
  78. (int) $data['para'],
  79. $data['channel'],
  80. $data,
  81. $paliService
  82. );
  83. }
  84. /**
  85. * 按 id 读取章节
  86. *
  87. * 与 index 等价,只是把 book 与 para 合成一个路径参数;游标 `from` 仍走
  88. * 查询参数。格式不对或 channel 不是 uuid 返回 422。
  89. *
  90. * @unauthenticated
  91. *
  92. * @urlParam tipitaka_read_chapter string required 章节 id,格式 {book}-{para}。Example: 9002-1
  93. *
  94. * @queryParam from integer 起点游标,从这一段开始取;不传则从章节开头取起。
  95. * 超出章节区间返回 422。
  96. * @queryParam channel string required 译文 channel 的 uuid
  97. * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html
  98. * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。
  99. * Enum: display,sentences,all Default: display
  100. * @queryParam pagesize integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、
  101. * unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际
  102. * 生效的值。Default: 10
  103. * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。
  104. * 两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para
  105. */
  106. public function show(Request $request, string $id, PaliContentService $paliService)
  107. {
  108. $arrId = explode('-', $id);
  109. if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) {
  110. throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]);
  111. }
  112. $channel = $request->input('channel');
  113. if (! Str::isUuid($channel)) {
  114. throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]);
  115. }
  116. $data = $request->validate([
  117. 'from' => 'integer|min:1',
  118. 'format' => 'string|in:html,markdown,react,text',
  119. 'view' => 'string|in:display,sentences,all',
  120. 'pagesize' => 'integer|min:1',
  121. 'unit' => 'string|in:para,byte',
  122. ]);
  123. return $this->chapter(
  124. (int) $arrId[0],
  125. (int) $arrId[1],
  126. $channel,
  127. $data,
  128. $paliService
  129. );
  130. }
  131. /**
  132. * @param array{from?: int|string, format?: string, view?: string, pagesize?: int|string, unit?: string} $param
  133. */
  134. protected function chapter(
  135. int $book,
  136. int $para,
  137. string $channel,
  138. array $param,
  139. PaliContentService $paliService
  140. ) {
  141. $format = $param['format'] ?? 'html';
  142. $view = $param['view'] ?? 'display';
  143. $unit = $param['unit'] ?? 'para';
  144. // 超上限不报错,按上限算;meta.page_size 回的是实际生效的值
  145. $pageSize = min(
  146. max(1, (int) ($param['pagesize'] ?? 10)),
  147. $unit === 'byte' ? self::MAX_BYTES_PER_PAGE : self::MAX_PARAGRAPHS_PER_PAGE
  148. );
  149. // 游标默认落在章节开头,即第一次调用不必传 para
  150. $cursor = max(1, (int) ($param['from'] ?? $para));
  151. $head = PaliText::where('book', $book)->where('paragraph', $para)->first();
  152. if (! $head) {
  153. abort(404, __('site.not_found'));
  154. }
  155. $to = $para + max(1, (int) $head->chapter_len) - 1;
  156. // 游标必须落在本章节的段落区间里,越界是调用方算错了,直接报错而不是
  157. // 悄悄钳到边界——断点续传时静默纠偏会把 bug 藏起来
  158. if ($cursor < $para || $cursor > $to) {
  159. throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]);
  160. }
  161. // 只取段落号,不取句子内容——最大的章节有一万五千段,整章的句子拉回来没有意义。
  162. // 整章都要查,因为 total_para 是进度条的分母,不能只算游标之后的部分。
  163. /** @var array<int, int> $translated 升序段落号 */
  164. $translated = Sentence::where('book_id', $book)
  165. ->whereBetween('paragraph', [$para, $to])
  166. ->where('channel_uid', $channel)
  167. ->where('strlen', '>', 0)
  168. ->distinct()
  169. ->orderBy('paragraph')
  170. ->pluck('paragraph')
  171. ->map(fn ($paragraph) => (int) $paragraph)
  172. ->all();
  173. $total = count($translated);
  174. if ($total === 0) {
  175. abort(404, __('site.not_found'));
  176. }
  177. $offset = $this->cursorOffset($translated, $cursor);
  178. if ($offset === null) {
  179. throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]);
  180. }
  181. // 只有按字节切块才需要原文长度,按段落切块完全不必碰 pali_texts
  182. $lengths = $unit === 'byte' ? $this->paragraphLengths($book, $translated) : [];
  183. $slice = $this->slice($translated, $lengths, $pageSize, $unit, $offset);
  184. $items = [];
  185. foreach ($slice as $paragraphNumber) {
  186. $paragraph = $paliService->readParagraph($book, $paragraphNumber, $channel, $format);
  187. if (empty($paragraph['display'])) {
  188. continue;
  189. }
  190. $items[] = $this->filterView($paragraph, $view);
  191. }
  192. return V3Resource::collection($items)->additional(['meta' => [
  193. // 请求的游标 from 原样回显;实际取到的第一段看 first_para(游标
  194. // 落在没有译文的段落上时两者不同)
  195. 'current_para' => $cursor,
  196. // 该 channel 在本章节内有译文的段落总数,进度条的分母
  197. 'total_para' => $total,
  198. // 每块大小带单位(段落数或字节数),不是 Laravel 的 per_page,
  199. // 所以单列 page_size + page_size_unit 两个键
  200. 'page_size' => $pageSize,
  201. 'page_size_unit' => $unit,
  202. 'book' => $book,
  203. 'chapter' => $para,
  204. // 本块实际覆盖的段落区间,两端都是闭区间、都是真实存在的段落号。
  205. // 取自切出来的这一块,不是取自 data:段落渲染出来是空的会被剔出
  206. // data,但仍算在本块之内,last_para 照样要跨过它——否则续传会卡在
  207. // 那一段上原地打转。所以 data 可能比 first_para..last_para 短。
  208. // first_para 是游标顺延后的落点,可能大于请求的 current_para;
  209. // last_para + 1 就是下一块要传的 from。
  210. 'first_para' => $slice[0],
  211. 'last_para' => $slice[count($slice) - 1],
  212. // last_para 之后本章节内还剩多少段,0 即取完;续传把 last_para + 1 当 from 传回
  213. 'remaining_para' => $total - $offset - count($slice),
  214. ]]);
  215. }
  216. /**
  217. * 游标 $cursor 落在升序段落号里的下标:它本身有译文就是它,否则顺延到之后的
  218. * 第一段。$cursor 已在章节区间内(调用方先校验过),但可能落在最后一段译文
  219. * 之后,那时返回 null。
  220. *
  221. * @param array<int, int> $paragraphs 升序段落号
  222. */
  223. protected function cursorOffset(array $paragraphs, int $cursor): ?int
  224. {
  225. foreach ($paragraphs as $offset => $paragraph) {
  226. if ($paragraph >= $cursor) {
  227. return $offset;
  228. }
  229. }
  230. return null;
  231. }
  232. /**
  233. * 取这些段落的原文字节数。按首尾段落号一次查回整段区间,避免几千个值的
  234. * IN 列表;区间里没有译文的段落多查回来几行,比拼 IN 便宜得多。
  235. *
  236. * @param array<int, int> $paragraphs 升序段落号,至少一个
  237. * @return array<int, int> 段落号 => 原文字节数
  238. */
  239. protected function paragraphLengths(int $book, array $paragraphs): array
  240. {
  241. return PaliText::where('book', $book)
  242. ->whereBetween('paragraph', [$paragraphs[0], $paragraphs[count($paragraphs) - 1]])
  243. ->pluck('lenght', 'paragraph')
  244. ->all();
  245. }
  246. /**
  247. * 从 $offset 起取一块段落号。按段落数时直接切片,按字节时累加原文长度,
  248. * 字节数或段落数任一到顶即断块,每块至少一段。
  249. *
  250. * @param array<int, int> $paragraphs 升序段落号
  251. * @param array<int, int> $lengths 段落号 => 原文字节数,只在 byte 模式用得上
  252. * @param string $unit para 按段落数,byte 按字节数
  253. * @param int $offset 起点下标,调用方保证在范围内
  254. * @return array<int, int> 非空
  255. */
  256. protected function slice(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array
  257. {
  258. if ($unit !== 'byte') {
  259. return array_slice($paragraphs, $offset, max(1, min($limit, self::MAX_PARAGRAPHS_PER_PAGE)));
  260. }
  261. $bytes = 0;
  262. $take = 0;
  263. $count = count($paragraphs);
  264. while ($offset + $take < $count) {
  265. $bytes += (int) ($lengths[$paragraphs[$offset + $take]] ?? 0);
  266. $take++;
  267. if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) {
  268. break;
  269. }
  270. }
  271. return array_slice($paragraphs, $offset, $take);
  272. }
  273. /**
  274. * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。
  275. *
  276. * @param array{para: int, display: string, sentences: array} $paragraph
  277. */
  278. protected function filterView(array $paragraph, string $view): array
  279. {
  280. return match ($view) {
  281. 'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']],
  282. 'all' => $paragraph,
  283. default => ['para' => $paragraph['para'], 'display' => $paragraph['display']],
  284. };
  285. }
  286. }