Browse Source

refactor(api-v13): v3 存量代码对齐硬规范,顺带修掉 progress 的 500

按硬规范逐条审了一遍 v3 的现存代码,改掉能验证的部分。

**删掉未注册路由的空脚手架方法**(硬规范第 5 条:控制器文件里除入口方法外不该
有别的方法):Upgrade 4 个、Progress 4 个、SearchPlus 2 个。它们都不在路由表里,
是 make:controller 留下的死代码。

**ProgressController 整改**:

- 去掉 view= 开关。它只有 channel 一个合法值、传别的直接 422——那不是业务路径
  开关而是噪音(硬规范第 1 条)。
- 新增 IndexProgressRequest,不再全程 $request->input()(硬规范第 10 条)。
- 查询逻辑下沉 Services\V3\ProgressService,控制器 110 行 → 39 行(第 5 条)。
- **修一个真 bug**:order 参数未经校验直接拼进 orderBy
  ('progress_chapters.'.$request->input('order')),传个不存在的列名就是
  QueryException → 500。实测确认过。现在走白名单,超出白名单 422。
- 补 7 条测试,这个端点原来零覆盖。

还剩两处冲突没动,它们是重写不是清理,需要单独决定:
SearchPlusController 与 SearchSuggestController 用的是第三种信封
({success, error, data}),既不是 v2 的 {ok,message,data} 也不是 v3 的 {data};
业务逻辑在控制器里;没有 FormRequest;SearchSuggest 还有硬编码中文;两个都
catch (\Exception) 返回 500 带原始 message。三个前端对它们零实际调用,但零测试
覆盖且依赖 OpenSearch,happy path 在本机验证不了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 weeks ago
parent
commit
6166055890

+ 12 - 83
api-v13/app/Http/Controllers/V3/ProgressController.php

@@ -3,108 +3,37 @@
 namespace App\Http\Controllers\V3;
 
 use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexProgressRequest;
 use App\Http\Resources\V3\ProgressResource;
-use App\Models\ProgressChapter;
-use Illuminate\Http\Request;
-use Illuminate\Validation\ValidationException;
+use App\Services\V3\ProgressService;
 
 class ProgressController extends Controller
 {
+    public function __construct(private readonly ProgressService $progress) {}
+
     /**
      * 列出章节翻译进度
      *
-     * 按 channel 查询各章节的翻译完成度。view 目前只支持 channel 一种口径,
-     * 传其它值返回 422。
+     * 按 channel 查询各章节的翻译完成度。分页与 meta 全由框架算。
+     *
+     * 原来有个 `view` 参数,但它只有 `channel` 一个合法值、传别的直接 422——
+     * 那不是业务路径开关而是噪音,已去掉(硬规范第 1 条)。
      *
      * @unauthenticated
      *
-     * @queryParam view string required 查询口径,目前只有 channel。Enum: channel
      * @queryParam channels string required channel uid 列表,**下划线分隔**(不是逗号)
      * @queryParam level integer 只返回该层级及以上的章节,需联查 pali_texts
      * @queryParam lang string 按译文语言过滤。Example: zh-Hans
      * @queryParam book integer 按典籍 id 过滤
-     * @queryParam order string 排序字段(progress_chapters 的列)。Default: completed_at
+     * @queryParam order string 排序字段,白名单内。Enum: book,para,lang,progress,title,last_chapter_completed_at,completed_at,updated_at Default: completed_at
      * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
      * @queryParam page integer 页码。Default: 1
-     * @queryParam per_page integer 每页数量。Default: 10
+     * @queryParam per_page integer 每页数量,最大 200。Default: 10
      */
-    public function index(Request $request)
+    public function index(IndexProgressRequest $request)
     {
-        //
-        $select = [
-            'progress_chapters.book',
-            'progress_chapters.para',
-            'progress_chapters.lang',
-            'progress_chapters.progress',
-            'progress_chapters.channel_id',
-            'progress_chapters.title',
-            'progress_chapters.last_chapter_completed_at',
-            'progress_chapters.completed_at',
-            'progress_chapters.updated_at',
-        ];
-        switch ($request->input('view')) {
-            case 'channel':
-                $table = ProgressChapter::select($select)
-                    ->whereIn('progress_chapters.channel_id', explode('_', $request->input('channels', '')));
-                break;
-            default:
-                throw ValidationException::withMessages(['view' => __('site.invalid_parameter')]);
-                break;
-        }
-
-        if ($request->filled('level')) {
-            $table = $table->join('pali_texts', function ($join) {
-                $join->on('progress_chapters.book', '=', 'pali_texts.book')
-                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
-            })->where('pali_texts.level', '<=', (int) $request->input('level'));
-        }
-
-        if ($request->has('lang')) {
-            $table = $table->where('progress_chapters.lang', $request->input('lang'));
-        }
-        if ($request->has('book')) {
-            $table = $table->where('progress_chapters.book', $request->input('book'));
-        }
-        $table = $table->orderBy(
-            'progress_chapters.'.$request->input('order', 'completed_at'),
-            $request->input('dir', 'desc')
-        );
-
-        // 分页与 meta 全由框架算;page 参数 paginate() 自己会读
         return ProgressResource::collection(
-            $table->paginate($request->integer('per_page', 10))
+            $this->progress->chapters($request->validated())
         );
     }
-
-    /**
-     * Store a newly created resource in storage.
-     */
-    public function store(Request $request)
-    {
-        //
-    }
-
-    /**
-     * Display the specified resource.
-     */
-    public function show(string $id)
-    {
-        //
-    }
-
-    /**
-     * Update the specified resource in storage.
-     */
-    public function update(Request $request, string $id)
-    {
-        //
-    }
-
-    /**
-     * Remove the specified resource from storage.
-     */
-    public function destroy(string $id)
-    {
-        //
-    }
 }

