Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d596a27
Bo context-path, gan tien to /api/v1 theo package
nhitt-1337 Sep 25, 2026
8c659af
Khu quan tri: layout Thymeleaf, dang nhap, phan quyen ADMIN
nhitt-1337 Sep 25, 2026
af2cdbf
Xoa mem cho tour va diem den
nhitt-1337 Sep 25, 2026
e4c748a
Quan ly tour trong khu quan tri: danh sach, form, upload anh, thung rac
nhitt-1337 Sep 25, 2026
78d36ba
Quan ly don dat trong khu quan tri: bang, loc trang thai, doi trang thai
nhitt-1337 Sep 25, 2026
5a4fe82
CRUD diem den trong khu quan tri
nhitt-1337 Sep 25, 2026
9995c3e
Dashboard: noi 4 o so lieu vao du lieu that
nhitt-1337 Sep 25, 2026
26ed4de
Hien anh tour o danh sach quan tri + script gan anh thay link seed chet
nhitt-1337 Sep 25, 2026
e8c223d
Ha tang test chay Postgres that bang Testcontainers
nhitt-1337 Sep 25, 2026
56ceac0
Tim kiem khong dau, va khong xoa file anh con ban ghi khac dung
nhitt-1337 Sep 25, 2026
d8bad7a
Dang nhap Facebook bang OAuth2 Login, doi sang JWT cua TripGo
nhitt-1337 Sep 25, 2026
b7d6739
Sua dang nhap Facebook lam hong DB da co du lieu
nhitt-1337 Sep 25, 2026
335813f
Xuat Excel cho don dat, tour va doanh thu thang
nhitt-1337 Sep 25, 2026
f46fba0
Nhap tour tu Excel, va gom viec don du lieu test vao mot cho
nhitt-1337 Sep 25, 2026
2b66aa8
Bieu do dashboard bang Chart.js
nhitt-1337 Sep 28, 2026
439f5a9
Job nen dinh ky bang @Scheduled + @Async
nhitt-1337 Sep 28, 2026
a3bb38a
Thong bao realtime cho khu quan tri bang WebSocket + STOMP
nhitt-1337 Sep 28, 2026
e051570
Hang doi gui mail xac nhan dat tour bang JMS
nhitt-1337 Sep 28, 2026
bfac434
SOAP cho he thong doi tac tra cuu cho trong (Spring-WS)
nhitt-1337 Sep 28, 2026
78eabca
Sua thong bao realtime khong bao gio hien len duoc
nhitt-1337 Sep 29, 2026
2601ae8
Khu quan tri: dang nhap Facebook, quan ly user, tao don, bieu do theo…
nhitt-1337 Sep 30, 2026
93e160a
Bieu do doanh thu realtime, man hinh Job nen, gon danh sach tour
nhitt-1337 Oct 1, 2026
cfe562e
fix all
nhitt-1337 Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,40 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
# Cổng Postgres publish ra máy host; đổi nếu máy đã có Postgres chiếm 5432
DB_PORT=5432

# --- Tài khoản quản trị đầu tiên ---
# Tạo lúc khởi động nếu email chưa có trong DB. Bỏ trống -> không tạo.
# Chỉ tạo mới, không ghi đè tài khoản đã tồn tại.
ADMIN_EMAIL=admin@tripgo.local
ADMIN_PASSWORD=
ADMIN_NAME="Quản trị viên"

# --- Đăng nhập Facebook (tuỳ chọn) ---
# Lấy ở https://developers.facebook.com/apps -> App settings -> Basic.
# Bỏ trống -> tính năng không bật, app vẫn chạy với đăng nhập email/mật khẩu.
# Trong app Facebook, thêm Valid OAuth Redirect URI:
# http://localhost:8080/login/oauth2/code/facebook
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
# Có giá trị -> sau khi đăng nhập xong redirect về đây kèm ?token=<JWT>.
# Bỏ trống -> callback trả thẳng JSON { token, user }.
OAUTH2_SUCCESS_REDIRECT_URI=

# --- Job nền ---
# false để tắt hẳn job dọn dẹp định kỳ.
TASKS_ENABLED=true
# Đơn chờ xác nhận quá số giờ này sẽ tự huỷ và hoàn chỗ.
BOOKING_EXPIRE_HOURS=72
# Bao lâu job kiểm tra đơn quá hạn / tính lại điểm đánh giá một lần.
# Định dạng thời lượng: PT2M = 2 phút, PT30S = 30 giây, PT1H = 1 giờ. Demo thì đặt PT2M cho nhanh.
BOOKING_EXPIRE_CHECK_INTERVAL=PT1H
RATING_REFRESH_INTERVAL=PT6H

