diff --git a/README.md b/README.md
index a019430..886e27d 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,170 @@
-# huu_java
\ No newline at end of file
+# 🍔 F&B Store — Foods & Drinks
+
+Mock project website bán đồ ăn & thức uống, xây dựng bằng Spring Boot. Dự án gồm **3 phần** chạy chung trong 1 ứng dụng:
+
+| Phần | Đường dẫn | Mô tả | Tài liệu chi tiết |
+|---|---|---|---|
+| **Admin** | `/admin/**` | Trang quản trị SSR (Thymeleaf): dashboard, quản lý sản phẩm, danh mục, đơn hàng, người dùng, góp ý | [docs/admin/README.md](docs/admin/README.md) |
+| **API** | `/api/v1/**` | REST API (JWT) chia sẻ để xây trang người dùng (SPA/mobile) | [docs/api/README.md](docs/api/README.md) |
+| **Web** | `/`, `/menu`, ... | Storefront SSR cho người dùng cuối: xem menu, giỏ hàng, đặt hàng, đánh giá sản phẩm | [docs/web/README.md](docs/web/README.md) |
+
+## Tech Stack
+
+- **Ngôn ngữ:** Java 25
+- **Framework:** Spring Boot 4.x (`spring-boot-starter-parent` 4.0.7)
+- **Build tool:** Maven (Maven Wrapper đi kèm)
+- **Database:** MySQL 8 + Spring Data JPA / Hibernate
+- **Migration:** Flyway (`src/main/resources/db/migration`)
+- **View:** Thymeleaf + Thymeleaf Layout Dialect, Bootstrap 5, Vanilla JS
+- **Security:** Spring Security — Form Login + OAuth2 (Google/Facebook/Twitter) cho Web, JWT (jjwt 0.12) cho API
+- **Khác:** Lombok, ModelMapper, Caffeine Cache, Spring Mail (Mailtrap), Slack Incoming Webhook, springdoc-openapi 3.x (Swagger UI), Actuator
+
+## Prerequisites
+
+- **JDK 25** (pom.xml pin `25` — JDK 17/21 sẽ không build được)
+- **MySQL 8.x** đang chạy tại `localhost:3306` (hoặc override qua env var)
+- Không cần cài Maven — dùng Maven Wrapper (`mvnw.cmd` trên Windows, `./mvnw` trên Mac/Linux)
+
+## Quick Start
+
+1. **Tạo database:**
+
+ ```sql
+ CREATE DATABASE foodsndrinks CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ ```
+
+2. **Khai báo biến môi trường bắt buộc** (dev profile không có default cho DB credentials):
+
+ ```bash
+ # Windows (PowerShell)
+ $env:DB_USERNAME = "root"
+ $env:DB_PASSWORD = "your-password"
+
+ # Mac/Linux
+ export DB_USERNAME=root
+ export DB_PASSWORD=your-password
+ ```
+
+3. **Chạy ứng dụng** (Flyway tự tạo bảng khi khởi động):
+
+ ```bash
+ # Windows
+ mvnw.cmd spring-boot:run
+
+ # Mac/Linux
+ ./mvnw spring-boot:run
+ ```
+
+4. Truy cập:
+ - Web (người dùng): http://localhost:8080
+ - Admin: http://localhost:8080/admin (cần tài khoản `ROLE_ADMIN`)
+ - Swagger UI (chỉ dev): http://localhost:8080/api-docs/swagger
+
+**Các lệnh khác:**
+
+```bash
+./mvnw test # Chạy unit test
+./mvnw clean package -DskipTests # Build file JAR (bỏ qua test)
+./mvnw clean # Clean dự án
+```
+
+## Project Structure
+
+Kiến trúc 3 lớp (3-Tier), package gốc `com.huuhv.foodsndrinks`:
+
+```
+src/main/java/com/huuhv/foodsndrinks/
+├── config/ # SecurityConfig, WebMvcConfig, AsyncConfig, OpenApiConfig, AppConfig
+├── controller/
+│ ├── admin/ # Controller trang quản trị (SSR) → docs/admin/README.md
+│ ├── api/ # REST controller /api/v1/** → docs/api/README.md
+│ ├── web/ # Controller storefront (SSR) → docs/web/README.md
+│ └── GlobalErrorController.java # Xử lý lỗi dispatch cấp servlet (/error)
+├── dto/
+│ ├── request/ # DTO hứng dữ liệu vào (không dùng Entity cho View/API)
+│ └── response/ # DTO trả ra
+├── entity/ # User, Product, ProductImage, Category, Order, OrderDetail, Rating, Suggestion
+├── enums/ # Role, AuthProvider, CategoryType, ProductType, OrderStatus, SuggestionStatus
+├── exception/ # AdminExceptionHandler, WebExceptionHandler, ApiExceptionHandler + custom exceptions
+├── repository/ # Spring Data JPA repositories
+├── security/ # JwtUtil, JwtAuthenticationFilter, CustomUserDetailsService, OAuth2 user services
+├── service/ # Toàn bộ business logic (Controller chỉ gọi Service)
+│ └── notification/ # Slack/Email notification, order event listener, statistics scheduler
+└── utils/ # SlugUtils...
+
+src/main/resources/
+├── db/migration/ # Flyway migrations
+├── templates/ # Thymeleaf (admin/, web/, error/)
+├── static/ # CSS, JS, images
+└── application*.yml # Cấu hình theo profile
+```
+
+**Quy ước quan trọng:** Constructor Injection qua `@RequiredArgsConstructor` + `private final` (không dùng field injection); Controller mỏng, logic nằm ở Service; query quan hệ dùng `LEFT JOIN FETCH` chống N+1. Xem thêm `CLAUDE.md`.
+
+## Configuration
+
+Profile mặc định là `dev` (đổi qua env `SPRING_PROFILES_ACTIVE=prod` khi deploy).
+
+| File | Vai trò |
+|---|---|
+| `application.yml` | Cấu hình chung: mail (SMTP), OAuth2 client (Google/Facebook/Twitter), Slack webhook, cron thống kê |
+| `application-dev.yml` | Datasource local, logging DEBUG, Swagger UI bật, JWT secret fallback, upload dir |
+| `application-prod.yml` | Datasource từ env (bắt buộc), logging ra file `logs/foodsndrinks.log`, **Swagger tắt hoàn toàn** |
+
+### Biến môi trường
+
+| Biến | Bắt buộc | Mô tả |
+|---|---|---|
+| `DB_USERNAME` / `DB_PASSWORD` | ✅ | Tài khoản MySQL |
+| `DB_FOODSNDRINKS_URL` | Prod | JDBC URL (dev mặc định `jdbc:mysql://localhost:3306/foodsndrinks`) |
+| `JWT_SECRET` | Prod | Secret ký JWT, ≥ 32 ký tự (dev có fallback) |
+| `REMEMBER_ME_KEY` | Prod | Key cho remember-me cookie (dev tự sinh random) |
+| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Khi dùng login Google | OAuth2 credentials từ Google Console |
+| `FACEBOOK_CLIENT_ID` / `FACEBOOK_CLIENT_SECRET` | Khi dùng login Facebook | OAuth2 credentials |
+| `TWITTER_CLIENT_ID` / `TWITTER_CLIENT_SECRET` | Khi dùng login Twitter/X | OAuth2 credentials |
+| `MAIL_HOST` / `MAIL_PORT` / `MAIL_USERNAME` / `MAIL_PASSWORD` | Khi gửi mail thật | SMTP (mặc định Mailtrap sandbox) |
+| `ADMIN_NOTIFY_EMAIL` | — | Email nhận thông báo đơn hàng & báo cáo tháng |
+| `SLACK_WEBHOOK_URL` / `SLACK_NOTIFY_ENABLED` | — | Slack incoming webhook thông báo đơn mới |
+| `MAIL_NOTIFY_ENABLED` | — | Bật/tắt email thông báo |
+| `ORDER_STATS_CRON` | — | Cron gửi báo cáo tháng (mặc định `0 0 23 L * *`, 23h ngày cuối tháng, giờ VN) |
+| `UPLOAD_DIR` | — | Thư mục lưu ảnh upload (mặc định `./uploads`) |
+| `SERVER_PORT` | — | Port (mặc định 8080) |
+
+## Database Migrations
+
+Dùng **Flyway**, tự chạy khi ứng dụng khởi động. File đặt tại `src/main/resources/db/migration` với quy ước tên `V__.sql`:
+
+```
+V20260624150700__create_table_users.sql
+V20260624150710__create_table_categories.sql
+V20260624150720__create_table_products.sql
+V20260624150730__create_table_product_images.sql
+V20260624150740__create_table_orders.sql
+V20260624150750__create_table_order_details.sql
+V20260624150760__create_table_ratings.sql
+V20260624150770__create_table_suggestions.sql
+V20260624150780__create_indexes.sql
+V20260702000001__alter_users_phone_nullable.sql
+```
+
+Quy ước: tên bảng số nhiều (`users`, `products`...). **Không sửa migration đã chạy** — muốn đổi schema thì thêm file version mới.
+
+## Notification & Batch Job
+
+- **Đơn hàng mới:** sau khi checkout commit thành công (`OrderPlacedEvent` + `@TransactionalEventListener`), hệ thống gửi **Slack** + **email** cho admin (chạy async qua thread pool `notify-*`).
+- **Báo cáo tháng:** `OrderStatisticsScheduler` chạy theo cron (mặc định 23h ngày cuối tháng, `Asia/Ho_Chi_Minh`) — tổng hợp doanh thu, số đơn theo trạng thái và email cho admin.
+
+## Xử Lý Lỗi
+
+- Mỗi luồng có `@ControllerAdvice` riêng cùng package: `AdminExceptionHandler` (view lỗi admin), `WebExceptionHandler` (view lỗi web), `ApiExceptionHandler` (JSON `ErrorResponse`).
+- `GlobalErrorController` (`/error`) chỉ xử lý lỗi dispatch cấp servlet (404/403/405/409 và exception thoát khỏi mọi advice) — tự chọn trả JSON hay view theo prefix URI.
+
+## Monitoring
+
+- Actuator: `/actuator/health` public; các endpoint khác (`info`, `metrics`, `caches` — chỉ dev) yêu cầu `ROLE_ADMIN`. Prod chỉ expose `health`.
+
+## Tài Liệu Chi Tiết
+
+- 🛠 [Admin — trang quản trị](docs/admin/README.md)
+- 🔌 [API — REST API cho trang người dùng](docs/api/README.md)
+- 🛒 [Web — storefront người dùng](docs/web/README.md)
diff --git a/docs/admin/README.md b/docs/admin/README.md
new file mode 100644
index 0000000..6dc1b9a
--- /dev/null
+++ b/docs/admin/README.md
@@ -0,0 +1,88 @@
+# 🛠 Admin — Trang Quản Trị (SSR)
+
+Trang quản trị hệ thống, render phía server bằng Thymeleaf + Bootstrap 5. Toàn bộ đường dẫn `/admin/**` yêu cầu **`ROLE_ADMIN`**, đăng nhập bằng form login tại `/login` (session-based, hỗ trợ remember-me 7 ngày).
+
+> Quay lại [README tổng quan](../../README.md) · Xem thêm: [API](../api/README.md) · [Web](../web/README.md)
+
+## Tổng Quan
+
+| Thành phần | Vị trí |
+|---|---|
+| Controllers | `com.huuhv.foodsndrinks.controller.admin` |
+| Templates | `src/main/resources/templates/admin/` (layout: `admin/layout/base.html`, dùng Thymeleaf Layout Dialect) |
+| Xử lý lỗi | `AdminExceptionHandler` (`@ControllerAdvice` scoped package admin) + view `error/admin/{404,403,405,409,500}` |
+| Model advice | `AdminLayoutModelAdvice` — inject `currentPath` và các enum (categoryTypes, productTypes, orderStatuses, roles, suggestionStatuses) vào mọi view admin |
+
+## Chức Năng & Routes
+
+### Dashboard — `AdminController`
+
+| Method | Path | Mô tả |
+|---|---|---|
+| GET | `/admin`, `/admin/dashboard` | Dashboard thống kê tổng quan |
+
+### Quản lý sản phẩm — `ProductAdminController` (`/admin/products`)
+
+| Method | Path | Mô tả |
+|---|---|---|
+| GET | `/admin/products` | Danh sách phân trang, filter theo tên / danh mục / loại / trạng thái bán |
+| GET | `/admin/products/add` | Form thêm sản phẩm |
+| POST | `/admin/products/add` | Tạo sản phẩm, upload nhiều ảnh (`newImages`, tối đa 10MB/file) |
+| GET | `/admin/products/edit/{id}` | Form sửa sản phẩm |
+| POST | `/admin/products/edit/{id}` | Cập nhật (thêm/xóa ảnh, chọn ảnh đại diện) |
+| POST | `/admin/products/{id}/delete` | Xóa sản phẩm |
+
+Ảnh upload lưu tại thư mục `UPLOAD_DIR` (mặc định `./uploads`), serve qua `/uploads/**` (cấu hình trong `WebMvcConfig`).
+
+### Quản lý danh mục — `CategoryAdminController` (`/admin/categories`)
+
+| Method | Path | Mô tả |
+|---|---|---|
+| GET | `/admin/categories` | Danh sách phân trang + filter |
+| GET / POST | `/admin/categories/add` | Thêm danh mục (validate, check trùng) |
+| GET / POST | `/admin/categories/edit/{id}` | Sửa danh mục |
+| POST | `/admin/categories/{id}/delete` | Xóa danh mục |
+
+### Quản lý đơn hàng — `OrderAdminController` (`/admin/orders`)
+
+| Method | Path | Mô tả |
+|---|---|---|
+| GET | `/admin/orders` | Danh sách phân trang, filter theo trạng thái / keyword / mã đơn |
+| GET | `/admin/orders/{id}` | Chi tiết đơn hàng |
+| POST | `/admin/orders/{id}/status` | Cập nhật trạng thái đơn (`OrderStatus`) |
+
+### Quản lý người dùng — `UserAdminController` (`/admin/users`)
+
+| Method | Path | Mô tả |
+|---|---|---|
+| GET | `/admin/users` | Danh sách phân trang, filter theo keyword / role / trạng thái active |
+| GET / POST | `/admin/users/{id}/edit` | Sửa thông tin người dùng |
+| POST | `/admin/users/{id}/toggle-active` | Khóa / mở khóa tài khoản |
+
+### Quản lý góp ý — `SuggestionAdminController` (`/admin/suggestions`)
+
+| Method | Path | Mô tả |
+|---|---|---|
+| GET | `/admin/suggestions` | Danh sách phân trang, filter theo trạng thái / keyword |
+| GET | `/admin/suggestions/{id}` | Chi tiết + form sửa |
+| POST | `/admin/suggestions/{id}/update` | Lưu chỉnh sửa góp ý |
+| POST | `/admin/suggestions/{id}/status` | Cập nhật trạng thái (`SuggestionStatus`) |
+
+## Thông Báo Cho Admin
+
+Admin nhận thông báo tự động (không cần thao tác trên UI):
+
+- **Đơn hàng mới:** sau khi user checkout thành công → gửi **Slack** (incoming webhook) + **email** tới `ADMIN_NOTIFY_EMAIL`. Chạy async (`@Async`), fire sau khi transaction commit (`@TransactionalEventListener`).
+- **Báo cáo doanh thu tháng:** `OrderStatisticsScheduler` chạy cron `ORDER_STATS_CRON` (mặc định 23h ngày cuối tháng, giờ VN) — email tổng doanh thu, số đơn hoàn thành, số đơn theo từng trạng thái.
+
+Bật/tắt qua `SLACK_NOTIFY_ENABLED`, `MAIL_NOTIFY_ENABLED`. Xem package `service/notification/`.
+
+## Xử Lý Lỗi
+
+- Exception nghiệp vụ (`ResourceNotFoundException` → 404, `DuplicateResourceException` → 409, `Exception` khác → 500) được `AdminExceptionHandler` bắt và render view `error/admin/*`.
+- Lỗi dispatch cấp servlet (404 URL không tồn tại, 403, 405...) do `GlobalErrorController` xử lý — với URI prefix `/admin/*` sẽ trả về view lỗi admin.
+- Thông báo thao tác thành công/thất bại trên UI dùng `RedirectAttributes` (flash message).
+
+## Monitoring
+
+Admin đã đăng nhập có thể truy cập Actuator: `/actuator/health` (chi tiết), `/actuator/metrics`, `/actuator/info`, `/actuator/caches` (chỉ dev).
diff --git a/docs/api/README.md b/docs/api/README.md
new file mode 100644
index 0000000..416d06a
--- /dev/null
+++ b/docs/api/README.md
@@ -0,0 +1,98 @@
+# 🔌 API — REST API Cho Trang Người Dùng
+
+REST API dưới prefix `/api/v1`, trả JSON, xác thực bằng **JWT (Bearer token)**. Đây là API chia sẻ để xây trang người dùng dạng SPA/mobile app trong tương lai.
+
+> Quay lại [README tổng quan](../../README.md) · Xem thêm: [Admin](../admin/README.md) · [Web](../web/README.md)
+
+## Tổng Quan
+
+| Thành phần | Vị trí |
+|---|---|
+| Controllers | `com.huuhv.foodsndrinks.controller.api` (`@RestController`) |
+| Security | Chain riêng `apiSecurityFilterChain` (`@Order(2)`, matcher `/api/**`) — **stateless**, CSRF off, `JwtAuthenticationFilter` |
+| JWT | `JwtUtil` (jjwt 0.12), HS256, secret từ `JWT_SECRET`, hết hạn sau **1 giờ** (`jwt.expiration: 3600000`) |
+| Xử lý lỗi | `ApiExceptionHandler` (`@RestControllerAdvice`) — trả JSON `ErrorResponse` |
+| OpenAPI | `OpenApiConfig` + springdoc-openapi 3.x |
+
+## Swagger / OpenAPI
+
+Chỉ bật ở profile **dev** (prod tắt hoàn toàn):
+
+- **Swagger UI:** `http://localhost:8080/api-docs/swagger`
+- **OpenAPI JSON:** `http://localhost:8080/api/v1/api-docs`
+
+Swagger có nút **Authorize** (security scheme `bearerAuth`) — dán JWT lấy từ endpoint login để gọi các API cần xác thực.
+
+## Xác Thực
+
+### Đăng nhập lấy token
+
+```
+POST /api/v1/auth/login
+Content-Type: application/json
+
+{
+ "username": "user@example.com", // username hoặc email
+ "password": "secret"
+}
+```
+
+Response (`AuthResDto`) chứa JWT. Các request sau gửi kèm header:
+
+```
+Authorization: Bearer
+```
+
+Token sai/thiếu/hết hạn → **401 Unauthorized**. Tài khoản bị khóa → 401 (bắt `DisabledException`).
+
+## Endpoints
+
+### Auth — `AuthApiController` (`/api/v1/auth`)
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| POST | `/api/v1/auth/login` | Public | Đăng nhập bằng username/email + password, trả JWT |
+
+### Products — `ProductApiController` (`/api/v1/products`)
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| GET | `/api/v1/products` | `USER` / `ADMIN` | Danh sách sản phẩm phân trang (chỉ `isAvailable=true`), filter: `name`, `categoryId`, `type` |
+| GET | `/api/v1/products/{id}` | `USER` / `ADMIN` | Chi tiết sản phẩm kèm danh sách ảnh |
+
+Response danh sách bọc trong `PageResDto` (nội dung + thông tin phân trang).
+
+### Ví dụ với curl
+
+```bash
+# 1. Login
+curl -X POST http://localhost:8080/api/v1/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"user@example.com","password":"secret"}'
+
+# 2. Gọi API với token
+curl http://localhost:8080/api/v1/products?page=0&size=10 \
+ -H "Authorization: Bearer "
+```
+
+## Định Dạng Lỗi
+
+`ApiExceptionHandler` trả JSON `ErrorResponse` thống nhất:
+
+| Exception | HTTP status |
+|---|---|
+| `ResourceNotFoundException` | 404 |
+| `IllegalArgumentException` | 400 |
+| `MethodArgumentNotValidException` (validation) | 400 — kèm map `field → message` |
+| `DuplicateResourceException` | 409 |
+| `BadCredentialsException` / `DisabledException` | 401 |
+| `Exception` khác | 500 |
+
+Lỗi dispatch cấp servlet (ví dụ 404 URL không tồn tại) do `GlobalErrorController` xử lý — URI prefix `/api/*` vẫn nhận JSON `ErrorResponse`, không bao giờ nhận HTML.
+
+## Quy Ước Khi Thêm API Mới
+
+- Đặt controller trong `controller.api`, base path `/api/v1/...`, thêm `@Tag` để Swagger gom nhóm (springdoc chỉ scan package `controller.api`).
+- Request/response dùng DTO trong `dto/request` / `dto/response` — **không expose Entity**.
+- Khai báo rule phân quyền cho path mới trong `apiSecurityFilterChain` (`SecurityConfig`).
+- Exception nghiệp vụ mới → thêm handler vào `ApiExceptionHandler`, không sửa `GlobalErrorController`.
diff --git a/docs/web/README.md b/docs/web/README.md
new file mode 100644
index 0000000..154ef9e
--- /dev/null
+++ b/docs/web/README.md
@@ -0,0 +1,100 @@
+# 🛒 Web — Storefront Người Dùng (SSR)
+
+Trang bán hàng cho người dùng cuối, render phía server bằng Thymeleaf + Bootstrap 5 + Vanilla JS. Khách vãng lai xem được menu/sản phẩm; các tính năng giỏ hàng, đặt hàng, đánh giá, góp ý yêu cầu đăng nhập (`ROLE_USER` hoặc `ROLE_ADMIN`).
+
+> Quay lại [README tổng quan](../../README.md) · Xem thêm: [Admin](../admin/README.md) · [API](../api/README.md)
+
+## Tổng Quan
+
+| Thành phần | Vị trí |
+|---|---|
+| Controllers | `com.huuhv.foodsndrinks.controller.web` |
+| Templates | `src/main/resources/templates/web/` (layout: `web/layout/base.html`, Thymeleaf Layout Dialect) |
+| Security | Chain `webSecurityFilterChain` (`@Order(3)`) — session-based, form login + OAuth2, remember-me 7 ngày |
+| Xử lý lỗi | `WebExceptionHandler` (`@ControllerAdvice` scoped package web) + view `error/{404,409,500}` |
+| Model advice | `WebLayoutModelAdvice` — inject `cartItemCount` (badge giỏ hàng trên navbar, 0 với khách) |
+
+## Xác Thực
+
+### Form login & đăng ký — `AuthController`
+
+| Method | Path | Mô tả |
+|---|---|---|
+| GET | `/login` | Trang đăng nhập (form + nút social login) |
+| GET / POST | `/register` | Đăng ký tài khoản (check trùng, check password nhập lại) → redirect `/login` |
+| POST | `/logout` | Đăng xuất → `/login?logout=true`, xóa session + remember-me cookie |
+
+Sau khi login: `ADMIN` → `/admin`, `USER` → `/` (hoặc quay lại trang yêu cầu trước đó).
+
+### Social login (OAuth2)
+
+Hỗ trợ **Google** (OIDC), **Facebook**, **Twitter/X** — cấu hình trong `application.yml`, cần set env `*_CLIENT_ID` / `*_CLIENT_SECRET` tương ứng:
+
+- PKCE được bật cho mọi provider (`pkceAuthorizationRequestResolver`).
+- Google dùng `email` làm principal key (map tài khoản theo email đã verify); xử lý ở `CustomOidcUserService`.
+- Facebook/Twitter xử lý ở `CustomOAuth2UserService`; Twitter là provider cấu hình thủ công (không nằm trong `CommonOAuth2Provider`).
+- Provider của tài khoản lưu ở enum `AuthProvider`.
+
+## Chức Năng & Routes
+
+### Menu & sản phẩm — `WebController`
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| GET | `/`, `/menu` | Public | Trang menu: filter, sort, phân trang sản phẩm |
+| GET | `/categories/{slug}` | Public | Sản phẩm theo danh mục |
+| GET | `/products/{slug}` | Public | Chi tiết sản phẩm + danh sách đánh giá (+ đánh giá của tôi nếu đã login) |
+| GET | `/contact` | Public | Trang liên hệ |
+
+### Giỏ hàng & checkout — `CartController`
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| GET | `/cart` | USER | Trang giỏ hàng |
+| POST | `/cart/add` | USER | Thêm sản phẩm (AJAX, trả JSON `CartPayloadResDto`) |
+| POST | `/cart/update/{detailId}` | USER | Đổi số lượng (AJAX, JSON) |
+| POST | `/cart/remove/{detailId}` | USER | Xóa item (AJAX, JSON) |
+| POST | `/cart/checkout` | USER | Đặt hàng → redirect `/orders` (đồng thời bắn Slack/email cho admin) |
+
+Các endpoint AJAX vẫn thuộc chain web (session + CSRF token), không phải API JWT.
+
+### Đơn hàng — `OrderController`
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| GET | `/orders` | USER | Lịch sử đơn hàng của tôi (phân trang) |
+| GET | `/orders/{id}` | USER | Chi tiết đơn hàng (chỉ đơn của chính mình) |
+
+### Đánh giá sản phẩm — `RatingController`
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| POST | `/products/{slug}/rate` | USER | Gửi đánh giá (số sao + bình luận) → redirect về trang sản phẩm |
+
+Dưới mỗi sản phẩm có nút chia sẻ mạng xã hội (client-side, trong template chi tiết sản phẩm).
+
+### Góp ý — `SuggestionController`
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| GET | `/suggest` | USER | Danh sách góp ý của tôi (phân trang) |
+| POST | `/suggest` | USER | Gửi góp ý mới |
+
+### Hồ sơ cá nhân — `ProfileController`
+
+| Method | Path | Auth | Mô tả |
+|---|---|---|---|
+| GET | `/profile` | USER | Trang hồ sơ (thông tin + đơn gần đây + số item giỏ hàng) |
+| GET / POST | `/profile/edit` | USER | Cập nhật hồ sơ (validate, check trùng) |
+
+## Xử Lý Lỗi
+
+- Exception nghiệp vụ (`ResourceNotFoundException` → 404, `DuplicateResourceException` → 409, khác → 500) do `WebExceptionHandler` bắt, render view `error/*`.
+- Lỗi dispatch cấp servlet do `GlobalErrorController` xử lý — URI ngoài `/api/*` và `/admin/*` nhận view lỗi web.
+- Thông báo trên UI dùng `RedirectAttributes` (flash message).
+
+## Quy Ước Template
+
+- Layout chung `web/layout/base.html` (Thymeleaf Layout Dialect) — trang con chỉ định nghĩa fragment content.
+- **Không hardcode domain** hay đường dẫn tuyệt đối — dùng cú pháp context-path của Thymeleaf `@{/duong-dan}`.
+- Static assets: `/css`, `/js`, `/images` (trong `src/main/resources/static`), ảnh sản phẩm upload serve qua `/uploads/**`.
diff --git a/pom.xml b/pom.xml
index 66f27d3..debd95d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -42,6 +42,10 @@
org.springframework.boot
spring-boot-starter-security
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-client
+
org.springframework.boot
spring-boot-starter-thymeleaf
@@ -129,10 +133,11 @@
0.12.6
runtime
+
org.springdoc
springdoc-openapi-starter-webmvc-ui
- 2.5.0
+ 3.0.3
nz.net.ultraq.thymeleaf
@@ -143,6 +148,12 @@
spring-boot-starter-test
test
+
+
+ org.springframework.boot
+ spring-boot-webmvc-test
+ test
+
org.springframework.security
spring-security-test
diff --git a/src/main/java/com/huuhv/foodsndrinks/config/SecurityConfig.java b/src/main/java/com/huuhv/foodsndrinks/config/SecurityConfig.java
index 1d951ef..a6a8772 100644
--- a/src/main/java/com/huuhv/foodsndrinks/config/SecurityConfig.java
+++ b/src/main/java/com/huuhv/foodsndrinks/config/SecurityConfig.java
@@ -1,13 +1,21 @@
package com.huuhv.foodsndrinks.config;
import com.huuhv.foodsndrinks.enums.Role;
+import com.huuhv.foodsndrinks.security.CustomOAuth2UserService;
+import com.huuhv.foodsndrinks.security.CustomOidcUserService;
+import com.huuhv.foodsndrinks.security.JwtAuthenticationFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -15,9 +23,16 @@
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
+import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
+import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
+import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.HttpStatusEntryPoint;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import java.util.Collection;
@@ -31,6 +46,25 @@ public class SecurityConfig {
@Value("${app.security.remember-me-key}")
private String rememberMeKey;
+ private final CustomOidcUserService customOidcUserService;
+ private final CustomOAuth2UserService customOAuth2UserService;
+ private final JwtAuthenticationFilter jwtAuthFilter;
+
+ /** Expose AuthenticationManager so AuthService can authenticate username/password for the JWT login API. */
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+
+ /** jwtAuthFilter is a @Component, so Boot would also auto-register it as a servlet filter for EVERY
+ * request (web pages included). Disable that — it must only run inside the API security chain below. */
+ @Bean
+ public FilterRegistrationBean jwtAuthFilterRegistration(JwtAuthenticationFilter filter) {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>(filter);
+ registration.setEnabled(false);
+ return registration;
+ }
+
// -------------------------------------------------------
// Chain 1: Actuator endpoints — ROLE_ADMIN only
// Kept first (highest priority) so the web/api chains
@@ -68,11 +102,15 @@ public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exce
.requestMatchers("/api-docs/**", "/api/v1/api-docs/**").permitAll()
// Auth endpoints (login, register, refresh-token) — public
.requestMatchers("/api/v1/auth/**").permitAll()
+ .requestMatchers(HttpMethod.GET, "/api/v1/products/**").hasAnyRole("USER", "ADMIN")
// Every other API call must be authenticated
.anyRequest().authenticated()
+ )
+ // No entry point configured → Spring falls back to 403; a missing/invalid token must be 401
+ .exceptionHandling(ex -> ex
+ .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
);
- // TODO: plug in JwtAuthenticationFilter once JWT service is ready:
- // http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
+ http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@@ -116,9 +154,20 @@ public AuthenticationSuccessHandler customAuthenticationSuccessHandler() {
// -------------------------------------------------------
// Chain 3: Web / Thymeleaf MVC — session + form login
// -------------------------------------------------------
+ /** Forces PKCE for every OAuth2 login registration — Twitter/X requires it, and it's a strict
+ * security improvement for Google/Facebook too even though they don't mandate it. */
+ @Bean
+ public OAuth2AuthorizationRequestResolver pkceAuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) {
+ DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(
+ clientRegistrationRepository, OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
+ resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
+ return resolver;
+ }
+
@Bean
@Order(3)
- public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain webSecurityFilterChain(HttpSecurity http,
+ OAuth2AuthorizationRequestResolver pkceAuthorizationRequestResolver) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// Static assets
@@ -127,6 +176,8 @@ public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exce
.requestMatchers("/products/*/rate").hasAnyRole("USER", "ADMIN")
// Public pages
.requestMatchers("/", "/menu", "/products", "/products/**", "/categories/**", "/contact", "/login", "/register", "/error").permitAll()
+ // OAuth2 login redirect/callback endpoints — hit before the user is authenticated
+ .requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll()
// Admin dashboard — ROLE_ADMIN only
.requestMatchers("/admin", "/admin/**").hasRole("ADMIN")
@@ -146,6 +197,17 @@ public SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exce
.failureUrl("/login?error=true")
.permitAll()
)
+ .oauth2Login(oauth2 -> oauth2
+ .loginPage("/login")
+ .successHandler(customAuthenticationSuccessHandler())
+ .failureUrl("/login?error=true")
+ .authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(pkceAuthorizationRequestResolver))
+ .userInfoEndpoint(userInfo -> userInfo
+ .oidcUserService(customOidcUserService)
+ .userService(customOAuth2UserService)
+ )
+ .permitAll()
+ )
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout=true")
diff --git a/src/main/java/com/huuhv/foodsndrinks/controller/admin/CategoryAdminController.java b/src/main/java/com/huuhv/foodsndrinks/controller/admin/CategoryAdminController.java
index c8a92d9..8fc71a6 100644
--- a/src/main/java/com/huuhv/foodsndrinks/controller/admin/CategoryAdminController.java
+++ b/src/main/java/com/huuhv/foodsndrinks/controller/admin/CategoryAdminController.java
@@ -2,6 +2,7 @@
import com.huuhv.foodsndrinks.dto.request.CategoryReqDto;
import com.huuhv.foodsndrinks.dto.response.CategoryResDto;
+import com.huuhv.foodsndrinks.exception.DuplicateResourceException;
import com.huuhv.foodsndrinks.service.CategoryService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -63,7 +64,7 @@ public String processAdd(@Valid @ModelAttribute("categoryReqDto") CategoryReqDto
try {
categoryService.addCategory(categoryReqDto);
ra.addFlashAttribute("successMessage", "Thêm danh mục thành công!");
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | DuplicateResourceException e) {
model.addAttribute("pageTitle", "Thêm Danh Mục");
model.addAttribute("errorMessage", e.getMessage());
return CATEGORY_FORM_VIEW;
@@ -100,7 +101,7 @@ public String processEdit(@PathVariable Long id,
try {
categoryService.updateCategory(categoryReqDto, id);
ra.addFlashAttribute("successMessage", "Cập nhật danh mục thành công!");
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | DuplicateResourceException e) {
model.addAttribute("pageTitle", "Cập Nhật Danh Mục");
model.addAttribute("errorMessage", e.getMessage());
return CATEGORY_FORM_VIEW;
diff --git a/src/main/java/com/huuhv/foodsndrinks/controller/admin/ProductAdminController.java b/src/main/java/com/huuhv/foodsndrinks/controller/admin/ProductAdminController.java
index 8df5e4e..1a6b3fb 100644
--- a/src/main/java/com/huuhv/foodsndrinks/controller/admin/ProductAdminController.java
+++ b/src/main/java/com/huuhv/foodsndrinks/controller/admin/ProductAdminController.java
@@ -3,6 +3,7 @@
import com.huuhv.foodsndrinks.dto.request.ProductReqDto;
import com.huuhv.foodsndrinks.dto.response.ProductResDto;
import com.huuhv.foodsndrinks.entity.Category;
+import com.huuhv.foodsndrinks.exception.DuplicateResourceException;
import com.huuhv.foodsndrinks.service.CategoryService;
import com.huuhv.foodsndrinks.service.ProductService;
import jakarta.validation.Valid;
@@ -78,7 +79,7 @@ public String processAdd(@Valid @ModelAttribute("productReqDto") ProductReqDto d
try {
productService.createProduct(dto, newImages, 0);
ra.addFlashAttribute("successMessage", "Thêm sản phẩm thành công!");
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | DuplicateResourceException e) {
model.addAttribute("pageTitle", "Thêm Sản Phẩm");
model.addAttribute("errorMessage", e.getMessage());
return PRODUCT_FORM_VIEW;
@@ -119,7 +120,7 @@ public String processEdit(@PathVariable Long id,
try {
productService.updateProduct(id, dto, newImages, deleteImageIds, primaryImageId);
ra.addFlashAttribute("successMessage", "Cập nhật sản phẩm thành công!");
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | DuplicateResourceException e) {
try { model.addAttribute("productDetail", productService.getProductDetail(id)); } catch (Exception ignored) {}
model.addAttribute("pageTitle", "Cập Nhật Sản Phẩm");
model.addAttribute("errorMessage", e.getMessage());
diff --git a/src/main/java/com/huuhv/foodsndrinks/controller/admin/UserAdminController.java b/src/main/java/com/huuhv/foodsndrinks/controller/admin/UserAdminController.java
index 46a0893..de24892 100644
--- a/src/main/java/com/huuhv/foodsndrinks/controller/admin/UserAdminController.java
+++ b/src/main/java/com/huuhv/foodsndrinks/controller/admin/UserAdminController.java
@@ -2,6 +2,7 @@
import com.huuhv.foodsndrinks.dto.request.UserEditReqDto;
import com.huuhv.foodsndrinks.dto.response.UserResDto;
+import com.huuhv.foodsndrinks.exception.DuplicateResourceException;
import com.huuhv.foodsndrinks.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@@ -72,7 +73,7 @@ public String processEdit(@PathVariable Long id,
try {
userService.updateUser(id, dto);
ra.addFlashAttribute("successMessage", "Cập nhật tài khoản thành công!");
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | DuplicateResourceException e) {
try { model.addAttribute("userDetail", userService.getUserDetail(id)); } catch (Exception ignored) {}
model.addAttribute("pageTitle", "Chỉnh sửa tài khoản");
model.addAttribute("errorMessage", e.getMessage());
diff --git a/src/main/java/com/huuhv/foodsndrinks/controller/api/AuthApiController.java b/src/main/java/com/huuhv/foodsndrinks/controller/api/AuthApiController.java
new file mode 100644
index 0000000..e9808a2
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/controller/api/AuthApiController.java
@@ -0,0 +1,36 @@
+package com.huuhv.foodsndrinks.controller.api;
+
+import com.huuhv.foodsndrinks.dto.request.LoginReqDto;
+import com.huuhv.foodsndrinks.dto.response.AuthResDto;
+import com.huuhv.foodsndrinks.service.AuthService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/auth")
+@RequiredArgsConstructor
+@Tag(name = "Auth API", description = "Xác thực và cấp JWT token")
+public class AuthApiController {
+
+ private final AuthService authService;
+
+ @Operation(
+ summary = "Đăng nhập lấy JWT token",
+ description = "Xác thực bằng username/email + mật khẩu. Token trả về dùng ở header: Authorization: Bearer "
+ )
+ @ApiResponse(responseCode = "200", description = "Đăng nhập thành công, trả về access token")
+ @ApiResponse(responseCode = "400", description = "Thiếu username hoặc mật khẩu")
+ @ApiResponse(responseCode = "401", description = "Sai thông tin đăng nhập hoặc tài khoản bị khóa")
+ @PostMapping("/login")
+ public ResponseEntity login(@Valid @RequestBody LoginReqDto loginReqDto) {
+ return ResponseEntity.ok(authService.login(loginReqDto));
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/controller/api/ProductApiController.java b/src/main/java/com/huuhv/foodsndrinks/controller/api/ProductApiController.java
index 7b6ca4f..82ee3f8 100644
--- a/src/main/java/com/huuhv/foodsndrinks/controller/api/ProductApiController.java
+++ b/src/main/java/com/huuhv/foodsndrinks/controller/api/ProductApiController.java
@@ -1,26 +1,55 @@
package com.huuhv.foodsndrinks.controller.api;
+import com.huuhv.foodsndrinks.dto.response.PageResDto;
+import com.huuhv.foodsndrinks.dto.response.ProductResDto;
+import com.huuhv.foodsndrinks.service.ProductService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/products")
+@RequiredArgsConstructor
@Tag(name = "Product API", description = "Các API liên quan đến tìm kiếm và hiển thị sản phẩm")
public class ProductApiController {
+ private final ProductService productService;
+
@Operation(
summary = "Lấy danh sách thức ăn/đồ uống",
- description = "Trả về danh sách sản phẩm, hỗ trợ filter theo type, name..."
+ description = "Trả về danh sách sản phẩm đang kinh doanh (phân trang), hỗ trợ filter theo name, categoryId, type (FOOD/DRINK)"
)
@ApiResponse(responseCode = "200", description = "Lấy danh sách thành công")
@GetMapping
- public ResponseEntity> getAllProducts() {
- // Logic here
- return ResponseEntity.ok(java.util.List.of());
+ public ResponseEntity> getAllProducts(
+ @RequestParam(required = false) String name,
+ @RequestParam(required = false) Long categoryId,
+ @RequestParam(required = false) String type,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "10") int size) {
+ int safePage = Math.max(page, 0);
+ int safeSize = Math.clamp(size, 1, 100);
+ // Public listing only exposes products still on sale → isAvailable = "true"
+ Page products = productService.searchProducts(name, categoryId, type, "true", safePage, safeSize);
+ return ResponseEntity.ok(PageResDto.from(products));
+ }
+
+ @Operation(
+ summary = "Lấy chi tiết sản phẩm",
+ description = "Trả về thông tin chi tiết sản phẩm kèm danh sách hình ảnh"
+ )
+ @ApiResponse(responseCode = "200", description = "Lấy chi tiết thành công")
+ @ApiResponse(responseCode = "404", description = "Không tìm thấy sản phẩm")
+ @GetMapping("/{id}")
+ public ResponseEntity getProductDetail(@PathVariable Long id) {
+ return ResponseEntity.ok(productService.getProductDetail(id));
}
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/controller/web/CartController.java b/src/main/java/com/huuhv/foodsndrinks/controller/web/CartController.java
index 6d2e3ba..744e8e3 100644
--- a/src/main/java/com/huuhv/foodsndrinks/controller/web/CartController.java
+++ b/src/main/java/com/huuhv/foodsndrinks/controller/web/CartController.java
@@ -4,6 +4,7 @@
import com.huuhv.foodsndrinks.dto.response.ErrorResponse;
import com.huuhv.foodsndrinks.dto.response.OrderResDto;
import com.huuhv.foodsndrinks.entity.User;
+import com.huuhv.foodsndrinks.exception.ResourceNotFoundException;
import com.huuhv.foodsndrinks.service.OrderService;
import com.huuhv.foodsndrinks.service.UserService;
import lombok.RequiredArgsConstructor;
@@ -46,7 +47,7 @@ public ResponseEntity> addToCart(@RequestParam Long productId,
try {
orderService.addToCart(user, productId, quantity);
return ResponseEntity.ok(buildCartPayload(user.getId()));
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | ResourceNotFoundException e) {
return ResponseEntity.badRequest().body(ErrorResponse.builder().message(e.getMessage()).build());
}
}
@@ -60,7 +61,7 @@ public ResponseEntity> updateCartItem(@PathVariable Long detailId,
try {
orderService.updateCartItemQuantity(user, detailId, quantity);
return ResponseEntity.ok(buildCartPayload(user.getId()));
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | ResourceNotFoundException e) {
return ResponseEntity.badRequest().body(ErrorResponse.builder().message(e.getMessage()).build());
}
}
@@ -73,7 +74,7 @@ public ResponseEntity> removeCartItem(@PathVariable Long detailId,
try {
orderService.removeCartItem(user, detailId);
return ResponseEntity.ok(buildCartPayload(user.getId()));
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | ResourceNotFoundException e) {
return ResponseEntity.badRequest().body(ErrorResponse.builder().message(e.getMessage()).build());
}
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/controller/web/ProfileController.java b/src/main/java/com/huuhv/foodsndrinks/controller/web/ProfileController.java
index 2fd2ee1..ec1acc0 100644
--- a/src/main/java/com/huuhv/foodsndrinks/controller/web/ProfileController.java
+++ b/src/main/java/com/huuhv/foodsndrinks/controller/web/ProfileController.java
@@ -2,6 +2,7 @@
import com.huuhv.foodsndrinks.dto.request.ProfileUpdateReqDto;
import com.huuhv.foodsndrinks.entity.User;
+import com.huuhv.foodsndrinks.exception.DuplicateResourceException;
import com.huuhv.foodsndrinks.service.OrderService;
import com.huuhv.foodsndrinks.service.UserService;
import jakarta.validation.Valid;
@@ -59,7 +60,7 @@ public String updateProfile(@Valid @ModelAttribute("profileUpdateReqDto") Profil
userService.updateProfile(user.getId(), dto);
ra.addFlashAttribute("successMessage", "Cập nhật thông tin thành công!");
return "redirect:/profile";
- } catch (IllegalArgumentException e) {
+ } catch (IllegalArgumentException | DuplicateResourceException e) {
model.addAttribute("errorMessage", e.getMessage());
return PROFILE_EDIT_VIEW;
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/dto/request/LoginReqDto.java b/src/main/java/com/huuhv/foodsndrinks/dto/request/LoginReqDto.java
new file mode 100644
index 0000000..8f07b7c
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/dto/request/LoginReqDto.java
@@ -0,0 +1,20 @@
+package com.huuhv.foodsndrinks.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class LoginReqDto {
+
+ @NotBlank(message = "Username không được để trống.")
+ private String username;
+
+ @NotBlank(message = "Mật khẩu không được để trống.")
+ private String password;
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/dto/response/AuthResDto.java b/src/main/java/com/huuhv/foodsndrinks/dto/response/AuthResDto.java
new file mode 100644
index 0000000..c783a8f
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/dto/response/AuthResDto.java
@@ -0,0 +1,16 @@
+package com.huuhv.foodsndrinks.dto.response;
+
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Builder
+public class AuthResDto {
+ private final String accessToken;
+ @Builder.Default
+ private final String tokenType = "Bearer";
+ /** Thời gian sống của token (giây) */
+ private final long expiresIn;
+ private final String username;
+ private final String role;
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/dto/response/PageResDto.java b/src/main/java/com/huuhv/foodsndrinks/dto/response/PageResDto.java
new file mode 100644
index 0000000..090c51d
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/dto/response/PageResDto.java
@@ -0,0 +1,31 @@
+package com.huuhv.foodsndrinks.dto.response;
+
+import lombok.Getter;
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+/** Stable JSON shape for paged API responses (serializing PageImpl directly is unsupported/unstable). */
+@Getter
+public class PageResDto {
+
+ private final List content;
+ private final int page;
+ private final int size;
+ private final long totalElements;
+ private final int totalPages;
+ private final boolean last;
+
+ private PageResDto(Page p) {
+ this.content = p.getContent();
+ this.page = p.getNumber();
+ this.size = p.getSize();
+ this.totalElements = p.getTotalElements();
+ this.totalPages = p.getTotalPages();
+ this.last = p.isLast();
+ }
+
+ public static PageResDto from(Page page) {
+ return new PageResDto<>(page);
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/exception/AdminExceptionHandler.java b/src/main/java/com/huuhv/foodsndrinks/exception/AdminExceptionHandler.java
index c530659..023d617 100644
--- a/src/main/java/com/huuhv/foodsndrinks/exception/AdminExceptionHandler.java
+++ b/src/main/java/com/huuhv/foodsndrinks/exception/AdminExceptionHandler.java
@@ -1,16 +1,42 @@
package com.huuhv.foodsndrinks.exception;
-import org.springframework.ui.Model;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.servlet.ModelAndView;
+@Slf4j
// Chỉ bắt lỗi phát sinh từ thư mục admin
@ControllerAdvice(basePackages = "com.huuhv.foodsndrinks.controller.admin")
public class AdminExceptionHandler {
+ @ExceptionHandler(ResourceNotFoundException.class)
+ public ModelAndView handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request, HttpServletResponse response) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ log.warn("Resource not found at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorView("error/admin/404", ex.getMessage(), request.getRequestURI());
+ }
+
+ @ExceptionHandler(DuplicateResourceException.class)
+ public ModelAndView handleDuplicateResource(DuplicateResourceException ex, HttpServletRequest request, HttpServletResponse response) {
+ response.setStatus(HttpServletResponse.SC_CONFLICT);
+ log.warn("Resource conflict at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorView("error/admin/409", ex.getMessage(), request.getRequestURI());
+ }
+
@ExceptionHandler(Exception.class)
- public String handleAdminException(Exception ex, Model model) {
- model.addAttribute("errorMessage", ex.getMessage());
- return "error/admin/500"; // Trỏ về thư mục error/admin/
+ public ModelAndView handleAnyException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ log.error("An unexpected error occurred at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
+ return buildErrorView("error/admin/500", "Đã có lỗi xảy ra, vui lòng thử lại sau.", request.getRequestURI());
+ }
+
+ private ModelAndView buildErrorView(String viewName, String message, String path) {
+ ModelAndView modelAndView = new ModelAndView(viewName);
+ modelAndView.addObject("message", message);
+ modelAndView.addObject("path", path);
+ return modelAndView;
}
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/exception/ApiExceptionHandler.java b/src/main/java/com/huuhv/foodsndrinks/exception/ApiExceptionHandler.java
index 0028907..2384135 100644
--- a/src/main/java/com/huuhv/foodsndrinks/exception/ApiExceptionHandler.java
+++ b/src/main/java/com/huuhv/foodsndrinks/exception/ApiExceptionHandler.java
@@ -2,24 +2,78 @@
import com.huuhv.foodsndrinks.dto.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
// Chỉ bắt lỗi phát sinh từ thư mục api.
// @RestControllerAdvice tự động chuyển output thành JSON
@RestControllerAdvice(basePackages = "com.huuhv.foodsndrinks.controller.api")
public class ApiExceptionHandler {
+ // Handle 404 Not found
+ @ExceptionHandler(ResourceNotFoundException.class)
+ public ResponseEntity handleResourceNotFoundException(ResourceNotFoundException ex, HttpServletRequest request) {
+ log.warn("Resource not found at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorResponse(HttpStatus.NOT_FOUND, ex.getMessage(), request);
+ }
+
+ // Handle 400 - lỗi nghiệp vụ (dữ liệu đầu vào không hợp lệ)
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex, HttpServletRequest request) {
+ log.warn("Bad request at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), request);
+ }
+
+ // Handle 400 - lỗi validation của @Valid (trả về map field -> message)
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) {
+ Map validationErrors = new HashMap<>();
+ for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
+ validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
+ }
+ log.warn("Validation error at {}: {}", request.getRequestURI(), validationErrors);
+ return buildErrorResponse(HttpStatus.BAD_REQUEST, validationErrors, request);
+ }
+
+ // Handle 409 Conflict (e.g., duplicate resource)
+ @ExceptionHandler(DuplicateResourceException.class)
+ public ResponseEntity handleResourceConflictException(DuplicateResourceException ex, HttpServletRequest request) {
+ log.warn("Resource conflict at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorResponse(HttpStatus.CONFLICT, ex.getMessage(), request);
+ }
+
+ // Handle 401 - sai thông tin đăng nhập hoặc tài khoản bị khóa
+ @ExceptionHandler({BadCredentialsException.class, DisabledException.class})
+ public ResponseEntity handleAuthenticationException(Exception ex, HttpServletRequest request) {
+ log.warn("Unauthorized access at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage(), request);
+ }
+
+ // Handle other exceptions (500 Internal Server Error)
@ExceptionHandler(Exception.class)
- public ResponseEntity handleApiException(Exception ex, HttpServletRequest request) {
- ErrorResponse error = ErrorResponse.builder()
- .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
- .error(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
- .message(ex.getMessage())
+ public ResponseEntity handleGlobalException(Exception ex, HttpServletRequest request) {
+ log.error("Unhandled exception occurred at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
+ return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred", request);
+ }
+
+ private ResponseEntity buildErrorResponse(HttpStatus status, Object message, HttpServletRequest request) {
+ ErrorResponse errorResponse = ErrorResponse.builder()
+ .status(status.value())
+ .error(status.getReasonPhrase())
+ .message(message)
.path(request.getRequestURI())
.build();
- return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
+ return new ResponseEntity<>(errorResponse, status);
}
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/exception/DuplicateResourceException.java b/src/main/java/com/huuhv/foodsndrinks/exception/DuplicateResourceException.java
new file mode 100644
index 0000000..c11a4bc
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/exception/DuplicateResourceException.java
@@ -0,0 +1,7 @@
+package com.huuhv.foodsndrinks.exception;
+
+public class DuplicateResourceException extends RuntimeException {
+ public DuplicateResourceException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/exception/ResourceNotFoundException.java b/src/main/java/com/huuhv/foodsndrinks/exception/ResourceNotFoundException.java
new file mode 100644
index 0000000..dceae30
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/exception/ResourceNotFoundException.java
@@ -0,0 +1,7 @@
+package com.huuhv.foodsndrinks.exception;
+
+public class ResourceNotFoundException extends RuntimeException {
+ public ResourceNotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/exception/WebExceptionHandler.java b/src/main/java/com/huuhv/foodsndrinks/exception/WebExceptionHandler.java
index 495c19a..fddbf23 100644
--- a/src/main/java/com/huuhv/foodsndrinks/exception/WebExceptionHandler.java
+++ b/src/main/java/com/huuhv/foodsndrinks/exception/WebExceptionHandler.java
@@ -1,16 +1,42 @@
package com.huuhv.foodsndrinks.exception;
-import org.springframework.ui.Model;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.servlet.ModelAndView;
+@Slf4j
// Chỉ bắt lỗi phát sinh từ thư mục web
@ControllerAdvice(basePackages = "com.huuhv.foodsndrinks.controller.web")
public class WebExceptionHandler {
+ @ExceptionHandler(ResourceNotFoundException.class)
+ public ModelAndView handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request, HttpServletResponse response) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ log.warn("Resource not found at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorView("error/404", ex.getMessage(), request.getRequestURI());
+ }
+
+ @ExceptionHandler(DuplicateResourceException.class)
+ public ModelAndView handleDuplicateResource(DuplicateResourceException ex, HttpServletRequest request, HttpServletResponse response) {
+ response.setStatus(HttpServletResponse.SC_CONFLICT);
+ log.warn("Resource conflict at {}: {}", request.getRequestURI(), ex.getMessage());
+ return buildErrorView("error/409", ex.getMessage(), request.getRequestURI());
+ }
+
@ExceptionHandler(Exception.class)
- public String handleWebException(Exception ex, Model model) {
- model.addAttribute("errorMessage", ex.getMessage());
- return "error/500"; // Trỏ về thư mục error/ (bên ngoài)
+ public ModelAndView handleAnyException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
+ response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ log.error("An unexpected error occurred at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
+ return buildErrorView("error/500", "Đã có lỗi xảy ra, vui lòng thử lại sau.", request.getRequestURI());
+ }
+
+ private ModelAndView buildErrorView(String viewName, String message, String path) {
+ ModelAndView modelAndView = new ModelAndView(viewName);
+ modelAndView.addObject("message", message);
+ modelAndView.addObject("path", path);
+ return modelAndView;
}
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/repository/UserRepository.java b/src/main/java/com/huuhv/foodsndrinks/repository/UserRepository.java
index 1b7ff1b..322fb9f 100644
--- a/src/main/java/com/huuhv/foodsndrinks/repository/UserRepository.java
+++ b/src/main/java/com/huuhv/foodsndrinks/repository/UserRepository.java
@@ -1,6 +1,7 @@
package com.huuhv.foodsndrinks.repository;
import com.huuhv.foodsndrinks.entity.User;
+import com.huuhv.foodsndrinks.enums.AuthProvider;
import com.huuhv.foodsndrinks.enums.Role;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -22,6 +23,8 @@ public interface UserRepository extends JpaRepository {
boolean existsByPhoneAndIdNot(String phone, Long id);
Optional findByUsernameOrEmail(String username, String email);
+ Optional findByEmail(String email);
+ Optional findByAuthProviderAndProviderId(AuthProvider authProvider, String providerId);
long countByIsActiveTrue();
diff --git a/src/main/java/com/huuhv/foodsndrinks/security/CustomOAuth2UserService.java b/src/main/java/com/huuhv/foodsndrinks/security/CustomOAuth2UserService.java
new file mode 100644
index 0000000..c53a354
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/security/CustomOAuth2UserService.java
@@ -0,0 +1,91 @@
+package com.huuhv.foodsndrinks.security;
+
+import com.huuhv.foodsndrinks.entity.User;
+import com.huuhv.foodsndrinks.enums.AuthProvider;
+import com.huuhv.foodsndrinks.service.UserService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Facebook and Twitter sign-in (plain OAuth2, not OIDC). Neither provider's user-info response
+ * can be trusted to expose a real name attribute usable as {@code Authentication#getName()}
+ * (Twitter nests everything under "data" and never exposes an email at all), so this always
+ * injects a synthetic "local_username" attribute pointing at the resolved local account instead —
+ * that's what {@code UserService.getCurrentUser(principal.getName())} ends up looking up.
+ */
+@Service
+@RequiredArgsConstructor
+public class CustomOAuth2UserService extends DefaultOAuth2UserService {
+
+ private static final String NAME_ATTRIBUTE_KEY = "local_username";
+
+ private final UserService userService;
+
+ @Override
+ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
+ OAuth2User oauth2User = super.loadUser(userRequest);
+ String registrationId = userRequest.getClientRegistration().getRegistrationId();
+
+ ProviderProfile profile = switch (registrationId) {
+ case "facebook" -> readFacebookProfile(oauth2User.getAttributes());
+ case "twitter" -> readTwitterProfile(oauth2User.getAttributes());
+ default -> throw new OAuth2AuthenticationException("Nhà cung cấp đăng nhập không được hỗ trợ: " + registrationId);
+ };
+
+ User localUser = userService.provisionOAuthUser(
+ profile.provider(), profile.providerId(), profile.email(), profile.name(), profile.avatarUrl());
+
+ Map attributes = new HashMap<>(profile.attributes());
+ attributes.put(NAME_ATTRIBUTE_KEY, localUser.getUsername());
+
+ List authorities = List.of(new SimpleGrantedAuthority(localUser.getRole().name()));
+ return new DefaultOAuth2User(authorities, attributes, NAME_ATTRIBUTE_KEY);
+ }
+
+ private ProviderProfile readFacebookProfile(Map attributes) {
+ String pictureUrl = null;
+ if (attributes.get("picture") instanceof Map, ?> pictureWrapper
+ && pictureWrapper.get("data") instanceof Map, ?> pictureData) {
+ pictureUrl = (String) pictureData.get("url");
+ }
+ return new ProviderProfile(
+ AuthProvider.FACEBOOK,
+ String.valueOf(attributes.get("id")),
+ (String) attributes.get("email"),
+ (String) attributes.getOrDefault("name", "Facebook User"),
+ pictureUrl,
+ attributes
+ );
+ }
+
+ @SuppressWarnings("unchecked")
+ private ProviderProfile readTwitterProfile(Map attributes) {
+ // Twitter's /2/users/me nests every field under "data" instead of returning it flat.
+ Map data = attributes.get("data") instanceof Map, ?> nested
+ ? (Map) nested
+ : Map.of();
+ return new ProviderProfile(
+ AuthProvider.TWITTER,
+ String.valueOf(data.get("id")),
+ null, // Twitter's API does not expose an email address via OAuth2
+ (String) data.getOrDefault("name", "Twitter User"),
+ (String) data.get("profile_image_url"),
+ data
+ );
+ }
+
+ private record ProviderProfile(AuthProvider provider, String providerId, String email, String name,
+ String avatarUrl, Map attributes) {
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/security/CustomOidcUserService.java b/src/main/java/com/huuhv/foodsndrinks/security/CustomOidcUserService.java
new file mode 100644
index 0000000..9f5c2da
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/security/CustomOidcUserService.java
@@ -0,0 +1,45 @@
+package com.huuhv.foodsndrinks.security;
+
+import com.huuhv.foodsndrinks.entity.User;
+import com.huuhv.foodsndrinks.enums.AuthProvider;
+import com.huuhv.foodsndrinks.service.UserService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
+import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * Google sign-in (OIDC). Google reliably verifies the account's email, so it doubles as the
+ * account-matching key and as the {@link org.springframework.security.core.Authentication#getName()}
+ * value — this lets {@code UserService.getCurrentUser(principal.getName())} resolve the same way
+ * it does for form login.
+ */
+@Service
+@RequiredArgsConstructor
+public class CustomOidcUserService extends OidcUserService {
+
+ private final UserService userService;
+
+ @Override
+ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
+ OidcUser oidcUser = super.loadUser(userRequest);
+
+ User localUser = userService.provisionOAuthUser(
+ AuthProvider.GOOGLE,
+ oidcUser.getSubject(),
+ oidcUser.getEmail(),
+ oidcUser.getFullName(),
+ oidcUser.getPicture()
+ );
+
+ List authorities = List.of(new SimpleGrantedAuthority(localUser.getRole().name()));
+ return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(), "email");
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/security/JwtAuthenticationFilter.java b/src/main/java/com/huuhv/foodsndrinks/security/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..10f9936
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/security/JwtAuthenticationFilter.java
@@ -0,0 +1,52 @@
+package com.huuhv.foodsndrinks.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtUtil jwtUtil;
+ private final CustomUserDetailsService userDetailsService;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ String header = request.getHeader("Authorization");
+ if (header != null && header.startsWith("Bearer ")) {
+ String token = header.substring(7);
+
+ try {
+ // Requires the extractUsername method in JwtUtil
+ String username = jwtUtil.extractUsername(token);
+
+ if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
+ UserDetails userDetails = userDetailsService.loadUserByUsername(username);
+ // Requires the validateToken method in JwtUtil
+ if (jwtUtil.validateToken(token)) {
+ UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
+ userDetails, null, userDetails.getAuthorities());
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ }
+ }
+ } catch (Exception ex) {
+ log.debug("Invalid JWT token: {}", ex.getMessage());
+ }
+
+ }
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/security/JwtUtil.java b/src/main/java/com/huuhv/foodsndrinks/security/JwtUtil.java
new file mode 100644
index 0000000..9f081a5
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/security/JwtUtil.java
@@ -0,0 +1,65 @@
+package com.huuhv.foodsndrinks.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+@Component
+public class JwtUtil {
+ @Value("${jwt.secret}")
+ private String secretString;
+
+ @Value("${jwt.expiration}")
+ private long expirationTime;
+
+ private SecretKey signingKey;
+
+ @PostConstruct
+ private void initKey() {
+ this.signingKey = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private SecretKey getKey() { return signingKey; }
+
+ /** Thời gian sống của token (milliseconds) */
+ public long getExpirationTime() { return expirationTime; }
+
+ // Generate a secret key for signing the JWT
+ public String generateToken(String username, String role) {
+ return Jwts.builder()
+ .subject(username)
+ .claim("role", role)
+ .issuedAt(new Date())
+ .expiration(new Date(System.currentTimeMillis() + expirationTime))
+ .signWith(getKey())
+ .compact();
+ }
+
+ public String extractUsername(String token) {
+ return parseClaims(token).getSubject();
+ }
+
+ public boolean validateToken(String token) {
+ try {
+ parseClaims(token);
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private Claims parseClaims(String token) {
+ return Jwts.parser()
+ .verifyWith((SecretKey) getKey())
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/service/AuthService.java b/src/main/java/com/huuhv/foodsndrinks/service/AuthService.java
new file mode 100644
index 0000000..280c6bf
--- /dev/null
+++ b/src/main/java/com/huuhv/foodsndrinks/service/AuthService.java
@@ -0,0 +1,43 @@
+package com.huuhv.foodsndrinks.service;
+
+import com.huuhv.foodsndrinks.dto.request.LoginReqDto;
+import com.huuhv.foodsndrinks.dto.response.AuthResDto;
+import com.huuhv.foodsndrinks.security.JwtUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+ private final AuthenticationManager authenticationManager;
+ private final JwtUtil jwtUtil;
+
+ /**
+ * Xác thực username/password và cấp JWT.
+ * Sai thông tin đăng nhập → AuthenticationManager ném BadCredentialsException,
+ * tài khoản bị khóa → DisabledException; cả hai được ApiExceptionHandler map về 401.
+ */
+ public AuthResDto login(LoginReqDto dto) {
+ Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(dto.getUsername(), dto.getPassword()));
+
+ UserDetails userDetails = (UserDetails) authentication.getPrincipal();
+ String role = userDetails.getAuthorities().iterator().next().getAuthority();
+ String token = jwtUtil.generateToken(userDetails.getUsername(), role);
+
+ log.info("User '{}' logged in via API", userDetails.getUsername());
+ return AuthResDto.builder()
+ .accessToken(token)
+ .expiresIn(jwtUtil.getExpirationTime() / 1000)
+ .username(userDetails.getUsername())
+ .role(role)
+ .build();
+ }
+}
diff --git a/src/main/java/com/huuhv/foodsndrinks/service/CategoryService.java b/src/main/java/com/huuhv/foodsndrinks/service/CategoryService.java
index 8a9ed56..8314f33 100644
--- a/src/main/java/com/huuhv/foodsndrinks/service/CategoryService.java
+++ b/src/main/java/com/huuhv/foodsndrinks/service/CategoryService.java
@@ -4,9 +4,12 @@
import com.huuhv.foodsndrinks.dto.response.CategoryResDto;
import com.huuhv.foodsndrinks.entity.Category;
import com.huuhv.foodsndrinks.enums.CategoryType;
+import com.huuhv.foodsndrinks.exception.DuplicateResourceException;
+import com.huuhv.foodsndrinks.exception.ResourceNotFoundException;
import com.huuhv.foodsndrinks.repository.CategoryRepository;
import com.huuhv.foodsndrinks.utils.SlugUtils;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.stereotype.Service;
@@ -19,6 +22,7 @@
import java.util.List;
import java.util.Locale;
+@Slf4j
@Service
@RequiredArgsConstructor
public class CategoryService {
@@ -47,13 +51,13 @@ public Page searchCategories(String name, String type, String de
@Transactional(readOnly = true)
public Category getCategoryBySlug(String slug) {
return categoryRepository.findBySlug(slug)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy danh mục!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy danh mục!"));
}
@Transactional(readOnly = true)
public CategoryReqDto getCategoryById(Long id) {
Category category = categoryRepository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy danh mục!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy danh mục!"));
CategoryReqDto dto = new CategoryReqDto();
dto.setId(category.getId());
@@ -69,7 +73,8 @@ public void addCategory(CategoryReqDto categoryReqDto) {
String normalizedName = normalizeName(categoryReqDto.getName());
if (categoryRepository.existsByName(normalizedName)) {
- throw new IllegalArgumentException("Tên danh mục đã tồn tại!");
+ log.error("Tên danh mục đã tồn tại: {}", normalizedName);
+ throw new DuplicateResourceException("Tên danh mục đã tồn tại!");
}
Category category = new Category();
@@ -84,12 +89,12 @@ public void addCategory(CategoryReqDto categoryReqDto) {
@Transactional
public void updateCategory(CategoryReqDto updateCategoryReqDto, Long categoryId) {
Category category = categoryRepository.findById(categoryId)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy danh mục!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy danh mục!"));
String normalizedName = normalizeName(updateCategoryReqDto.getName());
if (categoryRepository.existsByNameAndIdNot(normalizedName, categoryId)) {
- throw new IllegalArgumentException("Tên danh mục đã tồn tại!");
+ throw new DuplicateResourceException("Tên danh mục đã tồn tại!");
}
category.setName(normalizedName);
@@ -105,7 +110,7 @@ public void deleteCategory(Long id) {
try {
categoryRepository.deleteById(id);
} catch (EmptyResultDataAccessException e) {
- throw new IllegalArgumentException("Danh mục không tồn tại!");
+ throw new ResourceNotFoundException("Danh mục không tồn tại!");
} catch (DataIntegrityViolationException e) {
throw new IllegalArgumentException("Không thể xóa danh mục này vì đang được sử dụng.");
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/service/OrderService.java b/src/main/java/com/huuhv/foodsndrinks/service/OrderService.java
index b2ad183..6bba62e 100644
--- a/src/main/java/com/huuhv/foodsndrinks/service/OrderService.java
+++ b/src/main/java/com/huuhv/foodsndrinks/service/OrderService.java
@@ -6,6 +6,7 @@
import com.huuhv.foodsndrinks.entity.Product;
import com.huuhv.foodsndrinks.entity.User;
import com.huuhv.foodsndrinks.enums.OrderStatus;
+import com.huuhv.foodsndrinks.exception.ResourceNotFoundException;
import com.huuhv.foodsndrinks.repository.OrderDetailRepository;
import com.huuhv.foodsndrinks.repository.OrderRepository;
import com.huuhv.foodsndrinks.repository.ProductImageRepository;
@@ -51,7 +52,7 @@ public Page searchOrders(String statusStr, String keyword,
public OrderResDto getOrderDetail(Long id) {
// 1 query: order + user + orderDetails + products (FETCH JOIN, no lazy-load loops)
Order order = orderRepository.findByIdWithDetailsAndProducts(id)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy đơn hàng #" + id));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy đơn hàng #" + id));
return buildOrderResDto(order, order.getOrderDetails());
}
@@ -75,7 +76,7 @@ public int getCartItemCount(Long userId) {
public void addToCart(User user, Long productId, int quantity) {
int qty = Math.max(quantity, 1);
Product product = productRepository.findById(productId)
- .orElseThrow(() -> new IllegalArgumentException("Sản phẩm không tồn tại!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Sản phẩm không tồn tại!"));
if (!Boolean.TRUE.equals(product.getIsAvailable())) {
throw new IllegalArgumentException("Sản phẩm hiện không còn kinh doanh!");
}
@@ -160,14 +161,14 @@ public Page getOrderHistoryForUser(Long userId, int page, int size)
@Transactional(readOnly = true)
public OrderResDto getOrderDetailForUser(Long orderId, Long userId) {
Order order = orderRepository.findByIdAndUserIdWithDetailsAndProducts(orderId, userId)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy đơn hàng #" + orderId));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy đơn hàng #" + orderId));
return buildOrderResDto(order, order.getOrderDetails());
}
@Transactional
public void updateStatus(Long id, OrderStatus newStatus) {
Order order = orderRepository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy đơn hàng #" + id));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy đơn hàng #" + id));
OrderStatus current = order.getStatus();
if (current == newStatus) return;
@@ -223,7 +224,7 @@ private Order createEmptyCart(User user) {
private OrderDetail requireOwnedCartItem(User user, Long orderDetailId) {
OrderDetail detail = orderDetailRepository.findById(orderDetailId)
- .orElseThrow(() -> new IllegalArgumentException("Sản phẩm trong giỏ không tồn tại!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Sản phẩm trong giỏ không tồn tại!"));
Order order = detail.getOrder();
if (order.getUser() == null || !order.getUser().getId().equals(user.getId())
diff --git a/src/main/java/com/huuhv/foodsndrinks/service/ProductService.java b/src/main/java/com/huuhv/foodsndrinks/service/ProductService.java
index 0749710..f8c3cfe 100644
--- a/src/main/java/com/huuhv/foodsndrinks/service/ProductService.java
+++ b/src/main/java/com/huuhv/foodsndrinks/service/ProductService.java
@@ -6,6 +6,8 @@
import com.huuhv.foodsndrinks.entity.Product;
import com.huuhv.foodsndrinks.entity.ProductImage;
import com.huuhv.foodsndrinks.enums.ProductType;
+import com.huuhv.foodsndrinks.exception.DuplicateResourceException;
+import com.huuhv.foodsndrinks.exception.ResourceNotFoundException;
import com.huuhv.foodsndrinks.repository.CategoryRepository;
import com.huuhv.foodsndrinks.repository.ProductImageRepository;
import com.huuhv.foodsndrinks.repository.ProductRepository;
@@ -101,7 +103,7 @@ public void createProduct(ProductReqDto dto,
int primaryIndex) {
String normalizedName = normalizeName(dto.getName());
if (productRepository.existsByName(normalizedName)) {
- throw new IllegalArgumentException("Tên sản phẩm đã tồn tại!");
+ throw new DuplicateResourceException("Tên sản phẩm đã tồn tại!");
}
Category category = findCategory(dto.getCategoryId());
@@ -129,7 +131,7 @@ public void updateProduct(Long id,
String normalizedName = normalizeName(dto.getName());
if (productRepository.existsByNameAndIdNot(normalizedName, id)) {
- throw new IllegalArgumentException("Tên sản phẩm đã tồn tại!");
+ throw new DuplicateResourceException("Tên sản phẩm đã tồn tại!");
}
// Delete marked images
@@ -215,13 +217,13 @@ private void saveImages(Product product, List files, int primaryI
private Product findById(Long id) {
return productRepository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy sản phẩm!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy sản phẩm!"));
}
private Category findCategory(Long categoryId) {
if (categoryId == null) return null;
return categoryRepository.findById(categoryId)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy danh mục!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy danh mục!"));
}
private String normalizeName(String name) {
@@ -288,7 +290,7 @@ public Map getPrimaryImageUrls(List productIds) {
// Get product by slug for user
public Product getProductBySlug(String slug) {
return productRepository.findBySlugAndIsAvailableTrue(slug)
- .orElseThrow(() -> new IllegalArgumentException("Sản phẩm không tồn tại hoặc đã ngừng kinh doanh!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Sản phẩm không tồn tại hoặc đã ngừng kinh doanh!"));
}
}
diff --git a/src/main/java/com/huuhv/foodsndrinks/service/RatingService.java b/src/main/java/com/huuhv/foodsndrinks/service/RatingService.java
index e5ff116..b00ca94 100644
--- a/src/main/java/com/huuhv/foodsndrinks/service/RatingService.java
+++ b/src/main/java/com/huuhv/foodsndrinks/service/RatingService.java
@@ -4,6 +4,7 @@
import com.huuhv.foodsndrinks.entity.Product;
import com.huuhv.foodsndrinks.entity.Rating;
import com.huuhv.foodsndrinks.entity.User;
+import com.huuhv.foodsndrinks.exception.ResourceNotFoundException;
import com.huuhv.foodsndrinks.repository.ProductRepository;
import com.huuhv.foodsndrinks.repository.RatingRepository;
import lombok.RequiredArgsConstructor;
@@ -41,7 +42,7 @@ public void rateProduct(User user, Long productId, Byte stars, String comment) {
throw new IllegalArgumentException("Vui lòng chọn số sao từ 1 đến 5!");
}
Product product = productRepository.findById(productId)
- .orElseThrow(() -> new IllegalArgumentException("Sản phẩm không tồn tại!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Sản phẩm không tồn tại!"));
Rating rating = ratingRepository.findByUserIdAndProductId(user.getId(), productId)
.orElseGet(() -> Rating.builder().user(user).product(product).build());
diff --git a/src/main/java/com/huuhv/foodsndrinks/service/SuggestionService.java b/src/main/java/com/huuhv/foodsndrinks/service/SuggestionService.java
index a29e4d6..8383031 100644
--- a/src/main/java/com/huuhv/foodsndrinks/service/SuggestionService.java
+++ b/src/main/java/com/huuhv/foodsndrinks/service/SuggestionService.java
@@ -5,6 +5,7 @@
import com.huuhv.foodsndrinks.entity.Suggestion;
import com.huuhv.foodsndrinks.entity.User;
import com.huuhv.foodsndrinks.enums.SuggestionStatus;
+import com.huuhv.foodsndrinks.exception.ResourceNotFoundException;
import com.huuhv.foodsndrinks.repository.SuggestionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
@@ -97,7 +98,7 @@ public void updateStatus(Long id, SuggestionStatus newStatus) {
private Suggestion findById(Long id) {
return suggestionRepository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy góp ý #" + id));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy góp ý #" + id));
}
private static SuggestionStatus parseStatus(String value) {
diff --git a/src/main/java/com/huuhv/foodsndrinks/service/UserService.java b/src/main/java/com/huuhv/foodsndrinks/service/UserService.java
index a5f187a..ad663eb 100644
--- a/src/main/java/com/huuhv/foodsndrinks/service/UserService.java
+++ b/src/main/java/com/huuhv/foodsndrinks/service/UserService.java
@@ -7,7 +7,10 @@
import com.huuhv.foodsndrinks.entity.User;
import com.huuhv.foodsndrinks.enums.AuthProvider;
import com.huuhv.foodsndrinks.enums.Role;
+import com.huuhv.foodsndrinks.exception.DuplicateResourceException;
+import com.huuhv.foodsndrinks.exception.ResourceNotFoundException;
import com.huuhv.foodsndrinks.repository.UserRepository;
+import com.huuhv.foodsndrinks.utils.SlugUtils;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -54,6 +57,60 @@ public void registerNewUser(RegisterReqDto dto) {
log.info("User registered: {}", dto.getUsername());
}
+ // -------------------------------------------------------
+ // OAuth2 login (Google/Facebook/Twitter) — find-or-create the local account
+ // -------------------------------------------------------
+
+ /**
+ * Resolves the local {@link User} behind an OAuth2 identity, creating one on first login.
+ * Match order: (provider, providerId) first, then by email (lets a Google/Facebook login
+ * attach to an existing account that shares the same — provider-verified — email).
+ *
+ * @param email provider-supplied email; null when the provider doesn't expose one (e.g. Twitter)
+ * @param fullName provider-supplied display name; falls back to a generic label if blank
+ * @param avatarUrl provider-supplied profile picture URL, or null
+ */
+ @Transactional
+ public User provisionOAuthUser(AuthProvider provider, String providerId, String email, String fullName, String avatarUrl) {
+ User existing = userRepository.findByAuthProviderAndProviderId(provider, providerId).orElse(null);
+ if (existing != null) return existing;
+
+ if (!blank(email)) {
+ User byEmail = userRepository.findByEmail(email).orElse(null);
+ if (byEmail != null) return byEmail;
+ }
+
+ User user = new User();
+ user.setUsername(generateUniqueUsername(fullName, provider, providerId));
+ user.setEmail(!blank(email) ? email : placeholderEmail(provider, providerId));
+ user.setFullName(!blank(fullName) ? fullName : "Người dùng " + provider.name());
+ user.setAvatarUrl(avatarUrl);
+ user.setRole(Role.ROLE_USER);
+ user.setAuthProvider(provider);
+ user.setProviderId(providerId);
+ user.setIsActive(true);
+ userRepository.save(user);
+ log.info("Provisioned new {} user: {}", provider, user.getUsername());
+ return user;
+ }
+
+ private String generateUniqueUsername(String fullName, AuthProvider provider, String providerId) {
+ String base = SlugUtils.toSlug(fullName);
+ if (blank(base)) base = provider.name().toLowerCase(Locale.ROOT) + "-" + providerId;
+
+ String candidate = base;
+ int i = 1;
+ while (userRepository.existsByUsername(candidate)) {
+ candidate = base + "-" + (++i);
+ }
+ return candidate;
+ }
+
+ /** users.email is NOT NULL UNIQUE — synthesize a unique placeholder for providers (e.g. Twitter) that give no email. */
+ private String placeholderEmail(AuthProvider provider, String providerId) {
+ return provider.name().toLowerCase(Locale.ROOT) + "-" + providerId + "@no-email.fnbstore.local";
+ }
+
// -------------------------------------------------------
// Web: resolve logged-in principal → domain User
// -------------------------------------------------------
@@ -61,7 +118,7 @@ public void registerNewUser(RegisterReqDto dto) {
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public User getCurrentUser(String usernameOrEmail) {
return userRepository.findByUsernameOrEmail(usernameOrEmail, usernameOrEmail)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy người dùng!"));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy người dùng!"));
}
@org.springframework.transaction.annotation.Transactional(readOnly = true)
@@ -79,10 +136,10 @@ public void updateProfile(Long id, ProfileUpdateReqDto dto) {
User user = findById(id);
if (userRepository.existsByEmailAndIdNot(dto.getEmail(), id)) {
- throw new IllegalArgumentException("Email đã được sử dụng bởi tài khoản khác!");
+ throw new DuplicateResourceException("Email đã được sử dụng bởi tài khoản khác!");
}
if (userRepository.existsByPhoneAndIdNot(dto.getPhone(), id)) {
- throw new IllegalArgumentException("Số điện thoại đã được sử dụng bởi tài khoản khác!");
+ throw new DuplicateResourceException("Số điện thoại đã được sử dụng bởi tài khoản khác!");
}
if (!blank(dto.getNewPassword())) {
@@ -151,10 +208,10 @@ public void updateUser(Long id, UserEditReqDto dto) {
User user = findById(id);
if (userRepository.existsByEmailAndIdNot(dto.getEmail(), id)) {
- throw new IllegalArgumentException("Email đã được sử dụng bởi tài khoản khác!");
+ throw new DuplicateResourceException("Email đã được sử dụng bởi tài khoản khác!");
}
if (userRepository.existsByPhoneAndIdNot(dto.getPhone(), id)) {
- throw new IllegalArgumentException("Số điện thoại đã được sử dụng bởi tài khoản khác!");
+ throw new DuplicateResourceException("Số điện thoại đã được sử dụng bởi tài khoản khác!");
}
user.setFullName(dto.getFullName());
@@ -188,7 +245,7 @@ public void toggleActive(Long id) {
private User findById(Long id) {
return userRepository.findById(id)
- .orElseThrow(() -> new IllegalArgumentException("Không tìm thấy user #" + id));
+ .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy user #" + id));
}
private static Role parseRole(String value) {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 45b65a8..98aee1c 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -15,6 +15,48 @@ spring:
starttls:
enable: true
+ # Social login (see login.html "Đăng nhập bằng..." buttons). Client id/secret come from each
+ # provider's developer console — set the real values via env vars before going live; the
+ # "changeme-*" defaults just let the app boot without them configured.
+ security:
+ oauth2:
+ client:
+ registration:
+ google:
+ client-id: ${GOOGLE_CLIENT_ID:changeme-google-client-id}
+ client-secret: ${GOOGLE_CLIENT_SECRET:changeme-google-client-secret}
+ scope: openid, profile, email
+ facebook:
+ client-id: ${FACEBOOK_CLIENT_ID:changeme-facebook-client-id}
+ client-secret: ${FACEBOOK_CLIENT_SECRET:changeme-facebook-client-secret}
+ scope: public_profile, email
+ twitter:
+ client-id: ${TWITTER_CLIENT_ID:changeme-twitter-client-id}
+ client-secret: ${TWITTER_CLIENT_SECRET:changeme-twitter-client-secret}
+ authorization-grant-type: authorization_code
+ redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
+ client-authentication-method: client_secret_basic
+ scope: users.read, tweet.read
+ provider:
+ google:
+ # Google verifies email ownership, so use it as the account-matching / principal name key
+ user-name-attribute: email
+ facebook:
+ # Spring's Facebook default only requests id,name,email — add the profile picture too
+ user-info-uri: https://graph.facebook.com/me?fields=id,name,email,picture
+ twitter:
+ # Twitter/X isn't one of Spring's built-in providers (CommonOAuth2Provider) — fully manual.
+ # Its OAuth2 authorization server also mandates PKCE regardless of client type (see
+ # SecurityConfig#pkceAuthorizationRequestResolver).
+ authorization-uri: https://twitter.com/i/oauth2/authorize
+ token-uri: https://api.twitter.com/2/oauth2/token
+ # user.fields=profile_image_url so the response includes an avatar URL
+ user-info-uri: https://api.twitter.com/2/users/me?user.fields=profile_image_url
+ # The response nests every field under a top-level "data" object — see
+ # CustomOAuth2UserService, which unwraps it. "data" itself is just used to satisfy
+ # DefaultOAuth2UserService's "name attribute must exist" check.
+ user-name-attribute: data
+
server:
port: 8080 # Default port
diff --git a/src/main/resources/db/migration/V20260702000001__alter_users_phone_nullable.sql b/src/main/resources/db/migration/V20260702000001__alter_users_phone_nullable.sql
new file mode 100644
index 0000000..bd57e59
--- /dev/null
+++ b/src/main/resources/db/migration/V20260702000001__alter_users_phone_nullable.sql
@@ -0,0 +1,3 @@
+-- OAuth2 sign-ups (Google/Facebook/Twitter) don't supply a phone number.
+-- The entity already maps `phone` as nullable; the table just hadn't caught up.
+ALTER TABLE users MODIFY COLUMN phone VARCHAR(20) NULL;
diff --git a/src/test/java/com/huuhv/foodsndrinks/controller/api/AuthProductApiTest.java b/src/test/java/com/huuhv/foodsndrinks/controller/api/AuthProductApiTest.java
new file mode 100644
index 0000000..6a29cdd
--- /dev/null
+++ b/src/test/java/com/huuhv/foodsndrinks/controller/api/AuthProductApiTest.java
@@ -0,0 +1,113 @@
+package com.huuhv.foodsndrinks.controller.api;
+
+import com.huuhv.foodsndrinks.entity.User;
+import com.huuhv.foodsndrinks.repository.UserRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/** End-to-end flow of the JWT API: login → token → call protected product endpoints. */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Transactional // every test rolls back — the test user never persists in the dev DB
+class AuthProductApiTest {
+
+ private static final String USERNAME = "apitester";
+ private static final String PASSWORD = "apitest123";
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private UserRepository userRepository;
+ @Autowired private PasswordEncoder passwordEncoder;
+
+ @BeforeEach
+ void createTestUser() {
+ userRepository.save(User.builder()
+ .username(USERNAME)
+ .email("apitester@example.com")
+ .password(passwordEncoder.encode(PASSWORD))
+ .fullName("API Tester")
+ .build());
+ }
+
+ private String loginBody(String username, String password) {
+ return "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}";
+ }
+
+ private String obtainToken() throws Exception {
+ String response = mockMvc.perform(post("/api/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(loginBody(USERNAME, PASSWORD)))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ // accessToken is the first field of AuthResDto — extract without extra deps
+ return response.replaceAll(".*\"accessToken\":\"([^\"]+)\".*", "$1");
+ }
+
+ @Test
+ void login_withValidCredentials_returnsToken() throws Exception {
+ mockMvc.perform(post("/api/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(loginBody(USERNAME, PASSWORD)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.accessToken").isNotEmpty())
+ .andExpect(jsonPath("$.tokenType").value("Bearer"))
+ .andExpect(jsonPath("$.username").value(USERNAME))
+ .andExpect(jsonPath("$.role").value("ROLE_USER"));
+ }
+
+ @Test
+ void login_withWrongPassword_returns401() throws Exception {
+ mockMvc.perform(post("/api/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(loginBody(USERNAME, "wrong-password")))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.status").value(401));
+ }
+
+ @Test
+ void login_withBlankFields_returns400WithFieldErrors() throws Exception {
+ mockMvc.perform(post("/api/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(loginBody("", "")))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message.username").isNotEmpty())
+ .andExpect(jsonPath("$.message.password").isNotEmpty());
+ }
+
+ @Test
+ void getProducts_withoutToken_returns401() throws Exception {
+ mockMvc.perform(get("/api/v1/products"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void getProducts_withValidToken_returnsPagedList() throws Exception {
+ String token = obtainToken();
+ mockMvc.perform(get("/api/v1/products")
+ .header("Authorization", "Bearer " + token))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content").isArray())
+ .andExpect(jsonPath("$.page").value(0))
+ .andExpect(jsonPath("$.totalElements").isNumber());
+ }
+
+ @Test
+ void getProductDetail_notFound_returns404() throws Exception {
+ String token = obtainToken();
+ mockMvc.perform(get("/api/v1/products/999999999")
+ .header("Authorization", "Bearer " + token))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.status").value(404));
+ }
+}