feat(offers): 未驗證教練的課程不對公開端點曝光
is_verified 原本只有 Admin toggle 開關、無任何業務約束(稽核 P1-1)。 - DivingOffer 新增 visibleToPublic scope:provider_id null 或教練已驗證 - 公開 index/show 套用過濾,未驗證教練課程列表排除、詳情 404 - toggle-verified 後 flush diving_offers 快取標籤,切換立即生效 - 新增 provider-verification 規格(含已知限制註記)與 7 條可見性測試 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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]]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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。
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 公開課程可見性(provider-verification 規格)。
|
||||
*
|
||||
* is_verified 是平台對教練資質的把關開關:若未驗證教練的課程
|
||||
* 仍可公開曝光與被預約,Admin 的驗證機制形同虛設。
|
||||
*/
|
||||
class DivingOfferVisibilityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeProvider(bool $isVerified): User
|
||||
{
|
||||
$provider = User::factory()->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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user