Procházet zdrojové kódy

feat(api-v13): 新增 /v3/reactions 系列端点

v2 的 like 迁到 v3。v2 那份原地冻结不动(B 类,v4 在线上用着)。

v2 的 LikeController@index 用 view= 开关服务两个口径,按「一个 index 只服务一个
契约」拆成三个端点——权限模型和必填参数都不同,是三件事:

  GET    /v3/reactions?target_id=     公共只读(取代 view=target)
  GET    /v3/reactions/tally?target_id= 各 type 计数(取代 view=count)
  GET    /v3/me/reactions              我的列表
  POST   /v3/me/reactions              添加,幂等
  DELETE /v3/me/reactions/{reaction}   删除,只能删自己的

资源改名:likes 表早已不只存点赞,type 的实际取值有 like / dislike / favorite /
watch / bookmark / download 六种,叫 like 名不副实。已登记进 CLAUDE.md 的
v2 → v3 对照表。底层仍是 likes 表,Reaction 模型的类注释写明了这层关系。

顺带修对一个 v2 的 bug:唯一索引是 (type, target_id, user_id) 三列,v2 的
firstOrNew 用了四列(多一个 target_type),是个潜在的唯一键冲突。

POST 首次创建返回 201、命中已存在返回 200,不是手写状态码——Laravel 的
ResourceResponse::calculateStatus() 读模型的 wasRecentlyCreated。

Resource 不查库:Reaction 上两条 belongsTo(user_infos 与 ai_models,同一个
user_id 落在两张表之一、没有类型判别列,所以不是 morphTo),控制器用
UserService::eagerLoadActor() 预加载,Resource 用 ResolvesActor trait 择一。
实测 12 行与 2 行都是 4 次查询,改之前 12 行要 14 次。列白名单收在 UserService
里而不是让控制器手抄——抄漏匹配列(userid / uid)会让关系静默对不上、整列变 null。

控制器是胶水层,业务逻辑在 Services\V3\ReactionService。权限判定用
AuthorizationException 而不是 abort(403):规则属于 Service,HTTP 转换由
bootstrap/app.php 统一做。

测试 23 条,含一条钉住查询数不随行数增长的 N+1 守卫(断言的是不变量而不是
硬编码数字),以及守住 403 文案不退化成键名的断言。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda před 3 týdny
rodič
revize
d42b344a6c

+ 94 - 0
api-v13/app/Http/Controllers/V3/MeReactionController.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexMeReactionRequest;
+use App\Http\Requests\V3\StoreMeReactionRequest;
+use App\Http\Resources\V3\ReactionResource;
+use App\Http\Resources\V3\ReactionStatusResource;
+use App\Models\Reaction;
+use App\Services\V3\ReactionService;
+use Illuminate\Http\Request;
+
+/**
+ * 当前登录用户自己的 reactions(点赞/收藏/书签/关注/下载记录)。
+ *
+ * 与 `/v3/reactions`(公共只读)分属两个控制器:这个控制器全线要求登录,
+ * user_id 一律取当前登录用户,不接受客户端指定——替别人加关注是 target 的
+ * 关注者管理,暂留在 v2,不属于这里。
+ *
+ * 取代 `POST /v2/like` 与 `DELETE /v2/like/{like}`。资源改名(v2 的 `like` →
+ * v3 的 `reactions`)是根目录 CLAUDE.md「已登记的改名例外」里的唯一一条,
+ * 底层仍是 `likes` 表。
+ */
+class MeReactionController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly ReactionService $reactions) {}
+
+    /**
+     * 列出我自己的 reactions
+     *
+     * @queryParam type string 操作类型。Enum: like,dislike,favorite,watch,bookmark,download
+     * @queryParam target_type string target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语)。Enum: task,collection,progress_chapter,article,terms
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function index(IndexMeReactionRequest $request)
+    {
+        $user = $this->currentUser($request);
+
+        return ReactionResource::collection($this->reactions->listForUser(
+            $user['user_uid'],
+            $request->validated(),
+            $request->integer('per_page', 15),
+        ));
+    }
+
+    /**
+     * 添加一条我自己的 reaction(幂等)
+     *
+     * 唯一键是 (type, target_id, user_id):重复提交不会产生第二条记录,返回同一条。
+     * 首次创建返回 201,命中已存在的记录返回 200。
+     *
+     * 201 不是手写的状态码:Laravel 的 ResourceResponse::calculateStatus() 看模型的
+     * wasRecentlyCreated,新建时自动给 201。这里不要改成固定 200——幂等端点区分
+     * 「真的建了」与「早就有了」对调用方有用。
+     *
+     * @responseStatus 201 首次创建。响应体与 200 同形,区别只在这一条是本次新建的
+     *
+     * @bodyParam type string required 操作类型。Enum: like,dislike,favorite,watch,bookmark,download
+     * @bodyParam target_id string required target 的 uuid
+     * @bodyParam target_type string required target 的类型(task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语)。Enum: task,collection,progress_chapter,article,terms
+     * @bodyParam context string 附加上下文,最长 128 字符
+     */
+    public function store(StoreMeReactionRequest $request)
+    {
+        $user = $this->currentUser($request);
+
+        return new ReactionStatusResource(
+            $this->reactions->add($user['user_uid'], $request->validated())
+        );
+    }
+
+    /**
+     * 删除我自己的 reaction
+     *
+     * 只能删自己那条(user_id 与当前用户不符返回 403)。
+     *
+     * @urlParam reaction string required reaction 的 uuid
+     *
+     * @responseStatus 403 只能删除自己的 reaction
+     */
+    public function destroy(Request $request, Reaction $reaction)
+    {
+        $user = $this->currentUser($request);
+
+        return new ReactionStatusResource(
+            $this->reactions->remove($reaction, $user['user_uid'])
+        );
+    }
+}

