ReactionService.php 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Models\Reaction;
  4. use App\Services\UserService;
  5. use Illuminate\Auth\Access\AuthorizationException;
  6. use Illuminate\Contracts\Pagination\LengthAwarePaginator;
  7. use Illuminate\Database\Eloquent\Builder;
  8. use Illuminate\Support\Collection;
  9. use Illuminate\Support\Str;
  10. /**
  11. * reactions(点赞/收藏/书签/关注/下载记录)的业务逻辑。
  12. *
  13. * 底层是历史表 `likes`,v2 的 `LikeController` 另有一套实现,两边不共用——
  14. * 见 CLAUDE.md 铁律 2:契约相关的逻辑 v3 重写,不为共享去改 v2。
  15. */
  16. class ReactionService
  17. {
  18. /**
  19. * 某个 target 下的公共列表。
  20. *
  21. * @return LengthAwarePaginator<int, Reaction>
  22. */
  23. public function listForTarget(string $targetId, ?string $type, int $perPage): LengthAwarePaginator
  24. {
  25. return $this->withActor()
  26. ->where('target_id', $targetId)
  27. ->when($type, fn ($q, $value) => $q->where('type', $value))
  28. ->orderByDesc('created_at')
  29. ->paginate($perPage);
  30. }
  31. /**
  32. * 某个用户自己的列表。
  33. *
  34. * @param array{type?: string, target_type?: string} $filters
  35. * @return LengthAwarePaginator<int, Reaction>
  36. */
  37. public function listForUser(string $userUid, array $filters, int $perPage): LengthAwarePaginator
  38. {
  39. return $this->withActor()
  40. ->where('user_id', $userUid)
  41. ->when($filters['type'] ?? null, fn ($q, $value) => $q->where('type', $value))
  42. ->when($filters['target_type'] ?? null, fn ($q, $value) => $q->where('target_type', $value))
  43. ->orderByDesc('created_at')
  44. ->paginate($perPage);
  45. }
  46. /**
  47. * 添加一条 reaction,幂等。
  48. *
  49. * 唯一键是 (type, target_id, user_id):重复提交命中同一条,不新建。
  50. * 返回的模型上 `wasRecentlyCreated` 决定响应是 201 还是 200,
  51. * 由 Laravel 的 ResourceResponse::calculateStatus() 读取。
  52. *
  53. * @param array{type: string, target_id: string, target_type: string, context?: string|null} $data
  54. */
  55. public function add(string $userUid, array $data): Reaction
  56. {
  57. $reaction = Reaction::firstOrNew([
  58. 'type' => $data['type'],
  59. 'target_id' => $data['target_id'],
  60. 'user_id' => $userUid,
  61. ]);
  62. // id 不在 $fillable 里,且主键是 uuid(非自增),必须显式赋值,
  63. // 否则 save() 后拿不到生成的 id,前端无法据此删除。
  64. if (! $reaction->exists) {
  65. $reaction->id = (string) Str::uuid();
  66. $reaction->target_type = $data['target_type'];
  67. $reaction->context = $data['context'] ?? null;
  68. }
  69. $reaction->save();
  70. return $reaction;
  71. }
  72. /**
  73. * 删除一条 reaction。只能删自己那条。
  74. *
  75. * 抛 AuthorizationException 而不是 abort(403):权限判定是业务规则,属于这一层;
  76. * 转成 HTTP 响应由 bootstrap/app.php 的处理器做(prepareException 会把它
  77. * 转成 403,message 进 Problem 的 detail)。
  78. *
  79. * @throws AuthorizationException 不是本人
  80. */
  81. public function remove(Reaction $reaction, string $userUid): Reaction
  82. {
  83. if ($reaction->user_id !== $userUid) {
  84. throw new AuthorizationException(__('site.forbidden'));
  85. }
  86. $reaction->delete();
  87. return $reaction;
  88. }
  89. /**
  90. * 某个 target 下各 type 的计数;给了 userUid 就额外标出他选了哪些。
  91. *
  92. * @return Collection<int, array{type: string, count: int, selected: bool, id: string|null}>
  93. */
  94. public function tally(string $targetId, ?string $userUid): Collection
  95. {
  96. $rows = Reaction::query()
  97. ->where('target_id', $targetId)
  98. ->groupBy('type')
  99. ->selectRaw('type, count(*) as count')
  100. ->get();
  101. $mine = $userUid === null ? [] : Reaction::query()
  102. ->where('target_id', $targetId)
  103. ->where('user_id', $userUid)
  104. ->pluck('id', 'type')
  105. ->all();
  106. return $rows->map(fn ($row) => [
  107. 'type' => $row->type,
  108. 'count' => (int) $row->count,
  109. 'selected' => array_key_exists($row->type, $mine),
  110. 'id' => $mine[$row->type] ?? null,
  111. ]);
  112. }
  113. /**
  114. * 带操作者预加载的基础查询。
  115. *
  116. * 列白名单在 UserService 里,别在调用处手抄——抄漏匹配列(userid / uid)
  117. * 会让关系静默对不上、整列变 null,不报错。
  118. *
  119. * @return Builder<Reaction>
  120. */
  121. private function withActor()
  122. {
  123. return Reaction::query()->with(UserService::eagerLoadActor());
  124. }
  125. }