feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
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:
2026-06-12 00:26:12 +08:00
parent 37140554d3
commit 43720482c4
36 changed files with 1622 additions and 274 deletions
+68 -13
View File
@@ -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()
{
}
+23
View File
@@ -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;
}
}
+2 -2
View File
@@ -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));
});
}
+31
View File
@@ -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);
}
}
+21 -2
View File
@@ -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;
}
/**
* 與用戶的關聯
*/
+8
View File
@@ -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');
}
}