+ 45 - 0
api-v13/app/Http/Controllers/V3/ReactionController.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexReactionRequest;
+use App\Http\Resources\V3\ReactionResource;
+use App\Services\V3\ReactionService;
+
+/**
+ * 某个 target 下的 reactions(点赞/收藏/书签/关注/下载记录)——公共只读。
+ *
+ * 取代 `GET /v2/like?view=target`。与 `/v3/me/reactions`(当前用户自己的 reactions)
+ * 分属两个控制器:那边全线要求登录,这边全线公开。
+ *
+ * 资源改名(v2 的 `like` → v3 的 `reactions`)是根目录 CLAUDE.md
+ * 「已登记的改名例外」里的唯一一条,底层仍是 `likes` 表。
+ */
+class ReactionController extends Controller
+{
+    public function __construct(private readonly ReactionService $reactions) {}
+
+    /**
+     * 列出某个 target 下的 reactions
+     *
+     * 按 target 聚合的公共列表,不要求登录。分页由 Laravel paginator 提供。
+     *
+     * @unauthenticated
+     *
+     * @queryParam target_id string required target 的 uuid。Example: 5b4511c1-c99f-47e7-9798-97b9e5229e18
+     * @queryParam type string 操作类型。Enum: like,dislike,favorite,watch,bookmark,download
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function index(IndexReactionRequest $request)
+    {
+        $data = $request->validated();
+
+        return ReactionResource::collection($this->reactions->listForTarget(
+            $data['target_id'],
+            $data['type'] ?? null,
+            $request->integer('per_page', 15),
+        ));
+    }
+}

+ 52 - 0
api-v13/app/Http/Controllers/V3/ReactionTallyController.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexReactionTallyRequest;
+use App\Http\Resources\V3\ReactionTallyResource;
+use App\Services\V3\ReactionService;
+
+/**
+ * 某个 target 下各 type 的计数与「当前用户是否已操作」的摘要——公共只读。
+ *
+ * 取代 `GET /v2/like?view=count`。登录时额外富化 selected / id 两个字段,
+ * 不登录也能拿到全局计数。
+ *
+ * 资源改名(v2 的 `like` → v3 的 `reactions`)是根目录 CLAUDE.md
+ * 「已登记的改名例外」里的唯一一条,底层仍是 `likes` 表。
+ */
+class ReactionTallyController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly ReactionService $reactions) {}
+
+    /**
+     * 统计某个 target 下各 type 的计数
+     *
+     * 一次聚合查询出各 type 的 count;登录时再查一次当前用户的选择,补 selected / id。
+     *
+     * @unauthenticated
+     *
+     * @queryParam target_id string required target 的 uuid。Example: 5b4511c1-c99f-47e7-9798-97b9e5229e18
+     */
+    public function index(IndexReactionTallyRequest $request)
+    {
+        $items = $this->reactions->tally(
+            $request->validated('target_id'),
+            $this->currentUserUid($request),
+        );
+
+        // 非分页列表:手工给 meta,使响应形状与规格里的 {data, meta} 一致
+        // FIXME: current_page / per_page / last_page 在不分页的聚合上是假值,
+        // 只是为了套上生成器写死的 PaginationMeta,待定夺
+        return ReactionTallyResource::collection($items)->additional(['meta' => [
+            'current_page' => 1,
+            'per_page' => count($items),
+            'total' => count($items),
+            'last_page' => 1,
+        ]]);
+    }
+}