+ 0 - 22
api-v13/app/Http/Controllers/V3/SearchPlusController.php

@@ -7,7 +7,6 @@ use App\Http\Controllers\Controller;
 use App\Http\Resources\V3\BaseResource;
 use App\Services\OpenSearchService;
 use Illuminate\Http\Request;
-use Illuminate\Http\Response;
 
 class SearchPlusController extends Controller
 {
@@ -151,25 +150,4 @@ class SearchPlusController extends Controller
             abort(404, __('site.not_found'));
         }
     }
-
-    /**
-     * 更新资源
-     *
-     * @route PUT /api/search/{uid}
-     *
-     * @param JSON: OpenSearch 格式数据
-     * @return Response
-     */
-    public function update(Request $request, $uid) {}
-
-    /**
-     * Remove the specified resource from storage.
-     *
-     * 删除资源
-     *
-     * @route DELETE /api/search/{uid}
-     *
-     * @return Response
-     */
-    public function destroy($uid) {}
 }

+ 0 - 35
api-v13/app/Http/Controllers/V3/UpgradeController.php

@@ -3,10 +3,7 @@
 namespace App\Http\Controllers\V3;
 
 use App\Http\Controllers\Controller;
-use App\Http\Requests\StoreUpgradeRequest;
-use App\Http\Requests\UpdateUpgradeRequest;
 use App\Http\Resources\V3\BaseResource;
-use App\Models\Upgrade;
 
 class UpgradeController extends Controller
 {
@@ -22,36 +19,4 @@ class UpgradeController extends Controller
         //
         return BaseResource::make(['status' => 'ok']);
     }
-
-    /**
-     * Store a newly created resource in storage.
-     */
-    public function store(StoreUpgradeRequest $request)
-    {
-        //
-    }
-
-    /**
-     * Display the specified resource.
-     */
-    public function show(Upgrade $upgrade)
-    {
-        //
-    }
-
-    /**
-     * Update the specified resource in storage.
-     */
-    public function update(UpdateUpgradeRequest $request, Upgrade $upgrade)
-    {
-        //
-    }
-
-    /**
-     * Remove the specified resource from storage.
-     */
-    public function destroy(Upgrade $upgrade)
-    {
-        //
-    }
 }

