diff --git a/app/Http/Controllers/API/AdminUserController.php b/app/Http/Controllers/API/AdminUserController.php index f1bcb7b..9814760 100644 --- a/app/Http/Controllers/API/AdminUserController.php +++ b/app/Http/Controllers/API/AdminUserController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class AdminUserController extends Controller { @@ -144,6 +145,9 @@ class AdminUserController extends Controller $profile->is_verified = !$profile->is_verified; $profile->save(); + // 驗證狀態影響公開課程列表的可見性,需立即讓快取失效 + Cache::tags(['diving_offers'])->flush(); + $msg = $profile->is_verified ? '教練已驗證' : '已取消驗證'; return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]); } diff --git a/app/Http/Controllers/API/DivingOfferController.php b/app/Http/Controllers/API/DivingOfferController.php index 96d6e33..0889e1d 100644 --- a/app/Http/Controllers/API/DivingOfferController.php +++ b/app/Http/Controllers/API/DivingOfferController.php @@ -15,7 +15,7 @@ class DivingOfferController extends Controller $cacheKey = 'diving_offers_' . md5(serialize($request->all())); $result = Cache::tags(['diving_offers'])->remember($cacheKey, 180, function () use ($request, $perPage) { - $query = DivingOffer::query(); + $query = DivingOffer::query()->visibleToPublic(); if ($q = $request->query('q')) { $query->where(function ($sub) use ($q) { @@ -55,7 +55,7 @@ class DivingOfferController extends Controller public function show(int $id) { - $offer = DivingOffer::with('courseImages')->find($id); + $offer = DivingOffer::with('courseImages')->visibleToPublic()->find($id); if (!$offer) { return response()->json(['status' => false, 'message' => '課程不存在'], 404); diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index e1816d4..b552b52 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Storage; @@ -53,6 +54,18 @@ class DivingOffer extends Model return $this->belongsTo(User::class, 'provider_id'); } + /** + * 公開端點可見性:未驗證教練的課程不對外曝光。 + * provider_id 為 null 的課程(平台自有資料)不受此限制。 + */ + public function scopeVisibleToPublic(Builder $query): Builder + { + return $query->where(function (Builder $visible) { + $visible->whereNull('provider_id') + ->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('is_verified', true)); + }); + } + public function schedules() { return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); diff --git a/openspec/specs/provider-verification/spec.md b/openspec/specs/provider-verification/spec.md new file mode 100644 index 0000000..6a84b58 --- /dev/null +++ b/openspec/specs/provider-verification/spec.md @@ -0,0 +1,48 @@ +# provider-verification Specification + +## Purpose + +定義 `provider_profiles.is_verified` 的最小業務語意:未通過平台驗證的教練,其課程不對公開端點曝光。在完整教練審核流程(證照上傳、審核佇列、駁回原因)實作前,先以此約束堵住「未審核教練可公開曝光」的風險。 + +## ADDED Requirements + +### Requirement: 未驗證教練的課程不對公開端點曝光 +公開課程端點(`GET /api/diving-offers`、`GET /api/diving-offers/{id}`)SHALL 僅回傳符合以下任一條件的課程:(a) `provider_id` 為 null(平台自有資料);(b) 課程所屬 Provider 的 `provider_profiles.is_verified = true`。未驗證教練的課程在列表中 SHALL 被排除,在詳情端點 SHALL 回傳 404。 + +#### Scenario: 已驗證教練的課程正常曝光 +- **WHEN** 匿名使用者請求 `GET /api/diving-offers` +- **THEN** 已驗證教練(is_verified=true)的課程出現在結果中 + +#### Scenario: 未驗證教練的課程從列表排除 +- **WHEN** 匿名使用者請求 `GET /api/diving-offers` +- **THEN** 未驗證教練(is_verified=false 或無 ProviderProfile)的課程不出現在結果中 + +#### Scenario: 未驗證教練的課程詳情回 404 +- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}`,該課程屬於未驗證教練 +- **THEN** 回傳 HTTP 404 + +#### Scenario: provider_id 為 null 的課程不受限 +- **WHEN** 匿名使用者請求公開課程端點,課程的 `provider_id` 為 null +- **THEN** 課程正常曝光 + +--- + +### Requirement: 驗證狀態切換立即生效 +管理員透過 `PUT /api/admin/providers/{id}/toggle-verified` 切換驗證狀態後,公開課程列表的快取(`diving_offers` cache tag)SHALL 立即失效,下次請求反映最新可見性。 + +#### Scenario: 取消驗證後課程立即從公開列表消失 +- **WHEN** 管理員將教練 is_verified 由 true 切為 false +- **THEN** 下一次 `GET /api/diving-offers` 請求不包含該教練的課程(不受 180 秒快取影響) + +--- + +### Requirement: 教練自有管理端點不受可見性限制 +Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未驗證教練仍可登入、編輯與管理自己的課程。 + +#### Scenario: 未驗證教練仍可管理自己的課程 +- **WHEN** 未驗證教練以有效 token 請求 `GET /api/provider/offers` +- **THEN** 回傳該教練的全部課程 + +## Notes + +已知限制(留待完整教練審核流程處理):`GET /api/diving-offers/{id}/schedules`、`GET /api/diving-offers/{id}/reviews` 與預約建立流程尚未套用相同過濾,知道課程 id 的使用者仍可間接互動。完整審核流程(verification_status enum、證照上傳、審核佇列)見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1。 diff --git a/tests/Feature/DivingOfferVisibilityTest.php b/tests/Feature/DivingOfferVisibilityTest.php new file mode 100644 index 0000000..f6d40da --- /dev/null +++ b/tests/Feature/DivingOfferVisibilityTest.php @@ -0,0 +1,138 @@ +create(['role' => 'provider']); + + ProviderProfile::create([ + 'user_id' => $provider->id, + 'is_verified' => $isVerified, + ]); + + return $provider; + } + + private function makeOffer(?int $providerId, string $title): DivingOffer + { + return DivingOffer::create([ + 'provider_id' => $providerId, + 'title' => $title, + 'location' => 'Test', + 'spot' => 'Test Spot', + 'price' => 1000, + 'region' => '南部', + 'rating' => 0, + 'reviews' => 0, + ]); + } + + // ── 公開列表 ───────────────────────────────────────────── + + public function test_index_includes_verified_provider_offers(): void + { + $verified = $this->makeProvider(true); + $this->makeOffer($verified->id, 'Verified Course'); + + $response = $this->getJson('/api/diving-offers'); + + $response->assertOk(); + $this->assertContains('Verified Course', array_column($response->json('data'), 'title')); + } + + public function test_index_excludes_unverified_provider_offers(): void + { + $unverified = $this->makeProvider(false); + $this->makeOffer($unverified->id, 'Unverified Course'); + + $response = $this->getJson('/api/diving-offers'); + + $response->assertOk(); + $this->assertNotContains('Unverified Course', array_column($response->json('data'), 'title')); + } + + public function test_index_includes_platform_owned_offers_without_provider(): void + { + $this->makeOffer(null, 'Platform Course'); + + $response = $this->getJson('/api/diving-offers'); + + $response->assertOk(); + $this->assertContains('Platform Course', array_column($response->json('data'), 'title')); + } + + // ── 課程詳情 ───────────────────────────────────────────── + + public function test_show_returns_404_for_unverified_provider_offer(): void + { + $unverified = $this->makeProvider(false); + $offer = $this->makeOffer($unverified->id, 'Hidden Course'); + + $this->getJson("/api/diving-offers/{$offer->id}")->assertStatus(404); + } + + public function test_show_returns_verified_provider_offer(): void + { + $verified = $this->makeProvider(true); + $offer = $this->makeOffer($verified->id, 'Visible Course'); + + $this->getJson("/api/diving-offers/{$offer->id}") + ->assertOk() + ->assertJsonPath('data.title', 'Visible Course'); + } + + // ── 驗證狀態切換立即生效(快取失效) ───────────────────── + + public function test_toggle_verified_takes_effect_immediately_despite_cache(): void + { + $provider = $this->makeProvider(true); + $this->makeOffer($provider->id, 'Toggle Course'); + $admin = User::factory()->create(['role' => 'admin']); + + // 先打一次列表讓結果進快取 + $this->assertContains( + 'Toggle Course', + array_column($this->getJson('/api/diving-offers')->json('data'), 'title') + ); + + $this->actingAs($admin) + ->putJson("/api/admin/providers/{$provider->id}/toggle-verified") + ->assertOk() + ->assertJsonPath('data.is_verified', false); + + $this->assertNotContains( + 'Toggle Course', + array_column($this->getJson('/api/diving-offers')->json('data'), 'title') + ); + } + + // ── 教練自有管理端點不受限 ─────────────────────────────── + + public function test_unverified_provider_can_still_manage_own_offers(): void + { + $unverified = $this->makeProvider(false); + $this->makeOffer($unverified->id, 'My Own Course'); + + $response = $this->actingAs($unverified)->getJson('/api/provider/offers'); + + $response->assertOk(); + $this->assertContains('My Own Course', array_column($response->json('data'), 'title')); + } +}