+ 37 - 0
api-v13/app/Http/Requests/V3/IndexMeReactionRequest.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/me/reactions` 的入参:当前用户自己的 reaction 列表。
+ *
+ * 这里没有 user_id——结果集永远由登录态决定,不接受客户端指定。
+ */
+class IndexMeReactionRequest extends FormRequest
+{
+    /**
+     * 登录检查在控制器里做:authorize() 返回 false 会渲染成 403,
+     * 而「没登录」应该是 401。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'type' => ['nullable', 'string', Rule::in(Reaction::TYPES)],
+            'target_type' => ['nullable', 'string', Rule::in(Reaction::TARGET_TYPES)],
+            'page' => ['integer', 'min:1'],
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 38 - 0
api-v13/app/Http/Requests/V3/IndexReactionRequest.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/reactions` 的入参:某个 target 下的公共 reaction 列表。
+ *
+ * `target_id` 必填不是随口加的:likes 表没有全表可读的用例,
+ * 不带条件就能拉全表的列表端点是设计错误。
+ */
+class IndexReactionRequest extends FormRequest
+{
+    /**
+     * 公共只读端点,不需要鉴权。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'target_id' => ['required', 'uuid'],
+            'type' => ['nullable', 'string', Rule::in(Reaction::TYPES)],
+            'page' => ['integer', 'min:1'],
+            // 上限 200 与 v3 其它列表一致:没有上限时 ?per_page=100000 能一次拉空一个 target
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 33 - 0
api-v13/app/Http/Requests/V3/IndexReactionTallyRequest.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/reactions/tally` 的入参:某个 target 的各 type 计数摘要。
+ *
+ * 聚合结果每个 type 一行,总行数不超过 {@see Reaction::TYPES} 的长度,
+ * 所以不分页,也就没有 page / per_page。
+ */
+class IndexReactionTallyRequest extends FormRequest
+{
+    /**
+     * 公共只读端点,不需要鉴权;登录时才额外富化 selected / id。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'target_id' => ['required', 'uuid'],
+        ];
+    }
+}

+ 39 - 0
api-v13/app/Http/Requests/V3/StoreMeReactionRequest.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `POST /v3/me/reactions` 的入参:添加一条自己的 reaction。
+ *
+ * 不收 user_id:v2 允许 `user_id` 覆盖操作者(替别人加关注),那是 target 的
+ * 关注者管理,不属于 `me` 这组端点,留在 v2。
+ */
+class StoreMeReactionRequest extends FormRequest
+{
+    /**
+     * 登录检查在控制器里做:authorize() 返回 false 会渲染成 403,
+     * 而「没登录」应该是 401。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'type' => ['required', 'string', Rule::in(Reaction::TYPES)],
+            'target_id' => ['required', 'uuid'],
+            'target_type' => ['required', 'string', Rule::in(Reaction::TARGET_TYPES)],
+            // context 列是 varchar(128)
+            'context' => ['nullable', 'string', 'max:128'],
+        ];
+    }
+}

+ 39 - 0
api-v13/app/Http/Resources/Concerns/ResolvesActor.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Resources\Concerns;
+
+use App\Services\UserService;
+
+/**
+ * 给 Resource 用的操作者解析。
+ *
+ * 「操作者」在这个系统里可能是人类(`user_infos`)也可能是 AI 助手(`ai_models`),
+ * 共用一个 uuid 空间且没有类型判别列,所以每个模型都要挂两条 belongsTo,
+ * Resource 再从两者里择一。这个 trait 把「择一」那步收成一行:
+ *
+ *     use ResolvesActor;
+ *
+ *     'user' => $this->actor($this->user, $this->aiModel),
+ *
+ * 配套的预加载声明用 {@see UserService::eagerLoadActor()},两边都别手抄列名。
+ *
+ * **前提是关系已经预加载。** 没 `->with()` 就每行懒加载,又回到 N+1;
+ * `ReactionV3Test` 里那条「查询数与行数无关」的断言就是守这个的。
+ */
+trait ResolvesActor
+{
+    /**
+     * 从两条已预加载的关系里定出操作者摘要;都为空时给 unknown 占位。
+     *
+     * Resource 的构造签名由 JsonResource 定死,注入不进来,只能在这里解析容器。
+     * UserService 注册为 singleton,反复解析拿到的是同一个实例,不会丢身份映射。
+     *
+     * @param  mixed  $human  一行 `user_infos`,没有传 null
+     * @param  mixed  $assistant  一行 `ai_models`,没有传 null
+     * @return array<string, mixed>
+     */
+    protected function actor($human, $assistant = null): array
+    {
+        return app(UserService::class)->actor($human, $assistant);
+    }
+}

+ 49 - 0
api-v13/app/Http/Resources/V3/ReactionResource.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Http\Resources\Concerns\ResolvesActor;
+use App\Models\Reaction;
+use Illuminate\Http\Request;
+
+/**
+ * 一条 reaction 记录。
+ *
+ * user 是操作者的公开摘要。操作者可能是人类用户(`user_infos`)也可能是 AI 助手
+ * (`ai_models`),两张表的形状不同,这里只承诺稳定的核心字段。
+ *
+ * **本类不查库。** 操作者从 `user` / `aiModel` 两条已预加载的关系上取——
+ * 控制器负责 `->with([...])`,见 {@see Reaction::user()}。
+ * {@see ResolvesActor::actor()} 只把已加载的模型拼成摘要,不碰数据库。
+ */
+class ReactionResource extends BaseResource
+{
+    use ResolvesActor;
+
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     type: string,
+     *     target_id: string,
+     *     target_type: string,
+     *     context: string|null,
+     *     created_at: string,
+     *     updated_at: string,
+     *     user: array{id: string, nickName: string, userName: string, realName: string, sn: int, avatar: string|null, roles: array|null}|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => $this->id,
+            'type' => $this->type,
+            'target_id' => $this->target_id,
+            'target_type' => $this->target_type,
+            'context' => $this->context,
+            'created_at' => $this->created_at,
+            'updated_at' => $this->updated_at,
+            'user' => $this->actor($this->user, $this->aiModel),
+        ];
+    }
+}

+ 37 - 0
api-v13/app/Http/Resources/V3/ReactionStatusResource.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Models\Reaction;
+use Illuminate\Http\Request;
+
+/**
+ * 一条 reaction 操作后的状态:type + 全局计数 + 是否选中 + 记录 id。
+ *
+ * 组装成品是 Resource 的职责:控制器只把 Reaction 模型传进来(store 后是
+ * 已保存的模型,destroy 后是已删除的模型),count / selected / id 都在这里算。
+ * selected 直接取模型的 exists 状态——store 后为 true,delete 后为 false。
+ */
+class ReactionStatusResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     type: string,
+     *     count: int,
+     *     selected: bool,
+     *     id: string|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'type' => $this->type,
+            'count' => Reaction::where('target_id', $this->target_id)
+                ->where('type', $this->type)
+                ->count(),
+            'selected' => $this->exists,
+            'id' => $this->exists ? $this->id : null,
+        ];
+    }
+}

