feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
Run Tests / test (pull_request) Successful in 2m3s
Run Tests / test (pull_request) Successful in 2m3s
實作 openspec change provider-verification-workflow(25/25 tasks): - is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/ approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留 is_verified 相容輸出 - 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因 顯示與重送(/coach/verification 新頁面) - Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除 toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取 - 通知:審核通過/駁回(站內+Email,駁回含原因) - 可見性與預約入口改判 approved(行為等價) - 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8), 既有 4 個測試檔 helper 遷移;Swagger 同步 - 規格同步:provider-verification 全面改寫、admin-user-management toggle 段落改為移除聲明 容器內全套件 173 passed / 439 assertions。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+68
-13
@@ -283,30 +283,85 @@ class AdminApiDoc
|
||||
}
|
||||
|
||||
/**
|
||||
* 切換教練審核狀態
|
||||
* 教練審核佇列
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/admin/providers/{id}/toggle-verified",
|
||||
* summary="切換教練審核狀態",
|
||||
* description="通過或撤銷教練審核,回傳新的 is_verified 狀態",
|
||||
* operationId="toggleProviderVerified",
|
||||
* @OA\Get(
|
||||
* path="/admin/verifications",
|
||||
* summary="教練審核佇列",
|
||||
* description="查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
|
||||
* operationId="adminVerificationIndex",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, description="使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="status", in="query", required=false, description="unsubmitted / pending / approved / rejected / all", @OA\Schema(type="string", default="pending")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="切換成功",
|
||||
* description="查詢成功",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="status", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="教練已通過審核"),
|
||||
* @OA\Property(property="is_verified", type="boolean", example=true)
|
||||
* @OA\Property(property="data", type="array", @OA\Items(
|
||||
* @OA\Property(property="user_id", type="integer", example=5),
|
||||
* @OA\Property(property="name", type="string", example="王教練"),
|
||||
* @OA\Property(property="email", type="string", example="coach@example.com"),
|
||||
* @OA\Property(property="business_name", type="string", example="藍海潛水"),
|
||||
* @OA\Property(property="verification_status", type="string", example="pending"),
|
||||
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="certifications", type="array", @OA\Items(
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="url", type="string", example="http://localhost:8080/storage/providers/5/certifications/uuid.jpg")
|
||||
* ))
|
||||
* ))
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function toggleProviderVerified()
|
||||
public function adminVerificationIndex()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 通過教練審核
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/admin/verifications/{userId}/approve",
|
||||
* summary="通過教練審核",
|
||||
* description="將 pending 教練轉為 approved,課程恢復公開曝光並通知教練",
|
||||
* operationId="adminVerificationApprove",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="已通過審核"),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="當前狀態無法通過審核", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function adminVerificationApprove()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 駁回教練審核
|
||||
*
|
||||
* @OA\Put(
|
||||
* path="/admin/verifications/{userId}/reject",
|
||||
* summary="駁回教練審核",
|
||||
* description="駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練",
|
||||
* operationId="adminVerificationReject",
|
||||
* tags={"Admin 教練管理"},
|
||||
* security={{"bearerAuth": {}}},
|
||||
* @OA\Parameter(name="userId", in="path", required=true, description="教練使用者 ID", @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"reason"},
|
||||
* @OA\Property(property="reason", type="string", maxLength=500, example="證照影像不清晰,請重新拍攝上傳")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="已駁回"),
|
||||
* @OA\Response(response=403, description="非 admin 角色", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=404, description="教練不存在", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse")),
|
||||
* @OA\Response(response=422, description="原因未填或狀態不可駁回", @OA\JsonContent(ref="#/components/schemas/ApiErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function adminVerificationReject()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum VerificationStatus: string
|
||||
{
|
||||
case Unsubmitted = 'unsubmitted';
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
public const VALID_TRANSITIONS = [
|
||||
'unsubmitted' => ['pending'],
|
||||
'pending' => ['approved', 'rejected'],
|
||||
'approved' => ['rejected'], // 撤銷驗證,原因必填
|
||||
'rejected' => ['pending'], // 重新送審
|
||||
];
|
||||
|
||||
public function canTransitionTo(self $newStatus): bool
|
||||
{
|
||||
return in_array($newStatus->value, self::VALID_TRANSITIONS[$this->value] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
{
|
||||
@@ -132,23 +131,5 @@ class AdminUserController extends Controller
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||
}
|
||||
|
||||
public function toggleProviderVerified(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'provider');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
$profile = $user->providerProfile;
|
||||
$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]]);
|
||||
}
|
||||
// toggleProviderVerified 已移除:驗證狀態變更一律走 AdminVerificationController 的審核狀態機
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\VerificationStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ProviderVerificationApprovedNotification;
|
||||
use App\Notifications\ProviderVerificationRejectedNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AdminVerificationController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$status = $request->query('status', VerificationStatus::Pending->value);
|
||||
|
||||
$query = User::where('role', 'provider')
|
||||
->with(['providerProfile', 'providerCertifications'])
|
||||
->whereHas('providerProfile');
|
||||
|
||||
if ($status !== 'all') {
|
||||
$query->whereHas('providerProfile', fn($q) => $q->where('verification_status', $status));
|
||||
}
|
||||
|
||||
$providers = $query->orderByDesc('updated_at')->get()->map(fn($u) => [
|
||||
'user_id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'email' => $u->email,
|
||||
'business_name' => $u->providerProfile->business_name,
|
||||
'verification_status' => $u->providerProfile->verification_status->value,
|
||||
'rejection_reason' => $u->providerProfile->rejection_reason,
|
||||
'certifications' => $u->providerCertifications->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $providers]);
|
||||
}
|
||||
|
||||
public function approve(Request $request, int $userId)
|
||||
{
|
||||
$profile = $this->findProviderProfile($userId);
|
||||
|
||||
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Approved)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法通過審核'], 422);
|
||||
}
|
||||
|
||||
$profile->update(['verification_status' => VerificationStatus::Approved, 'rejection_reason' => null]);
|
||||
|
||||
// 驗證狀態影響公開課程可見性,需立即讓快取失效
|
||||
Cache::tags(['diving_offers'])->flush();
|
||||
|
||||
$this->notify($profile->user, new ProviderVerificationApprovedNotification());
|
||||
|
||||
return response()->json(['status' => true, 'message' => '教練已通過審核']);
|
||||
}
|
||||
|
||||
public function reject(Request $request, int $userId)
|
||||
{
|
||||
$data = $request->validate(['reason' => 'required|string|max:500']);
|
||||
|
||||
$profile = $this->findProviderProfile($userId);
|
||||
|
||||
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Rejected)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法駁回'], 422);
|
||||
}
|
||||
|
||||
$profile->update([
|
||||
'verification_status' => VerificationStatus::Rejected,
|
||||
'rejection_reason' => $data['reason'],
|
||||
]);
|
||||
|
||||
Cache::tags(['diving_offers'])->flush();
|
||||
|
||||
$this->notify($profile->user, new ProviderVerificationRejectedNotification($data['reason']));
|
||||
|
||||
return response()->json(['status' => true, 'message' => '已駁回,教練將收到原因通知']);
|
||||
}
|
||||
|
||||
private function findProviderProfile(int $userId)
|
||||
{
|
||||
return User::where('role', 'provider')->findOrFail($userId)->providerProfile()->firstOrFail();
|
||||
}
|
||||
|
||||
private function notify(User $provider, object $notification): void
|
||||
{
|
||||
try {
|
||||
$provider->notify($notification);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ProviderVerification notification failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,10 @@ class MemberBookingController extends Controller
|
||||
$schedule = CourseSchedule::with('divingOffer.provider.providerProfile')->findOrFail($data['schedule_id']);
|
||||
|
||||
// Layer 1:快速失敗
|
||||
// 可見性繞過防護:課程屬未驗證教練時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
|
||||
// 可見性繞過防護:課程教練未通過審核時不可建立新預約(既有預約不受影響,見 provider-verification 規格)
|
||||
$offer = $schedule->divingOffer;
|
||||
if ($offer->provider_id !== null && !($offer->provider?->providerProfile?->is_verified)) {
|
||||
// is_verified 為 accessor(= verification_status === approved)
|
||||
return response()->json(['status' => false, 'message' => '此課程目前不開放預約'], 422);
|
||||
}
|
||||
if ($schedule->status !== ScheduleStatus::Open) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\VerificationStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ProviderCertification;
|
||||
use App\Traits\CompressesImages;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProviderVerificationController extends Controller
|
||||
{
|
||||
use CompressesImages;
|
||||
|
||||
private const MAX_CERTIFICATIONS = 3;
|
||||
|
||||
public function show(Request $request)
|
||||
{
|
||||
$profile = $request->user()->providerProfile;
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => [
|
||||
'verification_status' => $profile->verification_status->value,
|
||||
'rejection_reason' => $profile->rejection_reason,
|
||||
'certifications' => $request->user()->providerCertifications
|
||||
->map(fn($c) => ['id' => $c->id, 'url' => $c->url])->values(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadCertification(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
|
||||
]);
|
||||
|
||||
if ($err = $this->ensureCertificationsEditable($request)) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->providerCertifications()->count() >= self::MAX_CERTIFICATIONS) {
|
||||
return response()->json(['status' => false, 'message' => '證照最多 3 張'], 422);
|
||||
}
|
||||
|
||||
$path = $this->compressToJpeg($request->file('image'), "providers/{$user->id}/certifications");
|
||||
|
||||
$certification = ProviderCertification::create([
|
||||
'user_id' => $user->id,
|
||||
'image_path' => $path,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '證照已上傳',
|
||||
'data' => ['id' => $certification->id, 'url' => $certification->url],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function deleteCertification(Request $request, int $id)
|
||||
{
|
||||
$certification = ProviderCertification::findOrFail($id);
|
||||
|
||||
if ($certification->user_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權刪除此證照'], 403);
|
||||
}
|
||||
|
||||
if ($err = $this->ensureCertificationsEditable($request)) {
|
||||
return $err;
|
||||
}
|
||||
|
||||
$certification->delete();
|
||||
|
||||
return response()->json(['status' => true, 'message' => '證照已刪除']);
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$profile = $user->providerProfile;
|
||||
|
||||
if (!$profile->verification_status->canTransitionTo(VerificationStatus::Pending)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法送審'], 422);
|
||||
}
|
||||
|
||||
if ($user->providerCertifications()->count() < 1) {
|
||||
return response()->json(['status' => false, 'message' => '請先上傳至少 1 張證照'], 422);
|
||||
}
|
||||
|
||||
$profile->update([
|
||||
'verification_status' => VerificationStatus::Pending,
|
||||
'rejection_reason' => null,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'message' => '已送出審核,請等待平台審核結果']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 證照僅於 unsubmitted / rejected 可增刪:pending / approved 是審核依據,不可變動
|
||||
*/
|
||||
private function ensureCertificationsEditable(Request $request)
|
||||
{
|
||||
$status = $request->user()->providerProfile->verification_status;
|
||||
|
||||
if (!in_array($status, [VerificationStatus::Unsubmitted, VerificationStatus::Rejected])) {
|
||||
return response()->json(['status' => false, 'message' => '審核期間或通過後不可變更證照'], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -55,14 +55,14 @@ class DivingOffer extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* 公開端點可見性:未驗證教練的課程不對外曝光。
|
||||
* 公開端點可見性:未通過審核(approved)教練的課程不對外曝光。
|
||||
* 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));
|
||||
->orWhereHas('provider.providerProfile', fn (Builder $profile) => $profile->where('verification_status', \App\Enums\VerificationStatus::Approved->value));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ProviderCertification extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'image_path',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function ($certification) {
|
||||
Storage::disk('public')->delete($certification->image_path);
|
||||
});
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return Storage::disk('public')->url($this->image_path);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\VerificationStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,9 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @OA\Property(property="certifications", type="string", example="PADI五星級潛水中心,SSI認證中心", description="業者相關認證"),
|
||||
* @OA\Property(property="facilities", type="string", example="空氣填充站,沖洗區,更衣室,休息區", description="設施"),
|
||||
* @OA\Property(property="business_hours", type="string", example="週一至週五 09:00-18:00,週六日 08:00-19:00", description="營業時間"),
|
||||
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證"),
|
||||
* @OA\Property(property="verification_status", type="string", example="approved", description="審核狀態:unsubmitted / pending / approved / rejected"),
|
||||
* @OA\Property(property="rejection_reason", type="string", nullable=true, example=null, description="駁回原因(rejected 時有值)"),
|
||||
* @OA\Property(property="is_verified", type="boolean", example=true, description="是否通過平台驗證(相容欄位,= verification_status 為 approved)"),
|
||||
* @OA\Property(property="rating", type="number", format="float", example=4.8, description="評分"),
|
||||
* @OA\Property(property="website", type="string", example="https://www.bluedive.com", description="官方網站"),
|
||||
* @OA\Property(property="social_media", type="string", example="https://www.facebook.com/bluedive", description="社群媒體連結"),
|
||||
@@ -55,7 +58,8 @@ class ProviderProfile extends Model
|
||||
'certifications',
|
||||
'facilities',
|
||||
'business_hours',
|
||||
'is_verified',
|
||||
'verification_status',
|
||||
'rejection_reason',
|
||||
'rating',
|
||||
'website',
|
||||
'social_media',
|
||||
@@ -64,6 +68,21 @@ class ProviderProfile extends Model
|
||||
'is_active'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'verification_status' => VerificationStatus::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* API 相容層:既有前端與 Swagger 讀取 is_verified boolean,
|
||||
* 欄位移除後以 accessor 維持輸出(= 審核通過)
|
||||
*/
|
||||
protected $appends = ['is_verified'];
|
||||
|
||||
public function getIsVerifiedAttribute(): bool
|
||||
{
|
||||
return $this->verification_status === VerificationStatus::Approved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 與用戶的關聯
|
||||
*/
|
||||
|
||||
@@ -140,6 +140,14 @@ class User extends Authenticatable
|
||||
return $this->hasOne(ProviderProfile::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 教練驗證證照圖片(送審用)
|
||||
*/
|
||||
public function providerCertifications()
|
||||
{
|
||||
return $this->hasMany(ProviderCertification::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取用戶的會員資料
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ProviderVerificationApprovedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'verification_approved',
|
||||
'title' => '審核通過',
|
||||
'body' => '恭喜!你的教練資格已通過平台審核,課程現在會公開曝光並可接受預約。',
|
||||
'action_url' => config('app.frontend_url') . '/coach/dashboard',
|
||||
'related_id' => $notifiable->id,
|
||||
'related_type' => 'provider_verification',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('教練資格審核通過 — CFDivePlatform')
|
||||
->greeting('恭喜!')
|
||||
->line('你的教練資格已通過平台審核。')
|
||||
->line('你的課程現在會公開曝光,並可開始接受會員預約。')
|
||||
->action('前往教練後台', config('app.frontend_url') . '/coach/dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ProviderVerificationRejectedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly string $reason) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'verification_rejected',
|
||||
'title' => '審核未通過',
|
||||
'body' => "你的教練資格審核未通過。原因:{$this->reason}",
|
||||
'action_url' => config('app.frontend_url') . '/coach/verification',
|
||||
'related_id' => $notifiable->id,
|
||||
'related_type' => 'provider_verification',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('教練資格審核結果 — CFDivePlatform')
|
||||
->greeting('審核結果通知')
|
||||
->line('很抱歉,你的教練資格審核未通過。')
|
||||
->line("原因:{$this->reason}")
|
||||
->line('你可以補正資料與證照後重新送審。')
|
||||
->action('重新送審', config('app.frontend_url') . '/coach/verification');
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('provider_profiles', function (Blueprint $table) {
|
||||
$table->string('verification_status', 20)->default('unsubmitted')->after('is_verified')
|
||||
->comment('教練驗證狀態:unsubmitted / pending / approved / rejected');
|
||||
$table->text('rejection_reason')->nullable()->after('verification_status');
|
||||
});
|
||||
|
||||
// 既有資料轉換:已驗證視為審核通過,未驗證回到未送審
|
||||
DB::table('provider_profiles')->where('is_verified', true)->update(['verification_status' => 'approved']);
|
||||
DB::table('provider_profiles')->where('is_verified', false)->update(['verification_status' => 'unsubmitted']);
|
||||
|
||||
Schema::table('provider_profiles', function (Blueprint $table) {
|
||||
$table->dropColumn('is_verified');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('provider_profiles', function (Blueprint $table) {
|
||||
$table->boolean('is_verified')->default(false);
|
||||
});
|
||||
|
||||
DB::table('provider_profiles')->where('verification_status', 'approved')->update(['is_verified' => true]);
|
||||
|
||||
Schema::table('provider_profiles', function (Blueprint $table) {
|
||||
$table->dropColumn(['verification_status', 'rejection_reason']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('provider_certifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete()->comment('教練 User id');
|
||||
$table->string('image_path')->comment('證照圖片相對路徑(public disk)');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('provider_certifications');
|
||||
}
|
||||
};
|
||||
@@ -65,7 +65,7 @@ class DemoSeeder extends Seeder
|
||||
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
|
||||
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
|
||||
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
|
||||
'business_hours' => '每日 07:30–18:00', 'is_verified' => true,
|
||||
'business_hours' => '每日 07:30–18:00', 'verification_status' => 'approved',
|
||||
],
|
||||
[
|
||||
'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
|
||||
@@ -77,7 +77,7 @@ class DemoSeeder extends Seeder
|
||||
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
|
||||
'certifications' => 'PADI 潛水中心,SSI 認證教練',
|
||||
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
|
||||
'business_hours' => '每日 07:00–17:30', 'is_verified' => true,
|
||||
'business_hours' => '每日 07:00–17:30', 'verification_status' => 'approved',
|
||||
],
|
||||
[
|
||||
'email' => 'islet@cfdive.com', 'name' => '張大偉',
|
||||
@@ -89,7 +89,7 @@ class DemoSeeder extends Seeder
|
||||
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
|
||||
'certifications' => 'PADI 授權潛水中心',
|
||||
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
|
||||
'business_hours' => '每日 08:00–17:00', 'is_verified' => true,
|
||||
'business_hours' => '每日 08:00–17:00', 'verification_status' => 'approved',
|
||||
],
|
||||
[
|
||||
'email' => 'northdive@cfdive.com', 'name' => '王建國',
|
||||
@@ -101,7 +101,7 @@ class DemoSeeder extends Seeder
|
||||
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
|
||||
'certifications' => 'PADI 課程總監,PADI TecRec 教練',
|
||||
'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修',
|
||||
'business_hours' => '週一公休,週二至週日 08:00–18:00', 'is_verified' => false,
|
||||
'business_hours' => '週一公休,週二至週日 08:00–18:00', 'verification_status' => 'pending',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -123,7 +123,7 @@ class DemoSeeder extends Seeder
|
||||
'certifications' => $data['certifications'],
|
||||
'facilities' => $data['facilities'],
|
||||
'business_hours' => $data['business_hours'],
|
||||
'is_verified' => $data['is_verified'],
|
||||
'verification_status' => $data['verification_status'],
|
||||
'rating' => 0,
|
||||
]);
|
||||
$providers[] = $user;
|
||||
|
||||
@@ -35,7 +35,7 @@ class DevelopmentSeeder extends Seeder
|
||||
'description' => '專業 PADI 認證教練,10 年教學經驗',
|
||||
'contact_phone' => '0912345678',
|
||||
'contact_email' => 'coach@cfdive.com',
|
||||
'is_verified' => true,
|
||||
'verification_status' => 'approved',
|
||||
]);
|
||||
|
||||
// Member
|
||||
|
||||
@@ -23,6 +23,7 @@ async function handleLogout() {
|
||||
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
|
||||
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
|
||||
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
|
||||
<RouterLink to="/coach/verification" class="text-sm hover:text-gray-300 transition">驗證申請</RouterLink>
|
||||
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const routes = [
|
||||
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
|
||||
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
|
||||
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
|
||||
{ path: 'verification', component: () => import('../views/coach/VerificationView.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@ import adminApi from '../../api/adminAxios'
|
||||
const providers = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const pendingOnly = ref(false)
|
||||
const certsOf = ref(null) // { name, certifications } 查看證照面板
|
||||
const rejectTarget = ref(null) // 駁回原因輸入面板的對象
|
||||
const rejectReason = ref('')
|
||||
|
||||
const STATUS_META = {
|
||||
unsubmitted: { label: '未送審', cls: 'bg-slate-100 text-slate-500' },
|
||||
pending: { label: '待審核', cls: 'bg-amber-100 text-amber-700' },
|
||||
approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700' },
|
||||
rejected: { label: '已駁回', cls: 'bg-rose-100 text-rose-600' },
|
||||
}
|
||||
|
||||
function statusMeta(p) {
|
||||
return STATUS_META[p.provider_profile?.verification_status] ?? STATUS_META.unsubmitted
|
||||
}
|
||||
|
||||
async function fetchProviders() {
|
||||
loading.value = true
|
||||
@@ -12,12 +27,19 @@ async function fetchProviders() {
|
||||
const params = {}
|
||||
if (search.value) params.q = search.value
|
||||
const res = await adminApi.get('/admin/providers', { params })
|
||||
providers.value = res.data.data
|
||||
providers.value = pendingOnly.value
|
||||
? res.data.data.filter(p => p.provider_profile?.verification_status === 'pending')
|
||||
: res.data.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function togglePendingFilter() {
|
||||
pendingOnly.value = !pendingOnly.value
|
||||
fetchProviders()
|
||||
}
|
||||
|
||||
async function toggleActive(p) {
|
||||
try {
|
||||
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-active`)
|
||||
@@ -25,10 +47,32 @@ async function toggleActive(p) {
|
||||
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
|
||||
}
|
||||
|
||||
async function toggleVerified(p) {
|
||||
async function viewCertifications(p) {
|
||||
try {
|
||||
const res = await adminApi.put(`/admin/providers/${p.id}/toggle-verified`)
|
||||
if (p.provider_profile) p.provider_profile.is_verified = res.data.data.is_verified
|
||||
const res = await adminApi.get('/admin/verifications', { params: { status: 'all' } })
|
||||
const row = res.data.data.find(v => v.user_id === p.id)
|
||||
certsOf.value = { name: p.name, certifications: row?.certifications ?? [] }
|
||||
} catch (e) { alert(e.response?.data?.message || '讀取失敗') }
|
||||
}
|
||||
|
||||
async function approve(p) {
|
||||
try {
|
||||
await adminApi.put(`/admin/verifications/${p.id}/approve`)
|
||||
await fetchProviders()
|
||||
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
|
||||
}
|
||||
|
||||
function openReject(p) {
|
||||
rejectTarget.value = p
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectReason.value.trim()) { alert('請填寫駁回原因'); return }
|
||||
try {
|
||||
await adminApi.put(`/admin/verifications/${rejectTarget.value.id}/reject`, { reason: rejectReason.value.trim() })
|
||||
rejectTarget.value = null
|
||||
await fetchProviders()
|
||||
} catch (e) { alert(e.response?.data?.message || '操作失敗') }
|
||||
}
|
||||
|
||||
@@ -44,6 +88,9 @@ onMounted(fetchProviders)
|
||||
class="flex-1 border border-slate-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" />
|
||||
<button @click="fetchProviders"
|
||||
class="bg-slate-800 text-white px-5 py-2 rounded-lg text-sm hover:bg-slate-700 transition">搜尋</button>
|
||||
<button @click="togglePendingFilter"
|
||||
:class="pendingOnly ? 'bg-amber-500 text-white' : 'bg-amber-50 text-amber-700'"
|
||||
class="px-5 py-2 rounded-lg text-sm hover:opacity-80 transition">待審核</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center text-slate-400 py-20">載入中...</div>
|
||||
@@ -67,9 +114,8 @@ onMounted(fetchProviders)
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-500">{{ p.provider_profile?.business_name || '-' }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span :class="p.provider_profile?.is_verified ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-500'"
|
||||
class="text-xs px-2 py-1 rounded-full font-medium">
|
||||
{{ p.provider_profile?.is_verified ? '已驗證' : '未驗證' }}
|
||||
<span :class="statusMeta(p).cls" class="text-xs px-2 py-1 rounded-full font-medium">
|
||||
{{ statusMeta(p).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
@@ -79,11 +125,18 @@ onMounted(fetchProviders)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<div class="flex justify-center gap-2">
|
||||
<button @click="toggleVerified(p)"
|
||||
:class="p.provider_profile?.is_verified ? 'bg-slate-100 text-slate-600' : 'bg-teal-50 text-teal-700'"
|
||||
class="text-xs px-3 py-1 rounded-lg hover:opacity-80 transition">
|
||||
{{ p.provider_profile?.is_verified ? '取消驗證' : '驗證' }}
|
||||
<div class="flex justify-center gap-2 flex-wrap">
|
||||
<button @click="viewCertifications(p)"
|
||||
class="text-xs px-3 py-1 rounded-lg bg-slate-50 text-slate-600 hover:opacity-80 transition">
|
||||
證照
|
||||
</button>
|
||||
<button v-if="p.provider_profile?.verification_status === 'pending'" @click="approve(p)"
|
||||
class="text-xs px-3 py-1 rounded-lg bg-teal-50 text-teal-700 hover:opacity-80 transition">
|
||||
通過
|
||||
</button>
|
||||
<button v-if="['pending', 'approved'].includes(p.provider_profile?.verification_status)" @click="openReject(p)"
|
||||
class="text-xs px-3 py-1 rounded-lg bg-rose-50 text-rose-600 hover:opacity-80 transition">
|
||||
駁回
|
||||
</button>
|
||||
<button @click="toggleActive(p)"
|
||||
:class="p.is_active ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
|
||||
@@ -99,5 +152,34 @@ onMounted(fetchProviders)
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 證照檢視面板 -->
|
||||
<div v-if="certsOf" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="certsOf = null">
|
||||
<div class="bg-white rounded-2xl p-6 max-w-lg w-full mx-4">
|
||||
<h2 class="font-semibold text-slate-800 mb-4">{{ certsOf.name }} 的證照</h2>
|
||||
<p v-if="certsOf.certifications.length === 0" class="text-sm text-slate-400 py-6 text-center">尚未上傳證照</p>
|
||||
<div v-else class="grid grid-cols-2 gap-3">
|
||||
<a v-for="c in certsOf.certifications" :key="c.id" :href="c.url" target="_blank"
|
||||
class="rounded-xl overflow-hidden border hover:opacity-90 transition">
|
||||
<img :src="c.url" loading="lazy" class="w-full h-36 object-cover" />
|
||||
</a>
|
||||
</div>
|
||||
<button @click="certsOf = null" class="mt-4 w-full bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">關閉</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 駁回原因面板 -->
|
||||
<div v-if="rejectTarget" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50" @click.self="rejectTarget = null">
|
||||
<div class="bg-white rounded-2xl p-6 max-w-md w-full mx-4">
|
||||
<h2 class="font-semibold text-slate-800 mb-1">駁回 {{ rejectTarget.name }}</h2>
|
||||
<p class="text-xs text-slate-400 mb-4">原因將以站內通知與 Email 告知教練</p>
|
||||
<textarea v-model="rejectReason" rows="3" maxlength="500" placeholder="例如:證照影像不清晰,請重新拍攝上傳"
|
||||
class="w-full border border-slate-300 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"></textarea>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button @click="rejectTarget = null" class="flex-1 bg-slate-100 text-slate-600 py-2 rounded-xl text-sm hover:bg-slate-200 transition">取消</button>
|
||||
<button @click="confirmReject" class="flex-1 bg-rose-600 text-white py-2 rounded-xl text-sm hover:bg-rose-700 transition">確認駁回</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import coachApi from '../../api/coachAxios'
|
||||
|
||||
const status = ref('unsubmitted')
|
||||
const reason = ref(null)
|
||||
const certs = ref([])
|
||||
const loading = ref(true)
|
||||
const uploading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const STATUS_META = {
|
||||
unsubmitted: { label: '尚未送審', cls: 'bg-slate-100 text-slate-600', hint: '上傳教練證照後即可送出審核。通過審核前,你的課程不會公開曝光。' },
|
||||
pending: { label: '審核中', cls: 'bg-amber-100 text-amber-700', hint: '已送出審核,平台將盡快處理。審核期間證照不可變更。' },
|
||||
approved: { label: '已通過', cls: 'bg-teal-100 text-teal-700', hint: '你的教練資格已通過審核,課程已公開曝光並可接受預約。' },
|
||||
rejected: { label: '未通過', cls: 'bg-rose-100 text-rose-700', hint: '請依駁回原因補正證照後重新送審。' },
|
||||
}
|
||||
|
||||
const meta = computed(() => STATUS_META[status.value] ?? STATUS_META.unsubmitted)
|
||||
const canEdit = computed(() => ['unsubmitted', 'rejected'].includes(status.value))
|
||||
const canSubmit = computed(() => canEdit.value && certs.value.length > 0)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await coachApi.get('/provider/verification')
|
||||
status.value = res.data.data.verification_status
|
||||
reason.value = res.data.data.rejection_reason
|
||||
certs.value = res.data.data.certifications
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCert(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
errorMessage.value = ''
|
||||
uploading.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
await coachApi.post('/provider/verification/certifications', form)
|
||||
await load()
|
||||
} catch (e) {
|
||||
errorMessage.value = e.response?.data?.message || '上傳失敗'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCert(id) {
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await coachApi.delete(`/provider/verification/certifications/${id}`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
errorMessage.value = e.response?.data?.message || '刪除失敗'
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
submitting.value = true
|
||||
try {
|
||||
await coachApi.post('/provider/verification/submit')
|
||||
await load()
|
||||
} catch (e) {
|
||||
errorMessage.value = e.response?.data?.message || '送審失敗'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">驗證申請</h1>
|
||||
<p class="text-gray-500 text-sm mb-6">上傳教練證照並送審,通過後課程才會公開曝光。</p>
|
||||
|
||||
<div v-if="loading" class="text-gray-400">載入中…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 狀態卡 -->
|
||||
<div class="bg-white rounded-2xl shadow p-6 mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="text-sm font-medium text-gray-600">目前狀態</span>
|
||||
<span :class="meta.cls" class="px-3 py-1 rounded-full text-sm font-medium">{{ meta.label }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">{{ meta.hint }}</p>
|
||||
<div v-if="status === 'rejected' && reason" class="mt-3 bg-rose-50 border border-rose-200 rounded-xl p-3 text-sm text-rose-700">
|
||||
駁回原因:{{ reason }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 證照管理 -->
|
||||
<div class="bg-white rounded-2xl shadow p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="font-semibold text-gray-700">教練證照({{ certs.length }}/3)</h2>
|
||||
<label v-if="canEdit && certs.length < 3"
|
||||
class="text-sm bg-ocean-600 hover:bg-ocean-700 text-white px-4 py-2 rounded-xl cursor-pointer transition"
|
||||
:class="{ 'opacity-50 pointer-events-none': uploading }">
|
||||
{{ uploading ? '上傳中…' : '+ 上傳證照' }}
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="uploadCert" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="certs.length === 0" class="text-sm text-gray-400 py-6 text-center">尚未上傳任何證照</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div v-for="cert in certs" :key="cert.id" class="relative group rounded-xl overflow-hidden border">
|
||||
<a :href="cert.url" target="_blank">
|
||||
<img :src="cert.url" loading="lazy" class="w-full h-32 object-cover" />
|
||||
</a>
|
||||
<button v-if="canEdit"
|
||||
@click="removeCert(cert.id)"
|
||||
class="absolute top-1 right-1 bg-black/60 text-white text-xs px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 transition">
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="text-sm text-rose-600 mb-4">{{ errorMessage }}</p>
|
||||
|
||||
<button v-if="canEdit"
|
||||
@click="submit"
|
||||
:disabled="!canSubmit || submitting"
|
||||
class="w-full bg-ocean-600 hover:bg-ocean-700 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium py-3 rounded-xl transition">
|
||||
{{ submitting ? '送出中…' : (status === 'rejected' ? '重新送出審核' : '送出審核') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-11
|
||||
@@ -0,0 +1,57 @@
|
||||
# Design — provider-verification-workflow
|
||||
|
||||
## D1:四狀態而非三狀態
|
||||
|
||||
路線圖原列 pending/approved/rejected 三態,但「註冊後補件送審」決策(Hank 2026-06-11 拍板)使「註冊完還沒送審」與「已送審待審」必須區分——Admin 佇列只該出現後者。故:
|
||||
|
||||
```
|
||||
unsubmitted ──submit──> pending ──approve──> approved
|
||||
↑ │ │
|
||||
│ reject reject(撤銷,原因必填)
|
||||
│ ↓ ↓
|
||||
└──(重新上傳)── rejected ──submit──> pending
|
||||
```
|
||||
|
||||
- 合法轉移:`unsubmitted→pending`、`pending→approved|rejected`、`rejected→pending`、`approved→rejected`(撤銷)
|
||||
- `rejected→pending` 重送時清空 `rejection_reason`
|
||||
- 欄位用 string + 應用層常數(與 BookingStatus enum 同模式,建 `App\Enums\VerificationStatus`)
|
||||
|
||||
## D2:取代而非並存 boolean
|
||||
|
||||
`is_verified` 欄位**移除**(migration: true→approved、false→unsubmitted),不留同步欄位——兩個真實來源必然漂移(Rule 7)。API 相容由 `ProviderProfile::$appends` 的 `is_verified` accessor 提供(`= status === approved`),前端/Swagger 既有讀取點不破壞;查詢端(`visibleToPublic`、預約入口)改查 `verification_status`。
|
||||
|
||||
## D3:證照獨立資料表
|
||||
|
||||
`provider_certifications`(id, user_id, image_path, created_at)。不塞 JSON 欄位:要逐張刪除、要 FK 清理、上限 3 張需 count 查詢。圖片複用 `CompressesImages::compressToJpeg`(O3.1 的 trait,第二個使用者,驗證抽共用的價值),存 `providers/{user_id}/certifications/`。刪除政策:`unsubmitted`/`rejected` 可增刪;`pending`/`approved` 鎖定(審核依據不可變動)。
|
||||
|
||||
## D4:端點設計
|
||||
|
||||
**Provider(auth:sanctum + provider prefix)**
|
||||
- `GET /api/provider/verification`——status、rejection_reason、certifications[]
|
||||
- `POST /api/provider/verification/certifications`——上傳(≤3 張、≤10MB、壓縮);僅 unsubmitted/rejected
|
||||
- `DELETE /api/provider/verification/certifications/{id}`——僅 unsubmitted/rejected、僅本人
|
||||
- `POST /api/provider/verification/submit`——至少 1 張證照;unsubmitted/rejected → pending
|
||||
|
||||
**Admin(auth:sanctum + admin)**
|
||||
- `GET /api/admin/verifications?status=pending`——佇列(預設 pending,可查全部狀態),含教練資料與證照 URL
|
||||
- `PUT /api/admin/verifications/{userId}/approve`——pending → approved
|
||||
- `PUT /api/admin/verifications/{userId}/reject`——pending/approved → rejected,`reason` 必填(max 500)
|
||||
|
||||
approve/reject 皆 flush `diving_offers` 快取 tag(可見性立即生效,沿用 toggle 的既有做法)。`toggle-verified` 端點與 `AdminUserController::toggleProviderVerified` 移除——保留會提供繞過狀態機的後門。
|
||||
|
||||
控制器:新建 `ProviderVerificationController` 與 `AdminVerificationController`(單一職責),不塞進 AuthController/AdminUserController。
|
||||
|
||||
## D5:通知
|
||||
|
||||
`ProviderVerificationApprovedNotification`、`ProviderVerificationRejectedNotification`(含原因),照 Booking* 模式(database + mail channel,try/catch 包裹不阻斷主流程)。送審時**不**通知 Admin(Admin 有佇列頁;避免通知氾濫),列為未來選項。
|
||||
|
||||
## D6:前端
|
||||
|
||||
- **教練端** `views/coach/VerificationView.vue`(route `/coach/verification`):狀態卡(四態 + 駁回原因)、證照上傳/刪除(沿用 OfferFormView 的圖片上傳模式)、送審按鈕;CoachLayout 側欄加入口與狀態 badge
|
||||
- **Admin 端** `views/admin/ProvidersView.vue` 擴充:狀態 badge 四態化、「待審核」filter、展開查看證照圖、通過/駁回操作(駁回原因用 inline 輸入,沿用該頁既有操作風格);不另開新頁(教練列表即審核入口,避免兩頁資料重複)
|
||||
|
||||
## D7:Demo 資料與測試遷移
|
||||
|
||||
- DemoSeeder:3 位已驗證 → `approved`;1 位未驗證 → 給 1 張證照 + `pending`(展示審核佇列)
|
||||
- 既有測試 helper(DivingOfferVisibility / BookingLifecycle / BookingOversell / ReviewTest):`is_verified => true/false` 改 `verification_status => approved/unsubmitted`
|
||||
- 新測試:送審狀態機(含非法轉移)、證照上限與鎖定、Admin 審核權限邊界、通知發送、approve/reject 後可見性立即生效
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
目前教練驗證只有一顆 Admin 的 `is_verified` 開關(2026-06-11 修補後具備可見性約束力),但**沒有審核「流程」**:教練無從提交資質證明、Admin 沒有審核依據與佇列、駁回無原因可言、結果無通知。路線圖(`docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1)將此列為金流前的信任基礎建設。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **資料模型**:`provider_profiles.is_verified`(boolean)升級為 `verification_status` 狀態機——`unsubmitted`(註冊預設)→ `pending`(已送審)→ `approved` / `rejected`(含 `rejection_reason`);新增 `provider_certifications` 資料表存證照圖片
|
||||
- **教練端**:後台新增「驗證申請」頁——上傳證照(1~3 張,複用 `CompressesImages` 壓縮管線)、送審、查看駁回原因、重新送審(產品決策:**註冊後補件送審**,不擋註冊流程)
|
||||
- **Admin 端**:審核佇列(pending 優先)、查看證照、通過/駁回(駁回原因必填);移除舊 `toggle-verified` 開關端點(與狀態機衝突)
|
||||
- **通知**:審核通過/駁回通知教練(站內 + Email,複用 notification 管線)
|
||||
- **相容性**:`ProviderProfile` 以 accessor 保留 API 輸出的 `is_verified` 欄位(= status 是否為 approved);`visibleToPublic` scope 與預約入口檢查改查 `verification_status = 'approved'`,語意不變
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `provider-verification`:boolean 語意升級為四狀態機;新增教練送審端點、Admin 審核端點、通知 requirements;可見性規則改以 `approved` 判定(行為等價)
|
||||
- `admin-user-management`:`toggle-verified` 端點移除,由 approve / reject 取代
|
||||
|
||||
## Impact
|
||||
|
||||
- **資料庫**:migration 轉換既有資料(`is_verified=true → approved`、`false → unsubmitted`)、新表 `provider_certifications`
|
||||
- **後端**:新增 `ProviderVerificationController`、`AdminVerificationController`、2 個 Notification class;修改 `DivingOffer::visibleToPublic`、`MemberBookingController`、`AdminUserController`(移除 toggle)、`ProviderProfile`、兩個 Seeder
|
||||
- **前端**:新增 `coach/VerificationView.vue`;改 `admin/ProvidersView.vue`(狀態 badge、審核操作)
|
||||
- **測試**:既有用到 `is_verified` 的 4 個測試檔改用 `verification_status`;新增送審流程與審核權限測試
|
||||
- **行為變更**:Admin 不再能單鍵切換驗證,必須走審核流(撤銷 = 駁回既有 approved 教練並附原因);既有預約不受狀態變動影響(沿用 provider-verification 規格既定政策)
|
||||
@@ -0,0 +1,14 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 管理員切換教練驗證狀態
|
||||
(本 requirement 由 provider-verification 的「Admin 審核佇列與裁決」取代)
|
||||
|
||||
`PUT /api/admin/providers/{id}/toggle-verified` SHALL 移除——單鍵切換允許繞過審核狀態機(無原因駁回、未送審直接通過)。教練驗證狀態的變更一律透過 `PUT /api/admin/verifications/{userId}/approve|reject`。
|
||||
|
||||
#### Scenario: toggle 端點回 404
|
||||
- **WHEN** Admin 請求 `PUT /api/admin/providers/{id}/toggle-verified`
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
#### Scenario: 教練列表仍顯示驗證狀態
|
||||
- **WHEN** Admin 請求 `GET /api/admin/providers`
|
||||
- **THEN** 每筆教練資料包含 `verification_status`(與相容用 `is_verified` accessor)
|
||||
@@ -0,0 +1,84 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 教練驗證狀態機
|
||||
`provider_profiles.verification_status` SHALL 為四狀態字串:`unsubmitted`(註冊預設)、`pending`、`approved`、`rejected`。合法轉移僅限:`unsubmitted→pending`(送審)、`pending→approved`(通過)、`pending→rejected`(駁回)、`rejected→pending`(重新送審,同時清空 `rejection_reason`)、`approved→rejected`(撤銷,原因必填)。原 boolean `is_verified` 欄位移除,API 輸出以 accessor 保留 `is_verified`(= status 為 approved)。
|
||||
|
||||
#### Scenario: 新教練註冊預設未送審
|
||||
- **WHEN** 教練完成註冊
|
||||
- **THEN** `verification_status = unsubmitted`,不出現在 Admin 待審佇列
|
||||
|
||||
#### Scenario: 非法轉移被拒絕
|
||||
- **WHEN** 嘗試對 `unsubmitted` 教練執行 approve,或對 `approved` 教練執行 approve
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 教練上傳證照與送審
|
||||
教練 SHALL 能於後台上傳 1~3 張證照圖片(jpeg/png/webp、≤10MB,伺服器端壓縮為 `.jpg`,存 `providers/{user_id}/certifications/`)並送出審核。證照僅於 `unsubmitted` / `rejected` 狀態可增刪;`pending` / `approved` 狀態鎖定。送審需至少 1 張證照。
|
||||
|
||||
#### Scenario: 上傳證照
|
||||
- **WHEN** `unsubmitted` 教練 `POST /api/provider/verification/certifications` 上傳合法圖片且現有 < 3 張
|
||||
- **THEN** 壓縮儲存並建立 `provider_certifications` 紀錄,回傳圖片資訊
|
||||
|
||||
#### Scenario: 超過 3 張上限
|
||||
- **WHEN** 教練已有 3 張證照再上傳
|
||||
- **THEN** 回傳 HTTP 422
|
||||
|
||||
#### Scenario: 送審成功
|
||||
- **WHEN** 教練至少有 1 張證照,`POST /api/provider/verification/submit`
|
||||
- **THEN** 狀態轉為 `pending`,`rejection_reason` 清空
|
||||
|
||||
#### Scenario: 無證照不可送審
|
||||
- **WHEN** 教練無任何證照即送審
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
#### Scenario: pending 期間證照鎖定
|
||||
- **WHEN** `pending` 或 `approved` 教練嘗試上傳或刪除證照
|
||||
- **THEN** 回傳 HTTP 422
|
||||
|
||||
#### Scenario: 查詢自身驗證狀態
|
||||
- **WHEN** 教練 `GET /api/provider/verification`
|
||||
- **THEN** 回傳 `status`、`rejection_reason`、`certifications[]`(id、url)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 審核佇列與裁決
|
||||
Admin SHALL 能查詢審核佇列(`GET /api/admin/verifications?status=pending`,預設 pending,含教練資料與證照 URL),並對 `pending` 教練執行通過(`PUT /api/admin/verifications/{userId}/approve`)或駁回(`PUT /api/admin/verifications/{userId}/reject`,`reason` 必填、max 500)。Admin SHALL 能駁回 `approved` 教練(撤銷驗證,原因必填)。裁決後 SHALL flush `diving_offers` 快取(可見性立即生效)。原 `PUT /api/admin/providers/{id}/toggle-verified` 端點 SHALL 移除。
|
||||
|
||||
#### Scenario: 通過審核
|
||||
- **WHEN** Admin 對 pending 教練執行 approve
|
||||
- **THEN** 狀態轉 `approved`,教練課程立即恢復公開可見性
|
||||
|
||||
#### Scenario: 駁回需附原因
|
||||
- **WHEN** Admin 執行 reject 未帶 `reason`
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
#### Scenario: 撤銷已通過教練
|
||||
- **WHEN** Admin 對 approved 教練執行 reject 並附原因
|
||||
- **THEN** 狀態轉 `rejected`,課程立即從公開列表消失;既有預約照常(沿用既定政策)
|
||||
|
||||
#### Scenario: 舊 toggle 端點已移除
|
||||
- **WHEN** 任何人請求 `PUT /api/admin/providers/{id}/toggle-verified`
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 審核結果通知
|
||||
系統 SHALL 於 approve / reject 後通知該教練(站內 + Email):通過通知告知課程已可公開曝光;駁回通知包含駁回原因。通知失敗不阻斷審核主流程。
|
||||
|
||||
#### Scenario: 通過通知
|
||||
- **WHEN** Admin approve
|
||||
- **THEN** 教練收到站內通知與 Email,內容含審核通過訊息
|
||||
|
||||
#### Scenario: 駁回通知含原因
|
||||
- **WHEN** Admin reject 並附原因
|
||||
- **THEN** 教練收到的通知內容包含該原因
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 未驗證教練的課程不對公開端點曝光
|
||||
公開課程端點(列表、詳情、schedules、reviews)SHALL 僅曝光 `provider_id` 為 null 或教練 `verification_status = 'approved'` 的課程(原以 `is_verified = true` 判定,語意等價)。`POST /api/member/bookings` 的可預約檢查同步改以 `approved` 判定。其餘行為(404、422、既有預約不受影響)不變。
|
||||
|
||||
#### Scenario: 非 approved 狀態一律不曝光
|
||||
- **WHEN** 課程教練的狀態為 unsubmitted / pending / rejected
|
||||
- **THEN** 公開端點不回傳該課程,新預約被拒
|
||||
@@ -0,0 +1,47 @@
|
||||
## 1. 資料模型
|
||||
|
||||
- [x] 1.1 新增 `App\Enums\VerificationStatus`(Unsubmitted / Pending / Approved / Rejected,含 `VALID_TRANSITIONS` 與 `canTransitionTo()`,仿 BookingStatus 模式)
|
||||
- [x] 1.2 Migration:`provider_profiles` 加 `verification_status`(string, default 'unsubmitted')與 `rejection_reason`(text nullable);資料轉換 `is_verified=true→approved`、`false→unsubmitted`;drop `is_verified`
|
||||
- [x] 1.3 Migration:新表 `provider_certifications`(id, user_id FK cascade, image_path, timestamps)
|
||||
- [x] 1.4 `ProviderProfile`:fillable/casts 更新、`is_verified` accessor(appends,= approved)、`certifications` 由 User 關聯
|
||||
- [x] 1.5 新 Model `ProviderCertification`(belongsTo User、url accessor,仿 CourseImage)
|
||||
|
||||
## 2. 教練端 API
|
||||
|
||||
- [x] 2.1 `ProviderVerificationController`:`show`(status + reason + certifications)、`uploadCertification`(≤3 張、複用 CompressesImages、僅 unsubmitted/rejected)、`deleteCertification`(僅本人 + 僅 unsubmitted/rejected)、`submit`(≥1 張證照、unsubmitted/rejected→pending、清 rejection_reason)
|
||||
- [x] 2.2 routes:`/api/provider/verification*` 四端點(auth:sanctum + provider group)
|
||||
|
||||
## 3. Admin 端 API
|
||||
|
||||
- [x] 3.1 `AdminVerificationController`:`index`(?status= 預設 pending,含教練資料與證照)、`approve`(pending→approved)、`reject`(pending|approved→rejected,reason 必填 max 500);approve/reject flush diving_offers 快取 + 通知教練
|
||||
- [x] 3.2 routes:`/api/admin/verifications*` 三端點;移除 toggle-verified route 與 `AdminUserController::toggleProviderVerified`
|
||||
- [x] 3.3 `AdminUserController::providers` 列表回應含 `verification_status`
|
||||
|
||||
## 4. 可見性判定切換
|
||||
|
||||
- [x] 4.1 `DivingOffer::visibleToPublic`:`is_verified=true` → `verification_status='approved'`
|
||||
- [x] 4.2 `MemberBookingController::store` 可預約檢查同步改判 approved
|
||||
|
||||
## 5. 通知
|
||||
|
||||
- [x] 5.1 `ProviderVerificationApprovedNotification` + `ProviderVerificationRejectedNotification`(含原因;database + mail,仿 Booking* 模式)
|
||||
|
||||
## 6. Seeder
|
||||
|
||||
- [x] 6.1 DemoSeeder / DevelopmentSeeder:`is_verified` 改 `verification_status`;demo 第 4 位教練給 1 張證照 + pending(展示佇列)
|
||||
|
||||
## 7. 前端
|
||||
|
||||
- [x] 7.1 `views/coach/VerificationView.vue` + route `/coach/verification`:狀態卡(四態 + 駁回原因)、證照上傳/刪除、送審;CoachLayout 側欄入口
|
||||
- [x] 7.2 `views/admin/ProvidersView.vue`:badge 四態化、待審 filter、查看證照、通過/駁回(原因輸入);移除 toggle 呼叫
|
||||
|
||||
## 8. 測試
|
||||
|
||||
- [x] 8.1 既有測試遷移:DivingOfferVisibilityTest / BookingLifecycleTest / BookingOversellTest / ReviewTest 的 helper 改 `verification_status`
|
||||
- [x] 8.2 `ProviderVerificationTest`(新):狀態機合法/非法轉移、證照上限/鎖定/本人限定、送審前置條件、重送清空原因
|
||||
- [x] 8.3 `AdminVerificationTest`(新):佇列過濾、approve/reject 權限(非 admin 403)、reject 無原因 422、撤銷 approved、裁決後可見性立即生效(快取)、通知發送斷言、toggle 端點 404
|
||||
- [x] 8.4 容器內全套件綠(基準 155 passed)
|
||||
|
||||
## 9. 規格同步
|
||||
|
||||
- [x] 9.1 specs 增量套用至主規格 `provider-verification` 與 `admin-user-management`
|
||||
@@ -57,13 +57,13 @@
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理員驗證教練
|
||||
後端 SHALL 提供 `PUT /api/admin/providers/{id}/toggle-verified`,反轉 ProviderProfile.is_verified 狀態。
|
||||
### Requirement: 管理員驗證教練(已由審核狀態機取代)
|
||||
`PUT /api/admin/providers/{id}/toggle-verified` 已於 2026-06-12 移除——單鍵切換允許繞過審核狀態機(無原因駁回、未送審直接通過)。教練驗證狀態的變更一律透過 `PUT /api/admin/verifications/{userId}/approve|reject`(見 provider-verification 規格「Admin 審核佇列與裁決」)。
|
||||
|
||||
#### Scenario: 驗證教練
|
||||
- **WHEN** 管理員對 is_verified=false 的教練發送請求
|
||||
- **THEN** 將 is_verified 設為 true,回傳 HTTP 200,`{ status: true, message: "教練已驗證", data: { is_verified: true } }`
|
||||
#### Scenario: toggle 端點回 404
|
||||
- **WHEN** 管理員請求 `PUT /api/admin/providers/{id}/toggle-verified`
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
#### Scenario: 取消驗證教練
|
||||
- **WHEN** 管理員對 is_verified=true 的教練發送請求
|
||||
- **THEN** 將 is_verified 設為 false,回傳 HTTP 200,`{ status: true, message: "已取消驗證", data: { is_verified: false } }`
|
||||
#### Scenario: 教練列表仍顯示驗證狀態
|
||||
- **WHEN** 管理員請求 `GET /api/admin/providers`
|
||||
- **THEN** 每筆教練的 provider_profile 包含 `verification_status` 與相容用 `is_verified`(accessor,= approved)
|
||||
|
||||
@@ -2,77 +2,119 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
定義 `provider_profiles.is_verified` 的最小業務語意:未通過平台驗證的教練,其課程不對公開端點曝光。在完整教練審核流程(證照上傳、審核佇列、駁回原因)實作前,先以此約束堵住「未審核教練可公開曝光」的風險。
|
||||
定義教練資質審核的完整生命週期:教練上傳證照送審、Admin 審核裁決(通過/駁回含原因)、結果通知,以及「未通過審核(approved)之教練的課程不對公開端點曝光、不可接受新預約」的可見性約束。
|
||||
|
||||
## 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。
|
||||
### Requirement: 教練驗證狀態機
|
||||
`provider_profiles.verification_status` SHALL 為四狀態字串:`unsubmitted`(註冊預設)、`pending`、`approved`、`rejected`。合法轉移僅限:`unsubmitted→pending`(送審)、`pending→approved`(通過)、`pending→rejected`(駁回)、`rejected→pending`(重新送審,同時清空 `rejection_reason`)、`approved→rejected`(撤銷,原因必填)。原 boolean `is_verified` 欄位移除,API 輸出以 accessor 保留 `is_verified`(= status 為 approved)。
|
||||
|
||||
#### Scenario: 已驗證教練的課程正常曝光
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers`
|
||||
- **THEN** 已驗證教練(is_verified=true)的課程出現在結果中
|
||||
#### Scenario: 新教練註冊預設未送審
|
||||
- **WHEN** 教練完成註冊
|
||||
- **THEN** `verification_status = unsubmitted`,不出現在 Admin 待審佇列
|
||||
|
||||
#### Scenario: 未驗證教練的課程從列表排除
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers`
|
||||
- **THEN** 未驗證教練(is_verified=false 或無 ProviderProfile)的課程不出現在結果中
|
||||
#### Scenario: 非法轉移被拒絕
|
||||
- **WHEN** 嘗試對 `unsubmitted` 教練執行 approve,或對 `approved` 教練執行 approve
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
#### Scenario: 未驗證教練的課程詳情回 404
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}`,該課程屬於未驗證教練
|
||||
---
|
||||
|
||||
### Requirement: 教練上傳證照與送審
|
||||
教練 SHALL 能於後台上傳 1~3 張證照圖片(jpeg/png/webp、≤10MB,伺服器端壓縮為 `.jpg`,存 `providers/{user_id}/certifications/`)並送出審核。證照僅於 `unsubmitted` / `rejected` 狀態可增刪;`pending` / `approved` 狀態鎖定(審核依據不可變動)。送審需至少 1 張證照。
|
||||
|
||||
#### Scenario: 上傳證照
|
||||
- **WHEN** `unsubmitted` 教練 `POST /api/provider/verification/certifications` 上傳合法圖片且現有 < 3 張
|
||||
- **THEN** 壓縮儲存並建立 `provider_certifications` 紀錄,回傳圖片資訊
|
||||
|
||||
#### Scenario: 超過 3 張上限
|
||||
- **WHEN** 教練已有 3 張證照再上傳
|
||||
- **THEN** 回傳 HTTP 422
|
||||
|
||||
#### Scenario: 送審成功
|
||||
- **WHEN** 教練至少有 1 張證照,`POST /api/provider/verification/submit`
|
||||
- **THEN** 狀態轉為 `pending`,`rejection_reason` 清空
|
||||
|
||||
#### Scenario: 無證照不可送審
|
||||
- **WHEN** 教練無任何證照即送審
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
#### Scenario: pending 期間證照鎖定
|
||||
- **WHEN** `pending` 或 `approved` 教練嘗試上傳或刪除證照
|
||||
- **THEN** 回傳 HTTP 422
|
||||
|
||||
#### Scenario: 查詢自身驗證狀態
|
||||
- **WHEN** 教練 `GET /api/provider/verification`
|
||||
- **THEN** 回傳 `status`、`rejection_reason`、`certifications[]`(id、url)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 審核佇列與裁決
|
||||
Admin SHALL 能查詢審核佇列(`GET /api/admin/verifications?status=pending`,預設 pending,含教練資料與證照 URL),並對 `pending` 教練執行通過(`PUT /api/admin/verifications/{userId}/approve`)或駁回(`PUT /api/admin/verifications/{userId}/reject`,`reason` 必填、max 500)。Admin SHALL 能駁回 `approved` 教練(撤銷驗證,原因必填)。裁決後 SHALL flush `diving_offers` 快取(可見性立即生效)。原 `PUT /api/admin/providers/{id}/toggle-verified` 端點已移除。
|
||||
|
||||
#### Scenario: 通過審核
|
||||
- **WHEN** Admin 對 pending 教練執行 approve
|
||||
- **THEN** 狀態轉 `approved`,教練課程立即恢復公開可見性(不受 180 秒快取影響)
|
||||
|
||||
#### Scenario: 駁回需附原因
|
||||
- **WHEN** Admin 執行 reject 未帶 `reason`
|
||||
- **THEN** 回傳 HTTP 422,狀態不變
|
||||
|
||||
#### Scenario: 撤銷已通過教練
|
||||
- **WHEN** Admin 對 approved 教練執行 reject 並附原因
|
||||
- **THEN** 狀態轉 `rejected`,課程立即從公開列表消失;既有預約照常
|
||||
|
||||
#### Scenario: 舊 toggle 端點已移除
|
||||
- **WHEN** 任何人請求 `PUT /api/admin/providers/{id}/toggle-verified`
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 審核結果通知
|
||||
系統 SHALL 於 approve / reject 後通知該教練(站內 + Email):通過通知告知課程已可公開曝光;駁回通知包含駁回原因。通知失敗不阻斷審核主流程。
|
||||
|
||||
#### Scenario: 通過通知
|
||||
- **WHEN** Admin approve
|
||||
- **THEN** 教練收到站內通知與 Email,內容含審核通過訊息
|
||||
|
||||
#### Scenario: 駁回通知含原因
|
||||
- **WHEN** Admin reject 並附原因
|
||||
- **THEN** 教練收到的通知內容包含該原因
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 未通過審核教練的課程不對公開端點曝光
|
||||
公開課程端點(`GET /api/diving-offers` 列表、`GET /api/diving-offers/{id}` 詳情、`/{id}/schedules`、`/{id}/reviews`)SHALL 僅回傳符合以下任一條件的課程:(a) `provider_id` 為 null(平台自有資料);(b) 課程所屬教練 `verification_status = 'approved'`。其餘狀態(unsubmitted / pending / rejected,或無 ProviderProfile)的課程在列表中排除、詳情與子端點回傳 404。
|
||||
|
||||
#### Scenario: approved 教練的課程正常曝光
|
||||
- **WHEN** 匿名使用者請求公開課程端點
|
||||
- **THEN** approved 教練的課程出現在結果中,詳情/時段/評價端點照常回傳
|
||||
|
||||
#### Scenario: 非 approved 教練的課程不曝光
|
||||
- **WHEN** 課程教練狀態為 unsubmitted / pending / rejected
|
||||
- **THEN** 列表排除該課程,詳情/時段/評價端點回傳 404
|
||||
|
||||
#### Scenario: provider_id 為 null 的課程不受限
|
||||
- **WHEN** 匿名使用者請求公開課程端點,課程的 `provider_id` 為 null
|
||||
- **THEN** 課程正常曝光
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 驗證狀態切換立即生效
|
||||
管理員透過 `PUT /api/admin/providers/{id}/toggle-verified` 切換驗證狀態後,公開課程列表的快取(`diving_offers` cache tag)SHALL 立即失效,下次請求反映最新可見性。
|
||||
### Requirement: 未通過審核教練的課程不可建立新預約
|
||||
`POST /api/member/bookings` SHALL 在建立預約前驗證 schedule 所屬課程可預約(`provider_id` 為 null 或教練 `verification_status = 'approved'`),不符時回傳 HTTP 422,不建立預約。既有預約(pending / confirmed / completed)SHALL 不受教練驗證狀態變動影響:教練仍可處理 pending、confirmed 的聊天與完課流程照常、completed 可正常評價。
|
||||
|
||||
#### Scenario: 取消驗證後課程立即從公開列表消失
|
||||
- **WHEN** 管理員將教練 is_verified 由 true 切為 false
|
||||
- **THEN** 下一次 `GET /api/diving-offers` 請求不包含該教練的課程(不受 180 秒快取影響)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 公開子端點套用相同可見性
|
||||
課程的公開子端點(`GET /api/diving-offers/{id}/schedules`、`GET /api/diving-offers/{id}/reviews`)SHALL 套用與課程詳情相同的可見性規則:課程屬未驗證教練時回傳 HTTP 404。
|
||||
|
||||
#### Scenario: 隱藏課程的時段查詢回 404
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}/schedules`,該課程屬未驗證教練
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
#### Scenario: 隱藏課程的評價查詢回 404
|
||||
- **WHEN** 匿名使用者請求 `GET /api/diving-offers/{id}/reviews`,該課程屬未驗證教練
|
||||
- **THEN** 回傳 HTTP 404
|
||||
|
||||
#### Scenario: 可見課程的子端點正常
|
||||
- **WHEN** 課程屬已驗證教練或 `provider_id` 為 null
|
||||
- **THEN** 時段與評價端點照常回傳資料
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 未驗證教練的課程不可建立新預約
|
||||
`POST /api/member/bookings` SHALL 在建立預約前驗證 schedule 所屬課程可預約(`provider_id` 為 null 或教練 `is_verified = true`),不符時回傳 HTTP 422,不建立預約。既有預約(pending / confirmed / completed)SHALL 不受教練驗證狀態變動影響:教練仍可處理 pending、confirmed 的聊天與完課流程照常、completed 可正常評價。
|
||||
|
||||
#### Scenario: 未驗證教練課程的新預約被拒絕
|
||||
- **WHEN** 會員以未驗證教練課程的 schedule_id 送出預約
|
||||
#### Scenario: 未通過審核教練課程的新預約被拒絕
|
||||
- **WHEN** 會員以非 approved 教練課程的 schedule_id 送出預約
|
||||
- **THEN** 回傳 HTTP 422,`{ status: false, message: "此課程目前不開放預約" }`,不建立 Booking
|
||||
|
||||
#### Scenario: 教練被取消驗證後既有預約照常
|
||||
- **WHEN** 教練在預約 confirmed 之後被取消驗證
|
||||
#### Scenario: 教練被撤銷驗證後既有預約照常
|
||||
- **WHEN** 教練在預約 confirmed 之後被撤銷驗證(approved→rejected)
|
||||
- **THEN** 該預約的聊天、完課、評價流程照常運作;僅新預約被擋
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 教練自有管理端點不受可見性限制
|
||||
Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未驗證教練仍可登入、編輯與管理自己的課程。
|
||||
Provider 對自己課程的管理端點(`/api/provider/offers*`)與 Admin 管理端點(`/api/admin/offers*`)SHALL 不受公開可見性過濾影響,未通過審核的教練仍可登入、編輯與管理自己的課程、上傳證照與送審。
|
||||
|
||||
#### Scenario: 未驗證教練仍可管理自己的課程
|
||||
- **WHEN** 未驗證教練以有效 token 請求 `GET /api/provider/offers`
|
||||
#### Scenario: 未通過審核教練仍可管理自己的課程
|
||||
- **WHEN** 未通過審核教練以有效 token 請求 `GET /api/provider/offers`
|
||||
- **THEN** 回傳該教練的全部課程
|
||||
|
||||
## Notes
|
||||
|
||||
完整審核流程(verification_status enum、證照上傳、審核佇列)見 `docs/analysis/2026-06-11-future-roadmap-feasibility.md` §2.1。
|
||||
|
||||
+9
-1
@@ -84,6 +84,11 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
|
||||
Route::get('/offers/{id}', [ProviderOfferController::class, 'show']);
|
||||
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
|
||||
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']);
|
||||
// 驗證申請(證照送審)
|
||||
Route::get('/verification', [\App\Http\Controllers\API\ProviderVerificationController::class, 'show']);
|
||||
Route::post('/verification/certifications', [\App\Http\Controllers\API\ProviderVerificationController::class, 'uploadCertification']);
|
||||
Route::delete('/verification/certifications/{id}', [\App\Http\Controllers\API\ProviderVerificationController::class, 'deleteCertification']);
|
||||
Route::post('/verification/submit', [\App\Http\Controllers\API\ProviderVerificationController::class, 'submit']);
|
||||
// 課程圖片
|
||||
Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']);
|
||||
Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']);
|
||||
@@ -130,7 +135,10 @@ Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function ()
|
||||
Route::get('/providers', [AdminUserController::class, 'providers']);
|
||||
Route::get('/providers/{id}', [AdminUserController::class, 'provider']);
|
||||
Route::put('/providers/{id}/toggle-active', [AdminUserController::class, 'toggleProviderActive']);
|
||||
Route::put('/providers/{id}/toggle-verified', [AdminUserController::class, 'toggleProviderVerified']);
|
||||
// 教練審核(toggle-verified 已由審核狀態機取代)
|
||||
Route::get('/verifications', [\App\Http\Controllers\API\AdminVerificationController::class, 'index']);
|
||||
Route::put('/verifications/{userId}/approve', [\App\Http\Controllers\API\AdminVerificationController::class, 'approve']);
|
||||
Route::put('/verifications/{userId}/reject', [\App\Http\Controllers\API\AdminVerificationController::class, 'reject']);
|
||||
// 課程管理
|
||||
Route::get('/offers', [AdminOfferController::class, 'index']);
|
||||
Route::delete('/offers/{id}', [AdminOfferController::class, 'destroy']);
|
||||
|
||||
+203
-145
@@ -612,28 +612,29 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/providers/{id}/toggle-verified": {
|
||||
"put": {
|
||||
"/admin/verifications": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin 教練管理"
|
||||
],
|
||||
"summary": "切換教練審核狀態",
|
||||
"description": "通過或撤銷教練審核,回傳新的 is_verified 狀態",
|
||||
"operationId": "toggleProviderVerified",
|
||||
"summary": "教練審核佇列",
|
||||
"description": "查詢教練驗證申請(預設僅 pending;status=all 可查全部),含證照圖片 URL",
|
||||
"operationId": "adminVerificationIndex",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "使用者 ID",
|
||||
"required": true,
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"description": "unsubmitted / pending / approved / rejected / all",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
"type": "string",
|
||||
"default": "pending"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "切換成功",
|
||||
"description": "查詢成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -642,13 +643,54 @@
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "教練已通過審核"
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"properties": {
|
||||
"user_id": {
|
||||
"type": "integer",
|
||||
"example": 5
|
||||
},
|
||||
"is_verified": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "王教練"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "coach@example.com"
|
||||
},
|
||||
"business_name": {
|
||||
"type": "string",
|
||||
"example": "藍海潛水"
|
||||
},
|
||||
"verification_status": {
|
||||
"type": "string",
|
||||
"example": "pending"
|
||||
},
|
||||
"rejection_reason": {
|
||||
"type": "string",
|
||||
"example": null,
|
||||
"nullable": true
|
||||
},
|
||||
"certifications": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"example": "http://localhost:8080/storage/providers/5/certifications/uuid.jpg"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -656,6 +698,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "非 admin 角色",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/verifications/{userId}/approve": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin 教練管理"
|
||||
],
|
||||
"summary": "通過教練審核",
|
||||
"description": "將 pending 教練轉為 approved,課程恢復公開曝光並通知教練",
|
||||
"operationId": "adminVerificationApprove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "path",
|
||||
"description": "教練使用者 ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "已通過審核"
|
||||
},
|
||||
"403": {
|
||||
"description": "非 admin 角色",
|
||||
"content": {
|
||||
@@ -675,6 +758,97 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "當前狀態無法通過審核",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/verifications/{userId}/reject": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin 教練管理"
|
||||
],
|
||||
"summary": "駁回教練審核",
|
||||
"description": "駁回 pending 教練或撤銷 approved 教練,原因必填並通知教練",
|
||||
"operationId": "adminVerificationReject",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "path",
|
||||
"description": "教練使用者 ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"example": "證照影像不清晰,請重新拍攝上傳"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "已駁回"
|
||||
},
|
||||
"403": {
|
||||
"description": "非 admin 角色",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "教練不存在",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "原因未填或狀態不可駁回",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -3021,133 +3195,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/register": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"管理員"
|
||||
],
|
||||
"summary": "管理員註冊",
|
||||
"description": "建立新的管理員帳號",
|
||||
"operationId": "registerAdmin",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"name",
|
||||
"email",
|
||||
"password",
|
||||
"password_confirmation"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "使用者姓名",
|
||||
"type": "string",
|
||||
"example": "張管理"
|
||||
},
|
||||
"email": {
|
||||
"description": "電子郵件",
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"example": "admin@example.com"
|
||||
},
|
||||
"password": {
|
||||
"description": "密碼",
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"example": "password123"
|
||||
},
|
||||
"password_confirmation": {
|
||||
"description": "確認密碼",
|
||||
"type": "string",
|
||||
"format": "password",
|
||||
"example": "password123"
|
||||
},
|
||||
"phone": {
|
||||
"description": "電話號碼",
|
||||
"type": "string",
|
||||
"example": "0912345678"
|
||||
},
|
||||
"position": {
|
||||
"description": "職位",
|
||||
"type": "string",
|
||||
"example": "系統管理員"
|
||||
},
|
||||
"department": {
|
||||
"description": "部門",
|
||||
"type": "string",
|
||||
"example": "IT部門"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "管理員註冊成功",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "管理員註冊成功"
|
||||
},
|
||||
"data": {
|
||||
"properties": {
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/User"
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"example": "1|abcdef1234567890"
|
||||
},
|
||||
"token_type": {
|
||||
"type": "string",
|
||||
"example": "Bearer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "驗證失敗",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "驗證失敗"
|
||||
},
|
||||
"errors": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -6993,8 +7040,19 @@
|
||||
"type": "string",
|
||||
"example": "週一至週五 09:00-18:00,週六日 08:00-19:00"
|
||||
},
|
||||
"verification_status": {
|
||||
"description": "審核狀態:unsubmitted / pending / approved / rejected",
|
||||
"type": "string",
|
||||
"example": "approved"
|
||||
},
|
||||
"rejection_reason": {
|
||||
"description": "駁回原因(rejected 時有值)",
|
||||
"type": "string",
|
||||
"example": null,
|
||||
"nullable": true
|
||||
},
|
||||
"is_verified": {
|
||||
"description": "是否通過平台驗證",
|
||||
"description": "是否通過平台驗證(相容欄位,= verification_status 為 approved)",
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ProviderVerificationApprovedNotification;
|
||||
use App\Notifications\ProviderVerificationRejectedNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Admin 審核裁決(provider-verification / admin-user-management 規格)。
|
||||
*
|
||||
* 審核是平台對教練資質的把關承諾:駁回必須有原因(教練的申訴與
|
||||
* 補正依據)、裁決必須走狀態機(不可對未送審者直接通過)、
|
||||
* 舊 toggle 後門必須保持關閉。
|
||||
*/
|
||||
class AdminVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Notification::fake();
|
||||
}
|
||||
|
||||
private function makeAdmin(): User
|
||||
{
|
||||
return User::factory()->create(['role' => 'admin']);
|
||||
}
|
||||
|
||||
private function makeProvider(string $status): User
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $status]);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
// ── 佇列 ─────────────────────────────────────────────────
|
||||
|
||||
public function test_queue_defaults_to_pending_only(): void
|
||||
{
|
||||
$pending = $this->makeProvider('pending');
|
||||
$this->makeProvider('unsubmitted');
|
||||
$this->makeProvider('approved');
|
||||
|
||||
$response = $this->actingAs($this->makeAdmin())->getJson('/api/admin/verifications');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data'), 'user_id');
|
||||
$this->assertSame([$pending->id], $ids);
|
||||
}
|
||||
|
||||
public function test_queue_can_filter_all_statuses(): void
|
||||
{
|
||||
$this->makeProvider('pending');
|
||||
$this->makeProvider('approved');
|
||||
|
||||
$response = $this->actingAs($this->makeAdmin())->getJson('/api/admin/verifications?status=all');
|
||||
|
||||
$this->assertCount(2, $response->json('data'));
|
||||
}
|
||||
|
||||
// ── 裁決狀態機 ───────────────────────────────────────────
|
||||
|
||||
public function test_approve_pending_provider_and_notify(): void
|
||||
{
|
||||
$provider = $this->makeProvider('pending');
|
||||
|
||||
$this->actingAs($this->makeAdmin())
|
||||
->putJson("/api/admin/verifications/{$provider->id}/approve")
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame('approved', $provider->providerProfile->fresh()->verification_status->value);
|
||||
Notification::assertSentTo($provider, ProviderVerificationApprovedNotification::class);
|
||||
}
|
||||
|
||||
public function test_reject_requires_reason(): void
|
||||
{
|
||||
$provider = $this->makeProvider('pending');
|
||||
|
||||
$this->actingAs($this->makeAdmin())
|
||||
->putJson("/api/admin/verifications/{$provider->id}/reject")
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertSame('pending', $provider->providerProfile->fresh()->verification_status->value);
|
||||
}
|
||||
|
||||
public function test_reject_pending_provider_stores_reason_and_notifies(): void
|
||||
{
|
||||
$provider = $this->makeProvider('pending');
|
||||
|
||||
$this->actingAs($this->makeAdmin())
|
||||
->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '證照已過期'])
|
||||
->assertOk();
|
||||
|
||||
$profile = $provider->providerProfile->fresh();
|
||||
$this->assertSame('rejected', $profile->verification_status->value);
|
||||
$this->assertSame('證照已過期', $profile->rejection_reason);
|
||||
Notification::assertSentTo($provider, ProviderVerificationRejectedNotification::class);
|
||||
}
|
||||
|
||||
public function test_cannot_approve_unsubmitted_provider(): void
|
||||
{
|
||||
$provider = $this->makeProvider('unsubmitted');
|
||||
|
||||
$this->actingAs($this->makeAdmin())
|
||||
->putJson("/api/admin/verifications/{$provider->id}/approve")
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_can_revoke_approved_provider_with_reason(): void
|
||||
{
|
||||
$provider = $this->makeProvider('approved');
|
||||
|
||||
$this->actingAs($this->makeAdmin())
|
||||
->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '收到檢舉,資料不實'])
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame('rejected', $provider->providerProfile->fresh()->verification_status->value);
|
||||
}
|
||||
|
||||
// ── 權限邊界與舊端點 ─────────────────────────────────────
|
||||
|
||||
public function test_non_admin_cannot_access_verification_endpoints(): void
|
||||
{
|
||||
$provider = $this->makeProvider('pending');
|
||||
$member = User::factory()->create(['role' => 'member']);
|
||||
|
||||
$this->actingAs($member)->getJson('/api/admin/verifications')->assertStatus(403);
|
||||
$this->actingAs($member)
|
||||
->putJson("/api/admin/verifications/{$provider->id}/approve")
|
||||
->assertStatus(403);
|
||||
$this->actingAs($provider)
|
||||
->putJson("/api/admin/verifications/{$provider->id}/approve")
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_legacy_toggle_verified_endpoint_is_removed(): void
|
||||
{
|
||||
$provider = $this->makeProvider('approved');
|
||||
|
||||
$this->actingAs($this->makeAdmin())
|
||||
->putJson("/api/admin/providers/{$provider->id}/toggle-verified")
|
||||
->assertStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ class BookingLifecycleTest extends TestCase
|
||||
|
||||
ProviderProfile::create([
|
||||
'user_id' => $provider->id,
|
||||
'is_verified' => $isVerified,
|
||||
'verification_status' => $isVerified ? 'approved' : 'unsubmitted',
|
||||
]);
|
||||
|
||||
return $provider;
|
||||
@@ -171,8 +171,8 @@ class BookingLifecycleTest extends TestCase
|
||||
$member = $this->makeMember();
|
||||
$booking = $this->makeBooking($member, $schedule, BookingStatus::Confirmed);
|
||||
|
||||
// 教練在預約成立後被取消驗證:只擋新預約,不毀既有合約
|
||||
$provider->providerProfile->update(['is_verified' => false]);
|
||||
// 教練在預約成立後被撤銷驗證(approved→rejected):只擋新預約,不毀既有合約
|
||||
$provider->providerProfile->update(['verification_status' => 'rejected', 'rejection_reason' => '測試撤銷']);
|
||||
|
||||
$this->actingAs($member)
|
||||
->getJson("/api/bookings/{$booking->id}/messages")
|
||||
|
||||
@@ -36,7 +36,7 @@ class BookingOversellTest extends TestCase
|
||||
|
||||
ProviderProfile::create([
|
||||
'user_id' => $provider->id,
|
||||
'is_verified' => true,
|
||||
'verification_status' => 'approved',
|
||||
]);
|
||||
|
||||
$offer = DivingOffer::create([
|
||||
|
||||
@@ -24,7 +24,7 @@ class DivingOfferVisibilityTest extends TestCase
|
||||
|
||||
ProviderProfile::create([
|
||||
'user_id' => $provider->id,
|
||||
'is_verified' => $isVerified,
|
||||
'verification_status' => $isVerified ? 'approved' : 'unsubmitted',
|
||||
]);
|
||||
|
||||
return $provider;
|
||||
@@ -127,7 +127,7 @@ class DivingOfferVisibilityTest extends TestCase
|
||||
|
||||
// ── 驗證狀態切換立即生效(快取失效) ─────────────────────
|
||||
|
||||
public function test_toggle_verified_takes_effect_immediately_despite_cache(): void
|
||||
public function test_revoking_verification_takes_effect_immediately_despite_cache(): void
|
||||
{
|
||||
$provider = $this->makeProvider(true);
|
||||
$this->makeOffer($provider->id, 'Toggle Course');
|
||||
@@ -139,10 +139,10 @@ class DivingOfferVisibilityTest extends TestCase
|
||||
array_column($this->getJson('/api/diving-offers')->json('data'), 'title')
|
||||
);
|
||||
|
||||
// 撤銷驗證(approved→rejected)後快取必須立即失效
|
||||
$this->actingAs($admin)
|
||||
->putJson("/api/admin/providers/{$provider->id}/toggle-verified")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.is_verified', false);
|
||||
->putJson("/api/admin/verifications/{$provider->id}/reject", ['reason' => '資料不實'])
|
||||
->assertOk();
|
||||
|
||||
$this->assertNotContains(
|
||||
'Toggle Course',
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ProviderCertification;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 教練端驗證申請流程(provider-verification 規格)。
|
||||
*
|
||||
* 證照是 Admin 的審核依據:pending/approved 期間若可變更,
|
||||
* 等於審核通過後可抽換證照,狀態機與鎖定規則是審核公信力的基礎。
|
||||
*/
|
||||
class ProviderVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeProvider(string $status = 'unsubmitted'): User
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => $status]);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function addCertification(User $provider): ProviderCertification
|
||||
{
|
||||
return ProviderCertification::create([
|
||||
'user_id' => $provider->id,
|
||||
'image_path' => "providers/{$provider->id}/certifications/test.jpg",
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 查詢 ─────────────────────────────────────────────────
|
||||
|
||||
public function test_provider_can_view_own_verification_state(): void
|
||||
{
|
||||
$provider = $this->makeProvider('rejected');
|
||||
$provider->providerProfile->update(['rejection_reason' => '證照不清晰']);
|
||||
$this->addCertification($provider);
|
||||
|
||||
$this->actingAs($provider)->getJson('/api/provider/verification')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.verification_status', 'rejected')
|
||||
->assertJsonPath('data.rejection_reason', '證照不清晰')
|
||||
->assertJsonCount(1, 'data.certifications');
|
||||
}
|
||||
|
||||
// ── 證照上傳與鎖定 ───────────────────────────────────────
|
||||
|
||||
public function test_unsubmitted_provider_can_upload_certification(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson('/api/provider/verification/certifications', [
|
||||
'image' => UploadedFile::fake()->image('cert.jpg', 800, 600),
|
||||
])->assertStatus(201);
|
||||
|
||||
$this->assertSame(1, $provider->providerCertifications()->count());
|
||||
}
|
||||
|
||||
public function test_certification_limit_is_three(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
foreach (range(1, 3) as $i) {
|
||||
$this->addCertification($provider);
|
||||
}
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson('/api/provider/verification/certifications', [
|
||||
'image' => UploadedFile::fake()->image('cert4.jpg', 800, 600),
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_certifications_are_locked_while_pending(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider('pending');
|
||||
$cert = $this->addCertification($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson('/api/provider/verification/certifications', [
|
||||
'image' => UploadedFile::fake()->image('late.jpg', 800, 600),
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->deleteJson("/api/provider/verification/certifications/{$cert->id}")
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_provider_cannot_delete_others_certification(): void
|
||||
{
|
||||
$owner = $this->makeProvider();
|
||||
$cert = $this->addCertification($owner);
|
||||
$intruder = $this->makeProvider();
|
||||
|
||||
$this->actingAs($intruder)
|
||||
->deleteJson("/api/provider/verification/certifications/{$cert->id}")
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
// ── 送審狀態機 ───────────────────────────────────────────
|
||||
|
||||
public function test_submit_requires_at_least_one_certification(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson('/api/provider/verification/submit')
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertSame('unsubmitted', $provider->providerProfile->fresh()->verification_status->value);
|
||||
}
|
||||
|
||||
public function test_submit_moves_unsubmitted_to_pending(): void
|
||||
{
|
||||
$provider = $this->makeProvider();
|
||||
$this->addCertification($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson('/api/provider/verification/submit')
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame('pending', $provider->providerProfile->fresh()->verification_status->value);
|
||||
}
|
||||
|
||||
public function test_rejected_provider_can_resubmit_and_reason_is_cleared(): void
|
||||
{
|
||||
$provider = $this->makeProvider('rejected');
|
||||
$provider->providerProfile->update(['rejection_reason' => '證照過期']);
|
||||
$this->addCertification($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson('/api/provider/verification/submit')
|
||||
->assertOk();
|
||||
|
||||
$profile = $provider->providerProfile->fresh();
|
||||
$this->assertSame('pending', $profile->verification_status->value);
|
||||
$this->assertNull($profile->rejection_reason);
|
||||
}
|
||||
|
||||
public function test_pending_or_approved_provider_cannot_submit_again(): void
|
||||
{
|
||||
foreach (['pending', 'approved'] as $status) {
|
||||
$provider = $this->makeProvider($status);
|
||||
$this->addCertification($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson('/api/provider/verification/submit')
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertSame($status, $provider->providerProfile->fresh()->verification_status->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class ReviewTest extends TestCase
|
||||
{
|
||||
$provider = User::factory()->create(['role' => 'provider']);
|
||||
// 公開評價端點僅對已驗證教練的課程開放(provider-verification 規格)
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'is_verified' => true]);
|
||||
ProviderProfile::create(['user_id' => $provider->id, 'verification_status' => 'approved']);
|
||||
return DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => '測試潛水課程',
|
||||
|
||||
Reference in New Issue
Block a user