validate([ 'book' => 'required|integer', 'para' => 'required|integer', 'from' => 'integer|min:1', 'channel' => 'required|uuid', 'format' => 'string|in:html,markdown,react,text', 'view' => 'string|in:display,sentences,all', 'pagesize' => 'integer|min:1', 'unit' => 'string|in:para,byte', ]); return $this->chapter( (int) $data['book'], (int) $data['para'], $data['channel'], $data, $paliService ); } /** * 按 id 读取章节 * * 与 index 等价,只是把 book 与 para 合成一个路径参数;游标 `from` 仍走 * 查询参数。格式不对或 channel 不是 uuid 返回 422。 * * @unauthenticated * * @urlParam tipitaka_read_chapter string required 章节 id,格式 {book}-{para}。Example: 9002-1 * * @queryParam from integer 起点游标,从这一段开始取;不传则从章节开头取起。 * 超出章节区间返回 422。 * @queryParam channel string required 译文 channel 的 uuid * @queryParam format string 内容格式。Enum: html,markdown,react,text Default: html * @queryParam view string 输出口径:display 整段合并,sentences 逐句,all 两者都给。 * Enum: display,sentences,all Default: display * @queryParam pagesize integer 每块大小,含义由 unit 决定。上限 unit=para 时 200、 * unit=byte 时 5000,超出不报错按上限算,meta.page_size 回实际 * 生效的值。Default: 10 * @queryParam unit string 每块大小的单位:para 按段落数,byte 按段落长度累加。 * 两种单位下每块都至少一段、至多 200 段。Enum: para,byte Default: para */ public function show(Request $request, string $id, PaliContentService $paliService) { $arrId = explode('-', $id); if (count($arrId) !== 2 || ! is_numeric($arrId[0]) || ! is_numeric($arrId[1])) { throw ValidationException::withMessages(['id' => __('site.invalid_parameter')]); } $channel = $request->input('channel'); if (! Str::isUuid($channel)) { throw ValidationException::withMessages(['channel' => __('site.invalid_parameter')]); } $data = $request->validate([ 'from' => 'integer|min:1', 'format' => 'string|in:html,markdown,react,text', 'view' => 'string|in:display,sentences,all', 'pagesize' => 'integer|min:1', 'unit' => 'string|in:para,byte', ]); return $this->chapter( (int) $arrId[0], (int) $arrId[1], $channel, $data, $paliService ); } /** * @param array{from?: int|string, format?: string, view?: string, pagesize?: int|string, unit?: string} $param */ protected function chapter( int $book, int $para, string $channel, array $param, PaliContentService $paliService ) { $format = $param['format'] ?? 'html'; $view = $param['view'] ?? 'display'; $unit = $param['unit'] ?? 'para'; // 超上限不报错,按上限算;meta.page_size 回的是实际生效的值 $pageSize = min( max(1, (int) ($param['pagesize'] ?? 10)), $unit === 'byte' ? self::MAX_BYTES_PER_PAGE : self::MAX_PARAGRAPHS_PER_PAGE ); // 游标默认落在章节开头,即第一次调用不必传 para $cursor = max(1, (int) ($param['from'] ?? $para)); $head = PaliText::where('book', $book)->where('paragraph', $para)->first(); if (! $head) { abort(404, __('site.not_found')); } $to = $para + max(1, (int) $head->chapter_len) - 1; // 游标必须落在本章节的段落区间里,越界是调用方算错了,直接报错而不是 // 悄悄钳到边界——断点续传时静默纠偏会把 bug 藏起来 if ($cursor < $para || $cursor > $to) { throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]); } // 只取段落号,不取句子内容——最大的章节有一万五千段,整章的句子拉回来没有意义。 // 整章都要查,因为 total_para 是进度条的分母,不能只算游标之后的部分。 /** @var array $translated 升序段落号 */ $translated = Sentence::where('book_id', $book) ->whereBetween('paragraph', [$para, $to]) ->where('channel_uid', $channel) ->where('strlen', '>', 0) ->distinct() ->orderBy('paragraph') ->pluck('paragraph') ->map(fn ($paragraph) => (int) $paragraph) ->all(); $total = count($translated); if ($total === 0) { abort(404, __('site.not_found')); } $offset = $this->cursorOffset($translated, $cursor); if ($offset === null) { throw ValidationException::withMessages(['from' => __('site.invalid_parameter')]); } // 只有按字节切块才需要原文长度,按段落切块完全不必碰 pali_texts $lengths = $unit === 'byte' ? $this->paragraphLengths($book, $translated) : []; $slice = $this->slice($translated, $lengths, $pageSize, $unit, $offset); $items = []; foreach ($slice as $paragraphNumber) { $paragraph = $paliService->readParagraph($book, $paragraphNumber, $channel, $format); if (empty($paragraph['display'])) { continue; } $items[] = $this->filterView($paragraph, $view); } return V3Resource::collection($items)->additional(['meta' => [ // 请求的游标 from 原样回显;实际取到的第一段看 first_para(游标 // 落在没有译文的段落上时两者不同) 'current_para' => $cursor, // 该 channel 在本章节内有译文的段落总数,进度条的分母 'total_para' => $total, // 每块大小带单位(段落数或字节数),不是 Laravel 的 per_page, // 所以单列 page_size + page_size_unit 两个键 'page_size' => $pageSize, 'page_size_unit' => $unit, 'book' => $book, 'chapter' => $para, // 本块实际覆盖的段落区间,两端都是闭区间、都是真实存在的段落号。 // 取自切出来的这一块,不是取自 data:段落渲染出来是空的会被剔出 // data,但仍算在本块之内,last_para 照样要跨过它——否则续传会卡在 // 那一段上原地打转。所以 data 可能比 first_para..last_para 短。 // first_para 是游标顺延后的落点,可能大于请求的 current_para; // last_para + 1 就是下一块要传的 from。 'first_para' => $slice[0], 'last_para' => $slice[count($slice) - 1], // last_para 之后本章节内还剩多少段,0 即取完;续传把 last_para + 1 当 from 传回 'remaining_para' => $total - $offset - count($slice), ]]); } /** * 游标 $cursor 落在升序段落号里的下标:它本身有译文就是它,否则顺延到之后的 * 第一段。$cursor 已在章节区间内(调用方先校验过),但可能落在最后一段译文 * 之后,那时返回 null。 * * @param array $paragraphs 升序段落号 */ protected function cursorOffset(array $paragraphs, int $cursor): ?int { foreach ($paragraphs as $offset => $paragraph) { if ($paragraph >= $cursor) { return $offset; } } return null; } /** * 取这些段落的原文字节数。按首尾段落号一次查回整段区间,避免几千个值的 * IN 列表;区间里没有译文的段落多查回来几行,比拼 IN 便宜得多。 * * @param array $paragraphs 升序段落号,至少一个 * @return array 段落号 => 原文字节数 */ protected function paragraphLengths(int $book, array $paragraphs): array { return PaliText::where('book', $book) ->whereBetween('paragraph', [$paragraphs[0], $paragraphs[count($paragraphs) - 1]]) ->pluck('lenght', 'paragraph') ->all(); } /** * 从 $offset 起取一块段落号。按段落数时直接切片,按字节时累加原文长度, * 字节数或段落数任一到顶即断块,每块至少一段。 * * @param array $paragraphs 升序段落号 * @param array $lengths 段落号 => 原文字节数,只在 byte 模式用得上 * @param string $unit para 按段落数,byte 按字节数 * @param int $offset 起点下标,调用方保证在范围内 * @return array 非空 */ protected function slice(array $paragraphs, array $lengths, int $limit, string $unit, int $offset): array { if ($unit !== 'byte') { return array_slice($paragraphs, $offset, max(1, min($limit, self::MAX_PARAGRAPHS_PER_PAGE))); } $bytes = 0; $take = 0; $count = count($paragraphs); while ($offset + $take < $count) { $bytes += (int) ($lengths[$paragraphs[$offset + $take]] ?? 0); $take++; if ($bytes >= $limit || $take >= self::MAX_PARAGRAPHS_PER_PAGE) { break; } } return array_slice($paragraphs, $offset, $take); } /** * 按 view 裁剪输出。display 只要段落 html,sentences 只要句子列表,all 两者都要。 * * @param array{para: int, display: string, sentences: array} $paragraph */ protected function filterView(array $paragraph, string $view): array { return match ($view) { 'sentences' => ['para' => $paragraph['para'], 'sentences' => $paragraph['sentences']], 'all' => $paragraph, default => ['para' => $paragraph['para'], 'display' => $paragraph['display']], }; } }