+ 33 - 0
api-v13/app/Http/Resources/V3/ReactionTallyResource.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 某个 type 的计数与「当前用户是否已操作」的摘要(tally 专用)。
+ *
+ * 输入是控制器已拼好的聚合行 {type, count, selected, id},这里只负责把类型转对。
+ * 与 {@see ReactionStatusResource} 同形状,但那边吃 Reaction 模型、这边吃聚合数组。
+ */
+class ReactionTallyResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     type: string,
+     *     count: int,
+     *     selected: bool,
+     *     id: string|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'type' => $this->resource['type'],
+            'count' => (int) $this->resource['count'],
+            'selected' => (bool) $this->resource['selected'],
+            'id' => $this->resource['id'] ?? null,
+        ];
+    }
+}

+ 80 - 0
api-v13/app/Models/Reaction.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Models;
+
+use App\Http\Resources\V3\ReactionResource;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * 用户对某个锚点(target)的单向操作记录。
+ *
+ * 点赞、收藏、书签、关注、下载记录共用这张表:type 区分操作种类,
+ * 唯一键 (type, target_id, user_id) 保证「一个用户对一个锚点的同种操作只有一条」。
+ *
+ * 底层仍是历史表 `likes`(表名不迁移,v2 的 Like 模型继续指向同一张表)。
+ */
+class Reaction extends Model
+{
+    use HasFactory;
+
+    /**
+     * `type` 列的合法取值。
+     *
+     * 列本身是 varchar(32) 没有约束,这里是 v3 入口处收紧的白名单——
+     * v2 的 `LikeController` 不做校验,历史数据里出现过的只有这几种。
+     *
+     * @var list<string>
+     */
+    public const TYPES = ['like', 'dislike', 'favorite', 'watch', 'bookmark', 'download'];
+
+    /**
+     * `target_type` 列的合法取值:锚点是哪一类对象。
+     *
+     * task 任务、collection 文集、progress_chapter 书·章节、article 文章、terms 术语。
+     *
+     * @var list<string>
+     */
+    public const TARGET_TYPES = ['task', 'collection', 'progress_chapter', 'article', 'terms'];
+
+    protected $table = 'likes';
+
+    protected $primaryKey = 'id';
+
+    public $incrementing = false;
+
+    protected $keyType = 'string';
+
+    protected $casts = [
+        'id' => 'string',
+    ];
+
+    protected $fillable = ['type', 'target_id', 'target_type', 'user_id', 'context'];
+
+    /**
+     * 操作者是人类用户时的那一行。
+     *
+     * `user_id` 存的是 uuid,对应 `user_infos.userid`(不是主键 `id`),所以要写
+     * 三参数形式。同款写法见 {@see Channel::owner()}。
+     *
+     * 关系没有数据库外键约束——`likes` 建表时就没有,加不得(migration 只准加
+     * 不准改)。匹配列 `user_infos.userid` 上有唯一索引,whereIn 走得动。
+     */
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(UserInfo::class, 'user_id', 'userid');
+    }
+
+    /**
+     * 操作者是 AI 助手时的那一行。
+     *
+     * 同一个 `user_id` 落在 `user_infos` 或 `ai_models` 两张表之一,没有类型判别列,
+     * 所以不是 morphTo,只能两条关系并存、取到哪个算哪个。
+     * 见 {@see ReactionResource::toArray()}。
+     */
+    public function aiModel(): BelongsTo
+    {
+        return $this->belongsTo(AiModel::class, 'user_id', 'uid');
+    }
+}

