Forráskód Böngészése

feat(api-v13): reactions 的写操作改用标准返回,destroy 回 204

按新定的写操作约定(v3-resource skill 硬规范第 2 条)调整三处:

- POST /v3/me/reactions  → 201 + reaction 资源本身(调用方需要 id),不再带计数
- DELETE /v3/me/reactions/{id} → 204 空体,不再是 200 + 状态体
- 计数只在 GET /v3/reactions/tally 出现

三条依据都指向 204:RFC 9110 §9.3.5 说动作已执行且「no further information is to
be supplied」用 204;GitHub 的 star/unstar 与 reactions 删除都是 204 空体;Laravel
的 assertNoContent() 默认就是 204 **且断言响应体为空**。

不返回计数的理由不是洁癖:计数是共享聚合值,你删的同时别人在点,返回的数字发出去
就过期了;它还会让前端拿它当真值缓存,并把「改一次状态」和「读一次聚合」耦在一起。
X(Twitter) 的 like/unlike 只回 {"liked": false},GitHub 干脆 204,都不给计数。
前端用乐观更新 ±1,下次读 tally 时校正。

ReactionStatusResource 随之无人使用,删除。

前端 client.ts 新增 unwrapVoid():openapi-fetch 对 204 返回 { data: undefined },
而 unwrap() 见到 data === undefined 会当失败、弹提示再抛异常——明明请求是成功的。
204 端点必须走 unwrapVoid,只判 response.ok 不取 body。

destroy 的测试按约定写两条断言,它们抓的失败不一样:assertNoContent() 抓「返回 204
但压根没删」之外的情况,assertModelMissing() 抓「删对了但多吐了不该有的 body」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 hete
szülő
commit
2482c107f5

+ 11 - 6
api-v13/app/Http/Controllers/V3/MeReactionController.php

@@ -7,7 +7,6 @@ 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;
@@ -58,6 +57,10 @@ class MeReactionController extends Controller
      * wasRecentlyCreated,新建时自动给 201。这里不要改成固定 200——幂等端点区分
      * 「真的建了」与「早就有了」对调用方有用。
      *
+     * 返回的是 reaction 资源本身(调用方需要 id 才能后续删除),**不含计数**。
+     * 计数是共享聚合值,归 `GET /v3/reactions/tally` 管;前端用乐观更新 ±1,
+     * 下次读 tally 时校正。见 v3-resource skill 硬规范第 2 条。
+     *
      * @responseStatus 201 首次创建。响应体与 200 同形,区别只在这一条是本次新建的
      *
      * @bodyParam type string required 操作类型。Enum: like,dislike,favorite,watch,bookmark,download
@@ -69,7 +72,7 @@ class MeReactionController extends Controller
     {
         $user = $this->currentUser($request);
 
-        return new ReactionStatusResource(
+        return new ReactionResource(
             $this->reactions->add($user['user_uid'], $request->validated())
         );
     }
@@ -79,16 +82,18 @@ class MeReactionController extends Controller
      *
      * 只能删自己那条(user_id 与当前用户不符返回 403)。
      *
+     * 成功返回 **204 空体**:硬删就是删了,没有资源可回,计数也不在这里给
+     * (见 v3-resource skill 硬规范第 2 条)。
+     *
      * @urlParam reaction string required reaction 的 uuid
      *
+     * @responseStatus 204 删除成功,无响应体
      * @responseStatus 403 只能删除自己的 reaction
      */
     public function destroy(Request $request, Reaction $reaction)
     {
-        $user = $this->currentUser($request);
+        $this->reactions->remove($reaction, $this->currentUser($request)['user_uid']);
 
-        return new ReactionStatusResource(
-            $this->reactions->remove($reaction, $user['user_uid'])
-        );
+        return response()->noContent();
     }
 }

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

@@ -1,37 +0,0 @@
-<?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,
-        ];
-    }
-}

+ 2 - 1
api-v13/app/Http/Resources/V3/ReactionTallyResource.php

