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)); + } +}