+ 138 - 0
api-v13/app/Services/V3/ReactionService.php

@@ -0,0 +1,138 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\Reaction;
+use App\Services\UserService;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
+
+/**
+ * reactions(点赞/收藏/书签/关注/下载记录)的业务逻辑。
+ *
+ * 底层是历史表 `likes`,v2 的 `LikeController` 另有一套实现,两边不共用——
+ * 见 CLAUDE.md 铁律 2:契约相关的逻辑 v3 重写,不为共享去改 v2。
+ */
+class ReactionService
+{
+    /**
+     * 某个 target 下的公共列表。
+     *
+     * @return LengthAwarePaginator<int, Reaction>
+     */
+    public function listForTarget(string $targetId, ?string $type, int $perPage): LengthAwarePaginator
+    {
+        return $this->withActor()
+            ->where('target_id', $targetId)
+            ->when($type, fn ($q, $value) => $q->where('type', $value))
+            ->orderByDesc('created_at')
+            ->paginate($perPage);
+    }
+
+    /**
+     * 某个用户自己的列表。
+     *
+     * @param  array{type?: string, target_type?: string}  $filters
+     * @return LengthAwarePaginator<int, Reaction>
+     */
+    public function listForUser(string $userUid, array $filters, int $perPage): LengthAwarePaginator
+    {
+        return $this->withActor()
+            ->where('user_id', $userUid)
+            ->when($filters['type'] ?? null, fn ($q, $value) => $q->where('type', $value))
+            ->when($filters['target_type'] ?? null, fn ($q, $value) => $q->where('target_type', $value))
+            ->orderByDesc('created_at')
+            ->paginate($perPage);
+    }
+
+    /**
+     * 添加一条 reaction,幂等。
+     *
+     * 唯一键是 (type, target_id, user_id):重复提交命中同一条,不新建。
+     * 返回的模型上 `wasRecentlyCreated` 决定响应是 201 还是 200,
+     * 由 Laravel 的 ResourceResponse::calculateStatus() 读取。
+     *
+     * @param  array{type: string, target_id: string, target_type: string, context?: string|null}  $data
+     */
+    public function add(string $userUid, array $data): Reaction
+    {
+        $reaction = Reaction::firstOrNew([
+            'type' => $data['type'],
+            'target_id' => $data['target_id'],
+            'user_id' => $userUid,
+        ]);
+
+        // id 不在 $fillable 里,且主键是 uuid(非自增),必须显式赋值,
+        // 否则 save() 后拿不到生成的 id,前端无法据此删除。
+        if (! $reaction->exists) {
+            $reaction->id = (string) Str::uuid();
+            $reaction->target_type = $data['target_type'];
+            $reaction->context = $data['context'] ?? null;
+        }
+        $reaction->save();
+
+        return $reaction;
+    }
+
+    /**
+     * 删除一条 reaction。只能删自己那条。
+     *
+     * 抛 AuthorizationException 而不是 abort(403):权限判定是业务规则,属于这一层;
+     * 转成 HTTP 响应由 bootstrap/app.php 的处理器做(prepareException 会把它
+     * 转成 403,message 进 Problem 的 detail)。
+     *
+     * @throws AuthorizationException 不是本人
+     */
+    public function remove(Reaction $reaction, string $userUid): Reaction
+    {
+        if ($reaction->user_id !== $userUid) {
+            throw new AuthorizationException(__('site.forbidden'));
+        }
+        $reaction->delete();
+
+        return $reaction;
+    }
+
+    /**
+     * 某个 target 下各 type 的计数;给了 userUid 就额外标出他选了哪些。
+     *
+     * @return Collection<int, array{type: string, count: int, selected: bool, id: string|null}>
+     */
+    public function tally(string $targetId, ?string $userUid): Collection
+    {
+        $rows = Reaction::query()
+            ->where('target_id', $targetId)
+            ->groupBy('type')
+            ->selectRaw('type, count(*) as count')
+            ->get();
+
+        $mine = $userUid === null ? [] : Reaction::query()
+            ->where('target_id', $targetId)
+            ->where('user_id', $userUid)
+            ->pluck('id', 'type')
+            ->all();
+
+        return $rows->map(fn ($row) => [
+            'type' => $row->type,
+            'count' => (int) $row->count,
+            'selected' => array_key_exists($row->type, $mine),
+            'id' => $mine[$row->type] ?? null,
+        ]);
+    }
+
+    /**
+     * 带操作者预加载的基础查询。
+     *
+     * 列白名单在 UserService 里,别在调用处手抄——抄漏匹配列(userid / uid)
+     * 会让关系静默对不上、整列变 null,不报错。
+     *
+     * @return Builder<Reaction>
+     */
+    private function withActor()
+    {
+        return Reaction::query()->with(UserService::eagerLoadActor());
+    }
+}

