From 8cbdd93e565dd55ba2140fd56e9f11c88529404e Mon Sep 17 00:00:00 2001 From: antv Date: Tue, 30 Jun 2026 15:31:40 +0700 Subject: [PATCH] feature: add error pages --- app/Providers/AppServiceProvider.php | 15 ++- config/logging.php | 9 ++ docker/php/entrypoint.sh | 2 + docs/system-error-pages.md | 124 ++++++++++++++++++++++++ lang/vi/errors.php | 30 ++++++ public/images/icn-401.svg | 3 + public/images/icn-403.svg | 3 + public/images/icn-404.svg | 3 + public/images/icn-419.svg | 3 + public/images/icn-429.svg | 3 + public/images/icn-500.svg | 3 + public/images/icn-503.svg | 4 + resources/views/errors/401.blade.php | 25 +++++ resources/views/errors/403.blade.php | 25 +++++ resources/views/errors/404.blade.php | 25 +++++ resources/views/errors/419.blade.php | 25 +++++ resources/views/errors/429.blade.php | 25 +++++ resources/views/errors/500.blade.php | 25 +++++ resources/views/errors/503.blade.php | 25 +++++ resources/views/errors/layout.blade.php | 48 +++++++++ routes/web.php | 11 +++ 21 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 docs/system-error-pages.md create mode 100644 lang/vi/errors.php create mode 100644 public/images/icn-401.svg create mode 100644 public/images/icn-403.svg create mode 100644 public/images/icn-404.svg create mode 100644 public/images/icn-419.svg create mode 100644 public/images/icn-429.svg create mode 100644 public/images/icn-500.svg create mode 100644 public/images/icn-503.svg create mode 100644 resources/views/errors/401.blade.php create mode 100644 resources/views/errors/403.blade.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/419.blade.php create mode 100644 resources/views/errors/429.blade.php create mode 100644 resources/views/errors/500.blade.php create mode 100644 resources/views/errors/503.blade.php create mode 100644 resources/views/errors/layout.blade.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 03bbaae..8fe3948 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,8 @@ use App\Services\Auth\AuthService; use App\Services\Auth\Contracts\AuthServiceInterface; use App\Services\User\Contracts\UserServiceInterface; use App\Services\User\UserService; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -21,6 +23,17 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - // + if (config('app.debug')) { + DB::listen(function ($query) { + Log::channel('query')->info( + sprintf( + "[%s ms] %s | Bindings: %s", + $query->time, + $query->sql, + json_encode($query->bindings) + ) + ); + }); + } } } diff --git a/config/logging.php b/config/logging.php index b09cb25..4f4a77d 100644 --- a/config/logging.php +++ b/config/logging.php @@ -63,6 +63,7 @@ return [ 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true, + 'permission' => 0666, ], 'daily' => [ @@ -71,6 +72,7 @@ return [ 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), 'replace_placeholders' => true, + 'permission' => 0666, ], 'slack' => [ @@ -123,6 +125,13 @@ return [ 'handler' => NullHandler::class, ], + 'query' => [ + 'driver' => 'single', + 'path' => storage_path('logs/query.log'), + 'level' => 'debug', + 'permission' => 0666, + ], + 'emergency' => [ 'path' => storage_path('logs/laravel.log'), ], diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index c9d0ab9..41971f4 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -13,6 +13,8 @@ mkdir -p storage/logs \ storage/framework/views \ bootstrap/cache +touch storage/logs/laravel.log storage/logs/query.log +chmod 666 storage/logs/laravel.log storage/logs/query.log chmod -R 777 storage bootstrap/cache exec "$@" diff --git a/docs/system-error-pages.md b/docs/system-error-pages.md new file mode 100644 index 0000000..8c7b408 --- /dev/null +++ b/docs/system-error-pages.md @@ -0,0 +1,124 @@ +# Tài liệu cấu hình và sử dụng System Error Pages + +Tài liệu này hướng dẫn chi tiết cách hoạt động, các trường hợp sử dụng, cách kích hoạt (trigger) và kiểm thử các trang lỗi hệ thống trong dự án **thankcard-system**. + +--- + +## 1. Danh sách các trang lỗi & Tình huống sử dụng + +| Status Code | Tên lỗi | Tình huống sử dụng | Key Localization (`errors.php`) | +| :--- | :--- | :--- | :--- | +| **401** | Unauthorized | Người dùng chưa đăng nhập nhưng cố truy cập tài nguyên yêu cầu xác thực. | `errors.401_title` / `errors.401_message` | +| **403** | Forbidden | Người dùng đã đăng nhập nhưng không có quyền hạn thực hiện hành động này (ví dụ: User thường cố truy cập chức năng Admin). | `errors.403_title` / `errors.403_message` | +| **404** | Not Found | Đường dẫn (URL) không tồn tại, hoặc khi tìm dữ liệu bằng Model binding/Query không thấy mà muốn trả về lỗi. | `errors.404_title` / `errors.404_message` | +| **419** | Session Expired | Phiên làm việc hết hạn hoặc token CSRF không hợp lệ/hết hạn khi gửi Form (POST/PUT/DELETE). | `errors.419_title` / `errors.419_message` | +| **429** | Too Many Requests | Người dùng/IP gửi quá nhiều yêu cầu trong thời gian ngắn (vượt giới hạn Rate Limit của Middleware). | `errors.429_title` / `errors.429_message` | +| **500** | Internal Error | Lỗi xảy ra từ phía máy chủ hoặc do code PHP bị lỗi crash, kết nối cơ sở dữ liệu thất bại... | `errors.500_title` / `errors.500_message` | +| **503** | Service Unavailable | Hệ thống đang được bảo trì khi chạy lệnh `php artisan down`. | `errors.503_title` / `errors.503_message` | + +--- + +## 2. Cách kích hoạt (Trigger) trong Laravel + +Trong Laravel, bạn có thể trigger các trang lỗi này bằng nhiều cách khác nhau tùy thuộc vào ngữ cảnh. + +### 2.1. Sử dụng hàm `abort()` (Khuyên dùng trong Controller/Service) +Hàm `abort()` sẽ ném ra một `HttpException` và Laravel sẽ tự động bắt lấy để render trang lỗi tương ứng nằm trong `resources/views/errors/{status}.blade.php`. + +```php +// Ví dụ kiểm tra quyền trong Controller +if ($user->role !== 'admin') { + abort(403, 'Bạn không có quyền quản trị viên.'); +} + +// Ví dụ không tìm thấy tài nguyên +$card = Card::find($id); +if (!$card) { + abort(404, 'Không tìm thấy thẻ cảm ơn.'); +} +``` + +### 2.2. Sử dụng lệnh bảo trì hệ thống (Maintenance Mode) +Kích hoạt lỗi **503** bằng cách đưa ứng dụng vào trạng thái bảo trì: +```bash +php artisan down +``` +Để tắt chế độ bảo trì và đưa hệ thống hoạt động bình thường: +```bash +php artisan up +``` + +### 2.3. Sử dụng Middleware (Rate Limiting cho lỗi 429) +Laravel tích hợp sẵn middleware `throttle`. Bạn có thể áp dụng nó trong file Routes (`routes/web.php` hoặc `routes/api.php`): +```php +Route::middleware('throttle:60,1')->group(function () { + Route::get('/send-card', [UserController::class, 'send']); +}); +``` +*(Nếu một IP gọi quá 60 lần/phút, Laravel sẽ tự động ném ra lỗi 429 và render trang 429)* + +--- + +## 3. Ví dụ triển khai thực tế + +### Trong Controller/Service: +```php +namespace App\Http\Controllers; + +use App\Services\User\Contracts\UserServiceInterface; +use Illuminate\Http\Request; + +class UserController extends Controller +{ + protected $userService; + + public function __construct(UserServiceInterface $userService) + { + $this->userService = $userService; + } + + public function show($id) + { + $user = $this->userService->findUserById($id); + + if (!$user) { + // Tự động render view errors/404.blade.php + abort(404); + } + + return view('user.profile', compact('user')); + } +} +``` + +--- + +## 4. Checklist kiểm thử các trang lỗi + +Để chắc chắn các trang lỗi hoạt động chính xác và hiển thị đẹp mắt, hãy tiến hành kiểm thử theo checklist sau: + +- [ ] **Test 401 Unauthorized:** + - Tạm thời gỡ bỏ middleware auth ở một route và ném `abort(401)`. + - Kiểm tra xem có hiển thị nút **"Đi đến trang Đăng Nhập"** hay không. +- [ ] **Test 403 Forbidden:** + - Đăng nhập tài khoản User thường, sau đó truy cập một route dành riêng cho Admin hoặc ném `abort(403)`. + - Kiểm tra xem nút **"Quay lại Trang Chủ"** có hoạt động chính xác không. +- [ ] **Test 404 Not Found:** + - Truy cập một đường dẫn ngẫu nhiên không khai báo trong route (ví dụ: `http://localhost:8888/path-does-not-exist`). + - Kiểm tra giao diện và nút **"Quay lại Trang Chủ"**. +- [ ] **Test 419 Session Expired:** + - Mở một trang có form POST, xóa trường `_token` ẩn (CSRF) trong thẻ `
` bằng Inspect Element của trình duyệt rồi bấm Submit. + - Hoặc ném `abort(419)`. + - Kiểm tra xem có hiển thị nút **"Tải lại trang"** hay không. +- [ ] **Test 429 Too Many Requests:** + - Cấu hình tạm thời middleware `throttle:3,1` trên route chính và bấm F5 liên tục 4 lần. + - Hoặc gọi `abort(429)`. + - Kiểm tra thông báo lỗi. +- [ ] **Test 500 Internal Server Error:** + - Thêm một dòng code bị lỗi cú pháp cố ý trong Controller (ví dụ: gọi hàm không tồn tại `$this->nonExistentMethod()`). + - Hoặc gọi `abort(500)`. + - Đảm bảo giao diện trang 500 hiển thị thân thiện, không làm lộ stack trace kỹ thuật của code PHP. +- [ ] **Test 503 Maintenance:** + - Chạy lệnh `php artisan down` trong container Docker. + - Tải lại trình duyệt để xem trang bảo trì 503 và nút **"Thử lại"**. + - Chạy `php artisan up` để đưa website trở lại bình thường. diff --git a/lang/vi/errors.php b/lang/vi/errors.php new file mode 100644 index 0000000..2e5a21f --- /dev/null +++ b/lang/vi/errors.php @@ -0,0 +1,30 @@ + 'Chưa đăng nhập', + '401_message' => 'Bạn cần đăng nhập tài khoản trước khi
truy cập vào trang này.', + + '403_title' => 'Không có quyền truy cập', + '403_message' => 'Bạn không được phân quyền để truy cập
vào tài nguyên hoặc hành động này.', + + '404_title' => 'Không tìm thấy trang', + '404_message' => 'Trang bạn đang tìm kiếm không tồn tại hoặc
đã được di chuyển khỏi hệ thống.', + + '419_title' => 'Phiên làm việc hết hạn', + '419_message' => 'Trang đã hết hạn bảo mật do lâu không tương tác.
Vui lòng tải lại trang và thử lại.', + + '429_title' => 'Yêu cầu quá giới hạn', + '429_message' => 'Bạn đang gửi quá nhiều yêu cầu đến máy chủ.
Vui lòng đợi một lát rồi thử lại.', + + '500_title' => 'Lỗi máy chủ nội bộ', + '500_message' => 'Hệ thống đã gặp sự cố đột xuất máy chủ.
Chúng tôi đang kiểm tra và khắc phục sớm nhất.', + + '503_title' => 'Hệ thống đang bảo trì', + '503_message' => 'Hệ thống hiện đang được bảo trì nâng cấp định kỳ.
Xin vui lòng quay lại sau ít phút.', + + 'back_to_home' => 'Về trang chủ', + 'back_to_login' => 'Đi đến Đăng Nhập', + 'back' => 'Quay lại', + 'retry' => 'Thử lại', + 'reload' => 'Tải lại trang', +]; diff --git a/public/images/icn-401.svg b/public/images/icn-401.svg new file mode 100644 index 0000000..73a681c --- /dev/null +++ b/public/images/icn-401.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icn-403.svg b/public/images/icn-403.svg new file mode 100644 index 0000000..0e190e5 --- /dev/null +++ b/public/images/icn-403.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icn-404.svg b/public/images/icn-404.svg new file mode 100644 index 0000000..e8ac469 --- /dev/null +++ b/public/images/icn-404.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icn-419.svg b/public/images/icn-419.svg new file mode 100644 index 0000000..0de5505 --- /dev/null +++ b/public/images/icn-419.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icn-429.svg b/public/images/icn-429.svg new file mode 100644 index 0000000..e260505 --- /dev/null +++ b/public/images/icn-429.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icn-500.svg b/public/images/icn-500.svg new file mode 100644 index 0000000..662212d --- /dev/null +++ b/public/images/icn-500.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/icn-503.svg b/public/images/icn-503.svg new file mode 100644 index 0000000..cb589d5 --- /dev/null +++ b/public/images/icn-503.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/views/errors/401.blade.php b/resources/views/errors/401.blade.php new file mode 100644 index 0000000..df15eec --- /dev/null +++ b/resources/views/errors/401.blade.php @@ -0,0 +1,25 @@ +@extends('errors.layout') + +@section('title', __('errors.401_title')) +@section('code', '401') + +@section('illustration') +
+ 401 Illustration +
+@endsection + +@section('message') + {!! __('errors.401_message') !!} +@endsection + +@section('action') + + + {{ __('errors.back_to_login') }} + +@endsection diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 0000000..75685dd --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,25 @@ +@extends('errors.layout') + +@section('title', __('errors.403_title')) +@section('code', '403') + +@section('illustration') +
+ 403 Illustration +
+@endsection + +@section('message') + {!! __('errors.403_message') !!} +@endsection + +@section('action') + + + {{ __('errors.back_to_home') }} + +@endsection diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 0000000..36bddd0 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,25 @@ +@extends('errors.layout') + +@section('title', __('errors.404_title')) +@section('code', '404') + +@section('illustration') +
+ 404 Illustration +
+@endsection + +@section('message') + {!! __('errors.404_message') !!} +@endsection + +@section('action') + + + {{ __('errors.back_to_home') }} + +@endsection diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php new file mode 100644 index 0000000..89e726a --- /dev/null +++ b/resources/views/errors/419.blade.php @@ -0,0 +1,25 @@ +@extends('errors.layout') + +@section('title', __('errors.419_title')) +@section('code', '419') + +@section('illustration') +
+ 419 Illustration +
+@endsection + +@section('message') + {!! __('errors.419_message') !!} +@endsection + +@section('action') + + + {{ __('errors.back_to_home') }} + +@endsection diff --git a/resources/views/errors/429.blade.php b/resources/views/errors/429.blade.php new file mode 100644 index 0000000..f005eaa --- /dev/null +++ b/resources/views/errors/429.blade.php @@ -0,0 +1,25 @@ +@extends('errors.layout') + +@section('title', __('errors.429_title')) +@section('code', '429') + +@section('illustration') +
+ 429 Illustration +
+@endsection + +@section('message') + {!! __('errors.429_message') !!} +@endsection + +@section('action') + + + {{ __('errors.back_to_home') }} + +@endsection diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php new file mode 100644 index 0000000..523febd --- /dev/null +++ b/resources/views/errors/500.blade.php @@ -0,0 +1,25 @@ +@extends('errors.layout') + +@section('title', __('errors.500_title')) +@section('code', '500') + +@section('illustration') +
+ 500 Illustration +
+@endsection + +@section('message') + {!! __('errors.500_message') !!} +@endsection + +@section('action') + + + {{ __('errors.back_to_home') }} + +@endsection diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 0000000..9db116c --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,25 @@ +@extends('errors.layout') + +@section('title', __('errors.503_title')) +@section('code', '503') + +@section('illustration') +
+ 503 Illustration +
+@endsection + +@section('message') + {!! __('errors.503_message') !!} +@endsection + +@section('action') + + + {{ __('errors.back_to_home') }} + +@endsection diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php new file mode 100644 index 0000000..8b1b1ec --- /dev/null +++ b/resources/views/errors/layout.blade.php @@ -0,0 +1,48 @@ + + + + + + @yield('title') - GMO-Z.com RUNSYSTEM + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ +
+ GMO-Z.com RUNSYSTEM +
+ + + @yield('illustration') + + +
+ + @yield('code') + +
+ + +

+ @yield('title') +

+ + +
+ @yield('message') +
+ + +
+ @yield('action') +
+ + +
+ © 2026 GMO-Z.com RUNSYSTEM. All rights reserved. +
+
+ + diff --git a/routes/web.php b/routes/web.php index 6e46daa..fbd638c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,17 @@ Route::get('/', function () { return redirect()->route('login'); }); +if (config('app.debug')) { + Route::prefix('errors')->group(function () { + Route::get('{code}', function ($code) { + if (view()->exists("errors.{$code}")) { + return view("errors.{$code}"); + } + abort(404); + }); + }); +} + Route::get('/test-modal', function() { return view('admin.users.index', [ 'users' => [],