@@ -8,7 +8,8 @@ use Illuminate\Http\Request;
  * 某个 type 的计数与「当前用户是否已操作」的摘要(tally 专用)。
  *
  * 输入是控制器已拼好的聚合行 {type, count, selected, id},这里只负责把类型转对。
- * 与 {@see ReactionStatusResource} 同形状,但那边吃 Reaction 模型、这边吃聚合数组。
+ * 计数只在这里出现。写端点(store / destroy)不返回聚合值——那是共享的、会被别人
+ * 改的,mutation 的响应发出去就可能过期。见 v3-resource skill 硬规范第 2 条。
  */
 class ReactionTallyResource extends BaseResource
 {

+ 15 - 12
api-v13/tests/Feature/ReactionV3Test.php

@@ -251,9 +251,11 @@ it('adds a reaction and forces user_id to the current user', function () {
         '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();
+    // 返回的是资源本身。计数不在这里给——那是 tally 的职责
+    expect($data)->toHaveKeys(['id', 'type', 'target_id', 'target_type', 'user'])
+        ->and($data)->not->toHaveKey('count')
+        ->and($data['type'])->toBe('like')
+        ->and($data['target_id'])->toBe($target);
 
     $record = Reaction::find($data['id']);
     expect($record->user_id)->toBe($uid);
@@ -269,7 +271,6 @@ it('is idempotent: repeat add returns the same record', function () {
     $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);
 });
 
@@ -279,19 +280,21 @@ it('requires auth to delete a reaction', function () {
     $this->deleteJson("/api/v3/me/reactions/{$reaction->id}")->assertStatus(401);
 });
 
-it('deletes my reaction and reports the remaining count', function () {
+it('deletes my reaction with 204 and an empty body', 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']);
+    $others = makeReaction(['target_id' => $target, 'type' => 'like']);
 
-    $data = $this->deleteJson("/api/v3/me/reactions/{$mine->id}", [], authHeader($uid))
-        ->assertOk()
-        ->json('data');
+    // 两条都要:只有前者可能「返回 204 但压根没删」,只有后者可能「删对了但多吐了 body」
+    $this->deleteJson("/api/v3/me/reactions/{$mine->id}", [], authHeader($uid))
+        ->assertNoContent();
+    $this->assertModelMissing($mine);
 
-    expect($data['selected'])->toBeFalse()
-        ->and($data['count'])->toBe(1)
-        ->and(Reaction::find($mine->id))->toBeNull();
+    // 别人那条还在;剩余计数去 tally 拿,不由 destroy 返回
+    $this->assertModelExists($others);
+    $tally = $this->getJson("/api/v3/reactions/tally?target_id={$target}")->assertOk()->json('data');
+    expect(collect($tally)->firstWhere('type', 'like')['count'])->toBe(1);
 });
 
 it('forbids deleting another user reaction', function () {

+ 19 - 0
dashboard-v6/src/api/client.ts

@@ -83,3 +83,22 @@ export const unwrapQuiet = <T>(result: FetchResult<T>): T => {
   }
   return result.data;
 };
+
+/**
+ * 给 **204 No Content** 的端点用:只判成败,不取 body。
+ *
+ * v3 的 destroy 一律返回 204 空体(见 v3-resource skill 硬规范第 2 条)。
+ * 而 `openapi-fetch` 对 204 返回的是 `{ data: undefined }`——`unwrap()` 见到
+ * `data === undefined` 会当成失败、弹提示再抛异常,明明请求是成功的。
+ * 所以 204 端点必须走这里,不能走 `unwrap()`。
+ *
+ * 失败时的行为与 `unwrap()` 一致:弹提示并抛出。
+ */
+export const unwrapVoid = (result: FetchResult<unknown>): void => {
+  if (result.response.ok) {
+    return;
+  }
+  const error = toApiError(result);
+  reportApiError(error);
+  throw error;
+};