+ 1 - 0
api-v13/resources/lang/en/site.php

@@ -20,6 +20,7 @@ return [
     // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
     // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
     'maintenance' => 'The service is under scheduled maintenance.',
     'maintenance' => 'The service is under scheduled maintenance.',
     'not_found' => 'The requested resource does not exist.',
     'not_found' => 'The requested resource does not exist.',
+    'forbidden' => 'You are not allowed to perform this action.',
     'invalid_parameter' => 'Invalid parameter.',
     'invalid_parameter' => 'Invalid parameter.',
     'author.name' => 'iapt',
     'author.name' => 'iapt',
     'author.email' => 'visuddhindand@gmail.com',
     'author.email' => 'visuddhindand@gmail.com',

+ 1 - 0
api-v13/resources/lang/zh-Hans/site.php

@@ -20,6 +20,7 @@ return [
     // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
     // 停机维护开关(.stop 文件)触发时,API 的 RFC 9457 detail 文案
     'maintenance' => '服务已进入停机维护状态',
     'maintenance' => '服务已进入停机维护状态',
     'not_found' => '请求的资源不存在',
     'not_found' => '请求的资源不存在',
+    'forbidden' => '你没有执行该操作的权限',
     'invalid_parameter' => '参数不合法',
     'invalid_parameter' => '参数不合法',
     'author.name' => 'iapt',
     'author.name' => 'iapt',
     'author.email' => 'visuddhindand@gmail.com',
     'author.email' => 'visuddhindand@gmail.com',

+ 313 - 0
api-v13/tests/Feature/ReactionV3Test.php

@@ -0,0 +1,313 @@
+<?php
+
+use App\Models\Reaction;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v3 reactions:点赞/收藏/书签/关注/下载记录共用一张 likes 表。
+ *
+ * 端点:
+ *   GET    /api/v3/reactions?target_id=…        公共,按 target 列记录
+ *   GET    /api/v3/reactions/tally?target_id=…   公共,各 type 计数 + 可选我的选择
+ *   GET    /api/v3/me/reactions                 我的列表(登录)
+ *   POST   /api/v3/me/reactions                 我添加(登录,幂等)
+ *   DELETE /api/v3/me/reactions/{reaction}       我删除(登录,只能删自己的)
+ */
+function makeReaction(array $attrs = []): Reaction
+{
+    return Reaction::forceCreate(array_merge([
+        'id' => (string) Str::uuid(),
+        'type' => 'like',
+        'target_id' => (string) Str::uuid(),
+        'target_type' => 'task',
+        'user_id' => makeStudio('reaction-'.Str::random(6)),
+        'context' => null,
+    ], $attrs));
+}
+
+it('lists reactions of a target without auth', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target]);
+    makeReaction(['target_id' => $target, 'type' => 'watch']);
+    makeReaction(['target_id' => (string) Str::uuid()]);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}")
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(2)
+        ->each(fn ($row) => $row->toHaveKeys(['id', 'type', 'target_id', 'target_type', 'user']));
+});
+
+it('filters reactions by type', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'favorite']);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}&type=like")
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(1)
+        ->and($data[0]['type'])->toBe('like');
+});
+
+it('rejects a non-uuid target_id', function () {
+    $this->getJson('/api/v3/reactions?target_id=not-a-uuid')->assertStatus(422);
+});
+
+it('rejects an out-of-range per_page', function () {
+    $target = (string) Str::uuid();
+
+    $this->getJson("/api/v3/reactions?target_id={$target}&per_page=100000")->assertStatus(422);
+    $this->getJson("/api/v3/reactions?target_id={$target}&per_page=0")->assertStatus(422);
+});
+
+it('honours per_page', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'watch']);
+
+    $json = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=1")
+        ->assertOk()
+        ->json();
+
+    expect($json['data'])->toHaveCount(1)
+        ->and($json['meta']['per_page'])->toBe(1)
+        ->and($json['meta']['total'])->toBe(2);
+});
+
+it('embeds the acting user without querying per row', function () {
+    // N+1 守卫:查询数必须与行数无关。
+    // 现在是 4 条(分页 count + 取页 + 预加载 user_infos + 预加载 ai_models);
+    // 断言的是「2 行与 12 行一样多」而不是硬编码 4——前者是真正的不变量,
+    // 后者会被 Laravel 内部实现的变动误伤。Resource 里每行再查一次就立刻红。
+    $countQueriesFor = function (int $rows): int {
+        $target = (string) Str::uuid();
+        foreach (range(1, $rows) as $i) {
+            makeReaction([
+                'target_id' => $target,
+                'type' => 'like',
+                'user_id' => makeStudio('actor-'.Str::random(8)),
+            ]);
+        }
+
+        DB::enableQueryLog();
+        DB::flushQueryLog();   // enable 不清空历史,两次测量之间必须手动冲掉
+        $data = $this->getJson("/api/v3/reactions?target_id={$target}&per_page=200")
+            ->assertOk()
+            ->json('data');
+        $queries = count(DB::getQueryLog());
+        DB::disableQueryLog();
+
+        expect($data)->toHaveCount($rows);
+
+        return $queries;
+    };
+
+    expect($countQueriesFor(12))->toBe($countQueriesFor(2));
+});
+
+it('resolves the acting user through the constrained eager load', function () {
+    $target = (string) Str::uuid();
+    $uid = makeStudio('actor-name');
+    makeReaction(['target_id' => $target, 'user_id' => $uid]);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
+
+    // 受限列漏掉哪个,这里就会露馅:id 来自 userid、nickName 来自 nickname、sn 来自 id
+    expect($data[0]['user']['id'])->toBe($uid)
+        ->and($data[0]['user']['nickName'])->toBe('actor-name')
+        ->and($data[0]['user']['userName'])->toBe('actor-name')
+        ->and($data[0]['user']['sn'])->toBeGreaterThan(0);
+});
+
+it('falls back to the unknown placeholder for an orphan user_id', function () {
+    $target = (string) Str::uuid();
+    Reaction::forceCreate([
+        'id' => (string) Str::uuid(),
+        'type' => 'like',
+        'target_id' => $target,
+        'target_type' => 'task',
+        'user_id' => (string) Str::uuid(), // 既不在 user_infos 也不在 ai_models
+        'context' => null,
+    ]);
+
+    $data = $this->getJson("/api/v3/reactions?target_id={$target}")->assertOk()->json('data');
+
+    expect($data[0]['user']['nickName'])->toBe('unknown');
+});
+
+it('tallies counts per type without auth', function () {
+    $target = (string) Str::uuid();
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+    makeReaction(['target_id' => $target, 'type' => 'favorite']);
+
+    $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}")
+        ->assertOk()
+        ->json('data');
+
+    $like = collect($data)->firstWhere('type', 'like');
+    $favorite = collect($data)->firstWhere('type', 'favorite');
+
+    expect($like['count'])->toBe(2)
+        ->and($like['selected'])->toBeFalse()
+        ->and($favorite['count'])->toBe(1);
+});
+
+it('marks my selection in the tally when authenticated', function () {
+    $target = (string) Str::uuid();
+    $uid = makeStudio('tally-owner');
+    $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+
+    $data = $this->getJson("/api/v3/reactions/tally?target_id={$target}", authHeader($uid))
+        ->assertOk()
+        ->json('data');
+
+    $like = collect($data)->firstWhere('type', 'like');
+    expect($like['selected'])->toBeTrue()
+        ->and($like['id'])->toBe($mine->id);
+});
+
+it('requires auth to list my reactions', function () {
+    $this->getJson('/api/v3/me/reactions')->assertStatus(401);
+});
+
+it('lists only my reactions', function () {
+    $uid = makeStudio('me-owner');
+    makeReaction(['user_id' => $uid, 'type' => 'like']);
+    makeReaction(['user_id' => $uid, 'type' => 'favorite']);
+    makeReaction(['user_id' => makeStudio('other'), 'type' => 'like']);
+
+    $data = $this->getJson('/api/v3/me/reactions', authHeader($uid))
+        ->assertOk()
+        ->json('data');
+
+    expect($data)->toHaveCount(2);
+});
+
+it('requires auth to add a reaction', function () {
+    $this->postJson('/api/v3/me/reactions', [
+        'type' => 'like',
+        'target_id' => (string) Str::uuid(),
+        'target_type' => 'task',
+    ])->assertStatus(401);
+});
+
+it('answers 401 before 422 when not logged in', function () {
+    // auth.v3 中间件在 FormRequest 之前跑,所以「未登录 + 参数非法」是 401 不是 422。
+    // 这正是把登录闸放进中间件(而不是控制器第一行)买到的东西
+    $this->postJson('/api/v3/me/reactions', ['type' => 'bogus'])
+        ->assertStatus(401)
+        ->assertJsonStructure(['type', 'title', 'status']);
+});
+
+it('does not accept the v1 cookie bypass', function () {
+    $uid = makeStudio('cookie-user');
+
+    // 必须直接改 $_COOKIE 超全局:withUnencryptedCookies() 只填 Symfony Request 的
+    // cookie 袋子,不碰超全局,用它写出来的 cookie 测试是空转的(不管代码里有没有
+    // 那条分支都会过)。helper 见 AuthServiceCookieTest.php
+    $backup = $_COOKIE;
+    $_COOKIE = ['user_uid' => $uid, 'user_id' => '1'];
+    try {
+        $this->getJson('/api/v3/me/reactions')->assertStatus(401);
+    } finally {
+        $_COOKIE = $backup;
+    }
+});
+
+it('validates required fields when adding', function () {
+    $uid = makeStudio('store-owner');
+
+    $this->postJson('/api/v3/me/reactions', ['type' => 'like'], authHeader($uid))
+        ->assertStatus(422);
+});
+
+it('rejects an unknown target_type when adding', function () {
+    $uid = makeStudio('store-owner');
+
+    $this->postJson('/api/v3/me/reactions', [
+        'type' => 'like',
+        'target_id' => (string) Str::uuid(),
+        'target_type' => 'unknown',
+    ], authHeader($uid))->assertStatus(422);
+});
+
+it('adds a reaction and forces user_id to the current user', function () {
+    $uid = makeStudio('store-owner');
+    $target = (string) Str::uuid();
+
+    $data = $this->postJson('/api/v3/me/reactions', [
+        'type' => 'like',
+        'target_id' => $target,
+        'target_type' => 'task',
+        'user_id' => (string) Str::uuid(), // 客户端塞的 user_id 应被忽略
+    ], authHeader($uid))->assertCreated()->json('data');
+
+    expect($data['selected'])->toBeTrue()
+        ->and($data['count'])->toBe(1)
+        ->and($data['id'])->not->toBeNull();
+
+    $record = Reaction::find($data['id']);
+    expect($record->user_id)->toBe($uid);
+});
+
+it('is idempotent: repeat add returns the same record', function () {
+    $uid = makeStudio('store-owner');
+    $target = (string) Str::uuid();
+    $payload = ['type' => 'favorite', 'target_id' => $target, 'target_type' => 'task'];
+
+    // 首次为 201(新建),重复提交命中唯一键,为 200(幂等不新建)
+    $first = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertCreated()->json('data');
+    $second = $this->postJson('/api/v3/me/reactions', $payload, authHeader($uid))->assertOk()->json('data');
+
+    expect($second['id'])->toBe($first['id'])
+        ->and($second['count'])->toBe(1)
+        ->and(Reaction::count())->toBe(1);
+});
+
+it('requires auth to delete a reaction', function () {
+    $reaction = makeReaction();
+
+    $this->deleteJson("/api/v3/me/reactions/{$reaction->id}")->assertStatus(401);
+});
+
+it('deletes my reaction and reports the remaining count', function () {
+    $uid = makeStudio('del-owner');
+    $target = (string) Str::uuid();
+    $mine = makeReaction(['target_id' => $target, 'type' => 'like', 'user_id' => $uid]);
+    makeReaction(['target_id' => $target, 'type' => 'like']);
+
+    $data = $this->deleteJson("/api/v3/me/reactions/{$mine->id}", [], authHeader($uid))
+        ->assertOk()
+        ->json('data');
+
+    expect($data['selected'])->toBeFalse()
+        ->and($data['count'])->toBe(1)
+        ->and(Reaction::find($mine->id))->toBeNull();
+});
+
+it('forbids deleting another user reaction', function () {
+    $other = makeReaction(['user_id' => makeStudio('del-other')]);
+
+    $detail = $this->deleteJson("/api/v3/me/reactions/{$other->id}", [], authHeader(makeStudio('del-me')))
+        ->assertStatus(403)
+        ->json('detail');
+
+    // 翻译文件缺键时 __() 会原样吐出键名,这条守住那种退化
+    expect($detail)->not->toBe('site.forbidden')->not->toBeEmpty();
+
+    expect(Reaction::find($other->id))->not->toBeNull();
+});
+
+it('rejects an unknown type filter on my reactions', function () {
+    $this->getJson('/api/v3/me/reactions?type=bogus', authHeader(makeStudio('filter-owner')))
+        ->assertStatus(422);
+});