+ 52 - 0
api-v13/app/Http/Requests/V3/IndexProgressRequest.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/progress` 的入参。
+ *
+ * 原来有个 `view` 参数,但它只有 `channel` 一个合法值,传别的直接 422——
+ * 那不是业务路径开关,是噪音,已去掉(硬规范第 1 条)。
+ */
+class IndexProgressRequest extends FormRequest
+{
+    /**
+     * 排序可用的列。**必须白名单**:原来是 `'progress_chapters.'.$request->input('order')`
+     * 直接拼进 orderBy,传个不存在的列名就是 QueryException → 500。
+     *
+     * @var list<string>
+     */
+    public const SORTABLE = [
+        'book', 'para', 'lang', 'progress', 'title',
+        'last_chapter_completed_at', 'completed_at', 'updated_at',
+    ];
+
+    /**
+     * 公开的只读接口。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            // channel uid 列表,**下划线分隔**(不是逗号)——既有契约,不要改
+            'channels' => ['required', 'string'],
+            'level' => ['sometimes', 'integer', 'min:1'],
+            'lang' => ['sometimes', 'string', 'max:20'],
+            'book' => ['sometimes', 'integer', 'min:1'],
+            'order' => ['sometimes', 'string', Rule::in(self::SORTABLE)],
+            'dir' => ['sometimes', 'string', 'in:asc,desc'],
+            'page' => ['sometimes', 'integer', 'min:1'],
+            'per_page' => ['sometimes', 'integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 69 - 0
api-v13/app/Services/V3/ProgressService.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\ProgressChapter;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+
+/**
+ * 章节翻译进度的查询。
+ *
+ * v2 的 `ProgressChapterController` 另有一套实现(还带着 view= 开关),两边不共用——
+ * 见 CLAUDE.md 铁律 2:契约相关的逻辑 v3 重写,不为共享去改 v2。
+ */
+class ProgressService
+{
+    /**
+     * 取出的列。progress_chapters 还有 summary / uid / completed_chapters 等,
+     * 接口不返回就不查。
+     *
+     * @var list<string>
+     */
+    private const COLUMNS = [
+        'progress_chapters.book',
+        'progress_chapters.para',
+        'progress_chapters.lang',
+        'progress_chapters.progress',
+        'progress_chapters.channel_id',
+        'progress_chapters.title',
+        'progress_chapters.last_chapter_completed_at',
+        'progress_chapters.completed_at',
+        'progress_chapters.updated_at',
+    ];
+
+    /**
+     * 按 channel 列出章节进度。
+     *
+     * @param  array<string, mixed>  $filters  已经过 FormRequest 校验的查询参数
+     * @return LengthAwarePaginator<int, ProgressChapter>
+     */
+    public function chapters(array $filters): LengthAwarePaginator
+    {
+        $query = ProgressChapter::query()
+            ->select(self::COLUMNS)
+            // channels 是下划线分隔的 uid 列表,既有契约
+            ->whereIn('progress_chapters.channel_id', explode('_', (string) $filters['channels']));
+
+        if (isset($filters['level'])) {
+            // 只要该层级及以上的章节,层级在 pali_texts 上
+            $query->join('pali_texts', function ($join) {
+                $join->on('progress_chapters.book', '=', 'pali_texts.book')
+                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
+            })->where('pali_texts.level', '<=', (int) $filters['level']);
+        }
+        if (isset($filters['lang'])) {
+            $query->where('progress_chapters.lang', $filters['lang']);
+        }
+        if (isset($filters['book'])) {
+            $query->where('progress_chapters.book', $filters['book']);
+        }
+
+        // order 已由 FormRequest 的白名单校验过,拼进列名是安全的
+        $query->orderBy(
+            'progress_chapters.'.($filters['order'] ?? 'completed_at'),
+            $filters['dir'] ?? 'desc'
+        );
+
+        return $query->paginate($filters['per_page'] ?? 10);
+    }
+}

+ 103 - 0
api-v13/tests/Feature/ProgressV3Test.php

@@ -0,0 +1,103 @@
+<?php
+
+use App\Models\ProgressChapter;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * GET /v3/progress —— 按 channel 列章节翻译进度。
+ *
+ * 这个端点原来零测试覆盖,整改(去掉只有一个合法值的 view 开关、加 FormRequest、
+ * order 白名单、逻辑下沉 Service)时补上。
+ */
+function makeProgressChapter(string $channelUid, int $book, int $para, array $attrs = []): ProgressChapter
+{
+    $row = new ProgressChapter;
+    $row->forceFill(array_merge([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'book' => $book,
+        'para' => $para,
+        'lang' => 'zh-Hans',
+        'channel_id' => $channelUid,
+        'progress' => 0.5,
+        'title' => "book {$book} para {$para}",
+        // all_trans / public 是 NOT NULL 且无默认值
+        'all_trans' => 0,
+        'public' => 0,
+    ], $attrs))->save();
+
+    return $row;
+}
+
+it('lists chapters of a channel', function () {
+    $channel = makeChannel(makeStudio('prog-owner'), 'prog channel');
+    makeProgressChapter($channel, 9001, 1);
+    makeProgressChapter($channel, 9001, 2);
+    makeProgressChapter((string) Str::uuid(), 9001, 3);   // 别的 channel
+
+    $json = $this->getJson("/api/v3/progress?channels={$channel}")->assertOk()->json();
+
+    expect($json['data'])->toHaveCount(2)
+        ->and($json['data'][0])->toHaveKeys(['book', 'para', 'lang', 'progress', 'channel_id'])
+        ->and($json['meta']['total'])->toBe(2);
+});
+
+it('accepts an underscore separated channel list', function () {
+    $a = makeChannel(makeStudio('prog-a'), 'a');
+    $b = makeChannel(makeStudio('prog-b'), 'b');
+    makeProgressChapter($a, 9001, 1);
+    makeProgressChapter($b, 9002, 1);
+
+    // 分隔符是下划线不是逗号,这是既有契约
+    $data = $this->getJson("/api/v3/progress?channels={$a}_{$b}")->assertOk()->json('data');
+
+    expect($data)->toHaveCount(2);
+});
+
+it('filters by book and lang', function () {
+    $channel = makeChannel(makeStudio('prog-filter'), 'c');
+    makeProgressChapter($channel, 9001, 1, ['lang' => 'zh-Hans']);
+    makeProgressChapter($channel, 9002, 1, ['lang' => 'en']);
+
+    expect($this->getJson("/api/v3/progress?channels={$channel}&book=9001")->assertOk()->json('data'))
+        ->toHaveCount(1);
+    expect($this->getJson("/api/v3/progress?channels={$channel}&lang=en")->assertOk()->json('data'))
+        ->toHaveCount(1);
+});
+
+it('requires the channels filter', function () {
+    // 不带条件就能拉全表的列表端点是设计错误(硬规范第 4 条)
+    $this->getJson('/api/v3/progress')
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['channels']]);
+});
+
+it('rejects an order column outside the whitelist', function () {
+    $channel = makeChannel(makeStudio('prog-order'), 'c');
+
+    // 原来 order 未校验,直接拼进 orderBy——传个不存在的列名就是 QueryException → 500
+    $this->getJson("/api/v3/progress?channels={$channel}&order=no_such_column")
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['order']]);
+
+    $this->getJson("/api/v3/progress?channels={$channel}&order=updated_at")->assertOk();
+});
+
+it('rejects a bad dir or per_page', function () {
+    $channel = makeChannel(makeStudio('prog-bad'), 'c');
+
+    $this->getJson("/api/v3/progress?channels={$channel}&dir=sideways")->assertStatus(422);
+    $this->getJson("/api/v3/progress?channels={$channel}&per_page=100000")->assertStatus(422);
+});
+
+it('no longer needs the view parameter', function () {
+    $channel = makeChannel(makeStudio('prog-view'), 'c');
+    makeProgressChapter($channel, 9001, 1);
+
+    // view 只有一个合法值,是噪音,已去掉;传了也只是被忽略
+    $this->getJson("/api/v3/progress?channels={$channel}")->assertOk();
+    $this->getJson("/api/v3/progress?channels={$channel}&view=channel")->assertOk();
+});