REST API đặt tour du lịch: tìm kiếm/lọc tour, xem chi tiết & ngày khởi hành, đặt và huỷ đơn, đánh giá tour. Xây bằng Spring Boot 4.1 / Java 21 / PostgreSQL, xác thực JWT.
- Base URL:
http://localhost:8080/api/v1 - Swagger UI:
http://localhost:8080/swagger-ui.html(tắt ở profileprod) - Ứng dụng không dùng
context-path: tiền tố/api/v1doApiPathConfiggắn riêng cho packagedemo.tripgo.controller, để khu quản trị/admin/**nằm ngoài tiền tố đó. - Bộ request mẫu:
docs/api.http
| Nhóm | Mô tả |
|---|---|
| F1 | Đăng ký, đăng nhập (JWT), xem thông tin tài khoản |
| F2 | Danh sách tour: tìm kiếm, lọc (điểm đến/loại/giá/số ngày/đánh giá), sắp xếp, phân trang |
| F3 | Chi tiết tour: điểm đến, ảnh, lịch trình theo ngày, highlights/included/excluded |
| F4 | Ngày khởi hành & số chỗ còn trống (lọc theo tháng) |
| F5 | Đặt tour, danh sách đơn của tôi, chi tiết đơn, huỷ đơn |
| F6 | Đánh giá tour (xem danh sách công khai, tạo đánh giá khi đã đăng nhập) |
| F7 | Danh sách điểm đến (kèm số tour) và loại hình tour (kèm nhãn tiếng Việt) |
| F8 | Wishlist: lưu / bỏ / xem tour quan tâm của riêng mình |
| Method | Endpoint | Quyền | Mô tả |
|---|---|---|---|
| POST | /auth/register |
public | Đăng ký → 201, trả luôn { token, user } |
| POST | /auth/login |
public | Đăng nhập → { token, user } |
| GET | /auth/me |
user | Thông tin tài khoản hiện tại |
| GET | /tours |
public | Danh sách tour (filter/sort/paging) |
| GET | /tours/{slug} |
public | Chi tiết tour (nhận cả id dạng số), kèm startDates |
| GET | /tours/{id}/availability |
public | Ngày khởi hành & chỗ trống |
| GET | /tours/{id}/reviews |
public | Đánh giá của tour (phân trang) |
| POST | /tours/{id}/reviews |
user đã đặt tour | Tạo đánh giá → 201 |
| GET | /destinations |
public | Điểm đến + ảnh + số tour |
| GET | /categories |
public | Loại hình tour + nhãn tiếng Việt (bảng categories) |
| POST | /bookings |
user | Đặt tour → 201 |
| GET | /bookings |
user | Đơn của tôi (phân trang) |
| GET | /bookings/{id} |
user | Chi tiết đơn của tôi |
| PATCH | /bookings/{id}/cancel |
user | Huỷ đơn |
| GET | /wishlist |
user | Tour đã lưu của tôi |
| POST | /wishlist |
user | Thêm tour vào wishlist |
| DELETE | /wishlist/{tourId} |
user | Bỏ tour khỏi wishlist → { message, removed, tourId } |
Tham số của GET /tours: q, destination (slug), category
(beach|mountain|city|trekking|cruise|cultural), minPrice, maxPrice, duration, rating,
sort (newest|price_asc|price_desc|rating, mặc định newest; rating_desc là bí danh của rating), page (từ 1), limit
(mặc định 10, tối đa 50).
Định dạng response. Danh sách phân trang trả { data, total, page, limit }, danh sách thường
trả { data: [...] }, GET đơn lẻ trả object DTO. Mọi lỗi dùng chung một khuôn:
{ "error": { "code": "VALIDATION", "message": "Dữ liệu không hợp lệ",
"fields": { "email": "Email không hợp lệ" } } }Message trả về bằng tiếng Việt. code dùng tên HttpStatus, trừ hai trường hợp hợp đồng chỉ
định riêng: INVALID_CREDENTIALS (sai email/mật khẩu) và VALIDATION (lỗi bean-validation).
fields chỉ xuất hiện ở lỗi bean-validation. Status dùng: 200/201 · 400 sai kiểu tham số ·
401 chưa xác thực · 403 thiếu quyền · 404 không tồn tại hoặc không sở hữu · 409 xung đột
(trùng email/đánh giá, hết chỗ, huỷ đơn đã huỷ) · 422 lỗi validate · 429 vượt rate limit.
Trang quản trị render phía server bằng Thymeleaf, tách hẳn khỏi API:
API /api/v1/** |
Quản trị /admin/** |
|
|---|---|---|
| Xác thực | Bearer JWT | form đăng nhập + session |
| CSRF | tắt (không dùng cookie) | bật |
| Quyền | theo từng endpoint | hasRole('ADMIN') |
Hai SecurityFilterChain riêng biệt (SecurityConfig và AdminSecurityConfig), phân biệt bằng
securityMatcher, nên thay đổi ở khu quản trị không ảnh hưởng API.
| Đường dẫn | Mô tả |
|---|---|
/admin/login |
Đăng nhập (công khai) |
/admin |
Dashboard |
/admin/logout |
Đăng xuất (POST, có token CSRF) |
Tạo tài khoản admin đầu tiên: đặt ADMIN_EMAIL và ADMIN_PASSWORD trong .env rồi khởi động
lại. Tài khoản chỉ được tạo mới, không bao giờ ghi đè tài khoản đã tồn tại — đổi biến môi
trường sẽ không đổi mật khẩu admin đang dùng.
Giao diện dùng Bootstrap 5 (CDN) + src/main/resources/static/css/admin.css.
Bỏ trống FACEBOOK_CLIENT_ID thì tính năng không được bật và ứng dụng chạy bình thường với
đăng nhập email/mật khẩu.
| Đường dẫn | Mô tả |
|---|---|
/oauth2/authorization/facebook |
Bắt đầu đăng nhập (mở bằng trình duyệt) |
/login/oauth2/code/facebook |
Callback Facebook gọi về |
Khai trong app Facebook (App settings → Basic) và thêm Valid OAuth Redirect URI:
http://localhost:8080/login/oauth2/code/facebook
Database đã có dữ liệu thì phải chạy migration trước:
psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/migrate-social-login.sql
ddl-auto=updatekhông gỡ được ràng buộcNOT NULLkhỏi cộtpassword(tài khoản Facebook không có mật khẩu). Bỏ qua bước này thì tạo tài khoản Facebook sẽ lỗi ở tầng DB.
Xong luồng, TripGo đổi sang JWT của chính nó để client dùng chung một loại token cho mọi
endpoint. Đặt OAUTH2_SUCCESS_REDIRECT_URI thì callback redirect về đó kèm ?token=; bỏ trống
thì trả thẳng JSON { token, user } như POST /auth/login.
Tài khoản Facebook không có mật khẩu, nên POST /auth/login với email đó trả 401 kèm lời nhắn
dùng đăng nhập Facebook. Nếu email đã có tài khoản email/mật khẩu, Facebook được gắn vào chính
tài khoản đó để giữ nguyên wishlist và đơn hàng cũ.
| Trang | Nút | Nội dung |
|---|---|---|
/admin/bookings |
Xuất Excel | Đơn theo đúng bộ lọc đang xem |
/admin/tours |
Xuất Excel | Tour theo từ khoá đang tìm |
Tầng Excel (demo.tripgo.excel) dùng chung cho mọi loại dữ liệu: gắn @ExcelColumn lên field,
ExcelMapper đọc bằng reflection rồi tự sinh file. Thêm loại mới không phải viết code đọc/ghi.
| WSDL | GET /ws/tourAvailability.wsdl |
| Endpoint | POST /ws |
| Thao tác | getTourAvailability(tourSlug, fromDate?) → danh sách ngày khởi hành + chỗ trống |
Contract-first: src/main/resources/soap/tripgo.xsd là nguồn sự thật duy nhất — Spring-WS sinh
WSDL từ đó, jaxb2-maven-plugin sinh lớp Java từ đó, nên hai bên không lệch nhau được.
Endpoint mở công khai vì client SOAP không có phiên đăng nhập để mang theo, và nó chỉ đọc + chỉ trả số liệu chỗ trống, không có dữ liệu khách hàng. Tour đã xoá mềm không lộ ra. Slug sai trả SOAP Fault dạng CLIENT để đối tác biết là phải sửa request chứ không phải thử lại.
Mail xác nhận đặt tour đi qua hàng đợi tripgo.booking.mail thay vì gửi ngay trong request.
JMS_ENABLED |
Cách gửi |
|---|---|
false (mặc định) |
DirectBookingMailDispatcher — gửi thẳng trên pool nền, không cần broker |
true |
Qua ActiveMQ; listener ném lỗi thì message quay lại hàng đợi để thử lại |
Mặc định tắt vì có ActiveMQ trên classpath mà không có broker thì listener thử kết nối lại liên
tục và làm log đầy lỗi. Bật thì chạy kèm service mq trong docker-compose.yml.
Hai bản cài cùng một interface nên phần còn lại của ứng dụng không cần biết có broker hay không — và việc gửi mail không lặng lẽ biến mất khi ai đó quên bật JMS.
Admin đang mở bất kỳ trang quản trị nào sẽ thấy thẻ thông báo ở góc màn hình khi có đơn mới, đơn được xác nhận hoặc bị huỷ.
- Endpoint:
/admin/ws— nằm trong khu quản trị nên chain bảo mật của admin bảo vệ luôn; đặt ở ngoài thì phải mở công khai và ai cũng nghe được thông tin đơn hàng của khách. - Kênh:
/topic/bookings, broker trong bộ nhớ. - Không dùng SockJS: SockJS dự phòng bằng transport POST, sẽ vướng CSRF của chain quản trị. WebSocket thuần chỉ cần một GET Upgrade.
Sự kiện phát ở BookingService/BookingAdminService và chỉ được gửi đi sau khi transaction
commit (@TransactionalEventListener(AFTER_COMMIT)) — nếu không, một lỗi làm rollback sẽ để lại
thông báo về đơn không hề tồn tại.
| Job | Nhịp | Việc |
|---|---|---|
| Tự huỷ đơn quá hạn | mỗi giờ | Đơn PENDING quá BOOKING_EXPIRE_HOURS (mặc định 72h) → huỷ và hoàn chỗ |
| Tính lại đánh giá | mỗi 6 giờ | So rating_avg/review_count với bảng reviews, sửa tour bị lệch |
Tắt bằng TASKS_ENABLED=false. Job chạy trên ThreadPoolTaskExecutor riêng (AsyncConfig) chứ
không dùng executor mặc định — executor mặc định tạo một luồng mới cho mỗi lần gọi, không giới hạn.
Việc huỷ đơn đi qua đúng BookingService.cancelById mà admin dùng, nên phần hoàn chỗ (có khoá
bi quan) chỉ tồn tại ở một nơi.
Một biểu đồ (Chart.js): doanh thu theo từng ngày đặt đơn của một tháng, chọn tháng bằng ô chọn
(không chọn được tháng tương lai), không tính đơn đã huỷ.
Dữ liệu tổng hợp lấy từ GET /admin/reports/chart-data?month=yyyy-MM.
Biểu đồ cập nhật realtime: khi có đơn mới / xác nhận / huỷ, hoặc job huỷ đơn quá hạn chạy xong
và có huỷ đơn, server gửi tín hiệu STOMP tới /topic/chart-updates (ChartUpdatePublisher).
Trình duyệt nhận tín hiệu rồi gọi lại /chart-data cho đúng tháng đang xem và cập nhật Chart.js.
Tín hiệu không mang số liệu vì mỗi admin có thể đang xem một tháng khác nhau. Dùng WebSocket thuần,
không SockJS (xem WebSocketConfig).
Biểu đồ chỉ có một chuỗi số liệu nên dùng một màu — tô mỗi cột một màu khi chúng cùng
ý nghĩa là gán màu theo thứ hạng chứ không theo dữ liệu. Màu khai trong admin.css dưới dạng
custom property (--viz-*) để JS đọc lại, nhờ vậy bảng màu chỉ tồn tại ở một chỗ.
Biểu đồ kèm một bảng số liệu (Xem dạng bảng): biểu đồ không được là cách duy nhất đọc được
con số.
/admin/tours/import — có nút tải file mẫu sinh từ chính lớp TourImportRow, nên cột trong
file mẫu không bao giờ lệch với cột bộ nhập mong đợi.
| Cột bắt buộc | Ghi chú |
|---|---|
Tên tour |
|
Điểm đến |
nhập slug (vd da-nang) |
Loại hình |
nhập slug (vd beach) |
Giá, Số ngày, Số khách tối đa |
Cột khớp theo tên tiêu đề nên thứ tự cột không quan trọng; thừa cột cũng không sao.
Nguyên tắc xử lý:
- Nhập được dòng nào hay dòng đó. Một ô gõ sai chỉ làm hỏng dòng đó, kèm số dòng đúng như trong Excel để tìm mà sửa.
- Slug trùng thì bỏ qua, không ghi đè — nhập lại cùng một file không tạo bản sao và không đè lên dữ liệu admin đã sửa tay.
- Mỗi dòng là một transaction riêng, nên một dòng hỏng không kéo đổ những dòng đã lưu.
Yêu cầu Docker + Docker Compose. Compose dựng sẵn cả Postgres lẫn API.
cp .env.example .env
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env # dòng thêm sau ghi đè dòng trống phía trên
docker compose up -d --buildAPI sẵn sàng tại http://localhost:8080/api/v1. Xem log: docker compose logs -f api.
Dừng: docker compose down (thêm -v để xoá luôn dữ liệu Postgres).
Nếu máy đã có Postgres chiếm cổng 5432, đặt DB_PORT=55432 trong .env.
Đổi cổng API bằng SERVER_PORT.
Cần Java 21 và một PostgreSQL đang chạy với database tripgo.
cp .env.example .env # điền JWT_SECRET
set -a; source .env; set +a
./mvnw spring-boot:runJWT_SECRET bắt buộc, không có giá trị mặc định. Các biến khác đều có default cho dev:
JWT_EXPIRATION (ms, mặc định 86400000 = 24h), DB_URL, DB_USERNAME, DB_PASSWORD,
SERVER_PORT, CORS_ALLOWED_ORIGINS (mặc định *, prod phải đặt cụ thể),
RATE_LIMIT_AUTH_MAX (20), RATE_LIMIT_AUTH_WINDOW (giây, 60).
Schema do Hibernate ddl-auto: update tự tạo lúc khởi động — không cần migration thủ công.
Chạy sau khi ứng dụng đã khởi động lần đầu (để Hibernate tạo bảng xong):
# Local
PGPASSWORD=postgres psql -h localhost -U postgres -d tripgo -f scripts/seed-tours.sql
# Docker (DB_PORT theo .env)
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/seed-tours.sqlScript chạy lại nhiều lần an toàn (xoá sạch dữ liệu tour trước khi seed). Sau khi seed có:
-
55 tour thuộc 8 điểm đến, tất cả đều có ảnh, lịch trình và ngày khởi hành (riêng Tĩnh dưỡng Đà Lạt chưa mở chuyến cố ý để trống, dùng thử case
availabilityrỗng) -
347 đánh giá rải trên mọi tour, tour nhiều nhất có 12 — đủ để thử
?page=2&limit=10 -
10 đơn đặt tour (8
pending, 2cancelled) trên 10 tour khác nhau, mỗi đơn một tài khoảnseed.*— dùng để thử luồng F8: chủ đơn đánh giá được (201), người chưa đặt bị chặn (403), và người có đơn đã huỷ cũng bị chặn -
1 admin + 14 tài khoản người dùng, mật khẩu là hash BCrypt thật nên đăng nhập được ngay:
Tài khoản Mật khẩu Role admin@tripgo.vnadmin123ADMIN seed.mai@example.com(và cácseed.*)password123USER
rating_avg/review_count trên bảng tours được đồng bộ đúng bằng dữ liệu thật trong bảng
reviews, nhưng vẫn giữ phân hoá (2.3 → 4.7) để lọc ?rating= và sort=rating_desc có ý nghĩa.
Mở docs/api.http trong IntelliJ IDEA (HTTP Client) hoặc VS Code
(extension REST Client) rồi bấm Send Request. File phủ đủ 17 endpoint kèm các case lỗi
(401/403/404/409/422/429); token được gán tự động sau request đăng nhập.
Hoặc dùng Swagger UI tại /swagger-ui.html.
./mvnw test94 test chạy trên H2 (chế độ PostgreSQL), profile test có sẵn khoá JWT riêng nên không cần
đặt JWT_SECRET. Gồm integration test MockMvc cho auth, tour, review, availability, booking,
destination và security config; cộng unit test rate limit và test race-condition khi huỷ đơn.
Tầng: Controller (mỏng, chỉ delegate) → Service (nghiệp vụ, @Transactional) →
Repository (Spring Data JPA). Mapper chuyển entity ↔ DTO; DTO là Java record.
Quan hệ dữ liệu:
Category 1─* Tour Tour 1─* TourImage
Destination 1─* Tour 1─* Departure
│ 1─* ItineraryDay Tour 1─* Review *─1 User
│ *─* User (wishlist, bảng user_wishlist)
└──────── 1─* Booking *─1 User (Booking embeds ContactInfo)
Điểm đáng chú ý:
- Không N+1:
@EntityGraphởBookingRepository/ReviewRepository,join fetchởTourRepository.findDetailById, vàroot.fetch("destination")trongTourSpecifications(tách nhánh riêng cho truy vấn count để không fetch thừa). - Lọc/sắp xếp/phân trang chạy ở DB qua
Specification+Pageable, không tải hết rồi lọc. Có@Indexcho mọi cột dùng để lọc/sắp xếp; mọi quan hệ đềuLAZY. - Quyền sở hữu kiểm ở service bằng
findByIdAndUserId→ trả 404 thay vì 403 để không lộ sự tồn tại của đơn người khác.userIdlấy từ JWT,totalPriceserver tự tính — không nhận từ client. - Chống oversell: khoá bi quan (
@Lock(PESSIMISTIC_WRITE)) trên hàngDeparturekhi đặt, vàentityManager.refresh(..., PESSIMISTIC_WRITE)khi huỷ để tránh lost update. - Rating denormalized (
rating_avg,review_counttrênTour) để lọc/sắp xếp theo đánh giá chạy được ở DB; cập nhật lại mỗi khi có review mới. - Chỉ người đã đặt tour mới được đánh giá:
POST /tours/{id}/reviewskiểm tra user có booking cho tour đó với trạng thái khácCANCELLED, nếu không trả 403. Bám theo user story F8 ("là người dùng đã đặt tour"), chặn tài khoản ảo spam điểm. Đơn đã huỷ không tính, để không ai đặt rồi huỷ ngay chỉ nhằm lấy quyền đánh giá. - Rate limit
POST /auth/**chống brute-force; lỗi 401/403/429 phát sinh trong filter cũng dùng chung khuôn{error:{...}}quaSecurityErrorResponder.
Mật khẩu băm BCrypt; JWT có exp; secret chỉ đến từ biến môi trường.
Khoá từng bị commit phải coi là đã lộ — mỗi môi trường dùng một khoá riêng, cấp qua biến môi
trường hoặc secret manager. Sau khi xoay khoá, token cũ hết hiệu lực và người dùng phải đăng
nhập lại. Không dùng khoá công khai của profile test cho môi trường chạy thật.
Trước khi commit, soát diff đã stage để phát hiện secret lọt vào:
git diff --cached | grep -iE "secret|password|token|key"