TipitakaChapterChannelService.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Facades\RedisCache;
  4. use App\Models\GroupInfo;
  5. use App\Models\GroupMember;
  6. use App\Models\ProgressChapter;
  7. use App\Models\Share;
  8. use App\Models\View;
  9. use App\Services\UserService;
  10. use Illuminate\Contracts\Pagination\LengthAwarePaginator;
  11. use Illuminate\Database\Eloquent\Builder;
  12. use Illuminate\Support\Collection;
  13. use Illuminate\Support\Str;
  14. /**
  15. * 某一章有哪些 channel(译本 / 逐词 / 原文……)及各自的完成度。
  16. *
  17. * 取代 v2 `ProgressChapterController@index` 的 `view=chapter_channels` 分支。v2 那份
  18. * 原样保留、不共用(CLAUDE.md 铁律 2)。v3 端点用 {@see self::list()},Laravel library 的
  19. * 作品版本页用 {@see self::all()}(经 `TipitakaCatalogService::workChannels()`)。与 v2 的差别:
  20. *
  21. * - **只列看得见的 channel**:公开的(status=30),加上登录用户自己的、共享给他(或他所在
  22. * 群组)的。v2 不判断可见性,私有 channel 的名字与进度对所有人可见。
  23. * - 不再返回 likes(前端没用);views、studio 改成整页批量取,查询数与行数无关。
  24. */
  25. class TipitakaChapterChannelService
  26. {
  27. /** channels.status:全网公开 */
  28. private const STATUS_PUBLIC = 30;
  29. /** shares.res_type:channel */
  30. private const SHARE_CHANNEL = 2;
  31. public function __construct(private readonly UserService $users) {}
  32. /**
  33. * 列出一章的 channel,按完成度从高到低(分页,给 v3 端点)。
  34. *
  35. * 返回的模型上已挂好 `views`、`studio`、`progress_line` 三个属性,Resource 直接读,不再查库。
  36. *
  37. * @param string|null $viewerUid 当前登录用户;未登录只看得到公开 channel
  38. * @return LengthAwarePaginator<int, ProgressChapter>
  39. */
  40. public function list(int $book, int $para, ?string $viewerUid, int $perPage): LengthAwarePaginator
  41. {
  42. $page = $this->query($book, $para, $viewerUid)->paginate($perPage);
  43. $this->enrich($page->getCollection());
  44. return $page;
  45. }
  46. /**
  47. * 同 {@see self::list()},但不分页、可排除一个 channel——给 Laravel library 的作品页用
  48. * (一章最多几十个 channel,整页渲染)。
  49. *
  50. * @param string|null $exceptChannel 不列的 channel(译本详情页的「其他版本」要排除当前译本)
  51. * @return Collection<int, ProgressChapter>
  52. */
  53. public function all(int $book, int $para, ?string $viewerUid, ?string $exceptChannel = null): Collection
  54. {
  55. $rows = $this->query($book, $para, $viewerUid)
  56. ->when($exceptChannel, fn (Builder $q) => $q->where('progress_chapters.channel_id', '<>', $exceptChannel))
  57. ->get();
  58. $this->enrich($rows);
  59. return $rows;
  60. }
  61. /**
  62. * 一章的进度行 join channel,已套可见性,按完成度降序。
  63. *
  64. * @return Builder<ProgressChapter>
  65. */
  66. private function query(int $book, int $para, ?string $viewerUid): Builder
  67. {
  68. return ProgressChapter::query()
  69. ->select([
  70. 'progress_chapters.uid',
  71. 'progress_chapters.book',
  72. 'progress_chapters.para',
  73. 'progress_chapters.channel_id',
  74. 'progress_chapters.progress',
  75. 'progress_chapters.updated_at',
  76. 'channels.name as channel_name',
  77. 'channels.type as channel_type',
  78. 'channels.lang as channel_lang',
  79. 'channels.owner_uid as channel_owner_uid',
  80. ])
  81. // inner join:channel 已删除的进度行没有意义,不列
  82. ->join('channels', 'progress_chapters.channel_id', '=', 'channels.uid')
  83. ->where('progress_chapters.book', $book)
  84. ->where('progress_chapters.para', $para)
  85. ->where(fn (Builder $q) => $this->readableBy($q, $viewerUid))
  86. ->orderByDesc('progress_chapters.progress')
  87. // 完成度相同时要稳定的 tiebreaker,否则跨页会漂移
  88. ->orderBy('progress_chapters.channel_id');
  89. }
  90. /**
  91. * 整批补上 views / studio / progress_line,查询数与行数无关。
  92. *
  93. * @param Collection<int, ProgressChapter> $rows
  94. */
  95. private function enrich(Collection $rows): void
  96. {
  97. $views = $this->viewCounts($rows->pluck('uid')->all());
  98. $studios = $this->studios($rows->pluck('channel_owner_uid')->all());
  99. foreach ($rows as $row) {
  100. $row->setAttribute('views', $views[$row->uid] ?? 0);
  101. $row->setAttribute('studio', $studios[$row->channel_owner_uid] ?? null);
  102. $row->setAttribute('progress_line', RedisCache::get(
  103. "/chapter_dynamic/{$row->book}/{$row->para}/ch_{$row->channel_id}"
  104. ));
  105. }
  106. }
  107. /**
  108. * 可见性:公开的,或(登录时)自己的、共享给自己或自己所在群组的。
  109. */
  110. private function readableBy(Builder $query, ?string $viewerUid): void
  111. {
  112. $query->where('channels.status', self::STATUS_PUBLIC);
  113. if ($viewerUid === null) {
  114. return;
  115. }
  116. $query->orWhere('channels.owner_uid', $viewerUid);
  117. $cooperators = GroupMember::where('user_id', $viewerUid)->pluck('group_id')->push($viewerUid);
  118. $shared = Share::whereIn('cooperator_id', $cooperators)
  119. ->where('res_type', self::SHARE_CHANNEL)
  120. ->pluck('res_id')
  121. // shares.res_id 是 varchar,channels.uid 是 uuid;非法值进了 where 会让 pgsql 报错
  122. ->filter(fn ($id) => Str::isUuid($id))
  123. ->unique()
  124. ->values();
  125. if ($shared->isNotEmpty()) {
  126. $query->orWhereIn('channels.uid', $shared);
  127. }
  128. }
  129. /**
  130. * 每个章节进度行被浏览的次数(views 表一行一次)。
  131. *
  132. * @param list<string> $uids progress_chapters.uid
  133. * @return array<string, int>
  134. */
  135. private function viewCounts(array $uids): array
  136. {
  137. if (! $uids) {
  138. return [];
  139. }
  140. return View::whereIn('target_id', $uids)
  141. ->groupBy('target_id')
  142. ->selectRaw('target_id, count(*) as n')
  143. ->pluck('n', 'target_id')
  144. ->map(fn ($n) => (int) $n)
  145. ->all();
  146. }
  147. /**
  148. * channel 所有者的摘要。所有者可能是用户,也可能是群组(v2 `StudioApi::getById` 的口径)。
  149. *
  150. * @param list<string|null> $ownerUids
  151. * @return array<string, array{id: string, nickName: string, realName: string, avatar?: string}>
  152. */
  153. private function studios(array $ownerUids): array
  154. {
  155. $output = [];
  156. foreach ($this->users->byUuids($ownerUids) as $uid => $profile) {
  157. $output[$uid] = array_filter([
  158. 'id' => $uid,
  159. 'nickName' => (string) ($profile['nickName'] ?? ''),
  160. 'realName' => (string) ($profile['realName'] ?? ''),
  161. 'avatar' => $profile['avatar'] ?? null,
  162. ], fn ($v) => $v !== null);
  163. }
  164. $groups = array_values(array_unique(array_diff(array_filter($ownerUids), array_keys($output))));
  165. if ($groups) {
  166. foreach (GroupInfo::whereIn('uid', $groups)->get(['uid', 'name']) as $group) {
  167. $output[$group->uid] = [
  168. 'id' => $group->uid,
  169. 'nickName' => (string) $group->name,
  170. 'realName' => (string) $group->uid,
  171. ];
  172. }
  173. }
  174. return $output;
  175. }
  176. }