# --- Hàng đợi gửi mail (tuỳ chọn) ---
# false: gửi thẳng trên pool nền, không cần broker. true: qua hàng đợi ActiveMQ.
JMS_ENABLED=false
MQ_USER=admin
MQ_PASSWORD=admin
# Cổng ActiveMQ publish ra máy host; đổi nếu đã có broker khác chiếm chỗ.
MQ_PORT=61616
MQ_CONSOLE_PORT=8161
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ REQUIREMENTS.md
# nên !docs/api.http sẽ không có tác dụng nếu ignore cả thư mục.
docs/*
!docs/api.http

# Ảnh admin tải lên lúc chạy; không thuộc mã nguồn.
uploads/
174 changes: 172 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ REST API đặt tour du lịch: tìm kiếm/lọc tour, xem chi tiết & ngày k
đá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/api/v1/swagger-ui.html` (tắt ở profile `prod`)
- Swagger UI: `http://localhost:8080/swagger-ui.html` (tắt ở profile `prod`)
- Ứng dụng không dùng `context-path`: tiền tố `/api/v1` do `ApiPathConfig` gắn riêng cho
package `demo.tripgo.controller`, để khu quản trị `/admin/**` nằm ngoài tiền tố đó.
- Bộ request mẫu: [`docs/api.http`](docs/api.http)

---
Expand Down Expand Up @@ -64,6 +66,174 @@ Message trả về bằng tiếng Việt. `code` dùng tên `HttpStatus`, trừ
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.

## 2b. Khu quản trị (giao diện)

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`.

---

## 2c. Đăng nhập Facebook (tuỳ chọn)

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:**
> ```bash
> psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/migrate-social-login.sql
> ```
> `ddl-auto=update` không gỡ được ràng buộc `NOT NULL` khỏi cột `password` (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ũ.

---

## 2d. Xuất Excel

| 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.

## 2i. SOAP cho hệ thống đối tác

| | |
|---|---|
| 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.

## 2h. Hàng đợi gửi mail (JMS)

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.

## 2g. Thông báo realtime (WebSocket + STOMP)

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.

## 2f. Job nền

| 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.

## 2e. Biểu đồ dashboard

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ố.

## 2e2. Nhập tour từ Excel

`/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.

---

## 3. Chạy bằng Docker (khuyến nghị)

Yêu cầu Docker + Docker Compose. Compose dựng sẵn cả Postgres lẫn API.
Expand Down Expand Up @@ -132,7 +302,7 @@ Mở [`docs/api.http`](docs/api.http) trong IntelliJ IDEA (HTTP Client) hoặc V
(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 `/api/v1/swagger-ui.html`.
Hoặc dùng Swagger UI tại `/swagger-ui.html`.

## 7. Kiểm thử

Expand Down
24 changes: 24 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ services:
timeout: 5s
retries: 10

# Hàng đợi gửi mail. Chỉ cần khi JMS_ENABLED=true; để mặc định thì api không đụng tới nó.
mq:
image: apache/activemq-classic:6.2.0
environment:
ACTIVEMQ_CONNECTION_USER: ${MQ_USER:-admin}
ACTIVEMQ_CONNECTION_PASSWORD: ${MQ_PASSWORD:-admin}
ports:
- "${MQ_PORT:-61616}:61616"
- "${MQ_CONSOLE_PORT:-8161}:8161"
healthcheck:
test: ["CMD-SHELL", "curl -fsS -u $${ACTIVEMQ_CONNECTION_USER}:$${ACTIVEMQ_CONNECTION_PASSWORD} http://localhost:8161/admin/ || exit 1"]
interval: 10s
timeout: 5s
retries: 10

api:
build: .
depends_on:
Expand All @@ -31,6 +46,15 @@ services:
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-*}
# Bỏ trống -> đăng nhập Facebook không được bật (xem .env.example).
FACEBOOK_CLIENT_ID: ${FACEBOOK_CLIENT_ID:-}
FACEBOOK_CLIENT_SECRET: ${FACEBOOK_CLIENT_SECRET:-}
OAUTH2_SUCCESS_REDIRECT_URI: ${OAUTH2_SUCCESS_REDIRECT_URI:-}
# Bật hàng đợi gửi mail. Để false thì api gửi thẳng trên pool nền, không cần broker.
JMS_ENABLED: ${JMS_ENABLED:-false}
MQ_URL: tcp://mq:61616
MQ_USER: ${MQ_USER:-admin}
MQ_PASSWORD: ${MQ_PASSWORD:-admin}
RATE_LIMIT_AUTH_MAX: ${RATE_LIMIT_AUTH_MAX:-20}
RATE_LIMIT_AUTH_WINDOW: ${RATE_LIMIT_AUTH_WINDOW:-60}
ports:
Expand Down
Loading
Loading