From d596a276f1f890631f342341959c84b6c91daed6 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 13:10:06 +0700 Subject: [PATCH 01/23] Bo context-path, gan tien to /api/v1 theo package server.servlet.context-path bao toan bo ung dung nen khu quan tri sap lam se bi keo vao /api/v1/admin. Thay bang ApiPathConfig: gan tien to /api/v1 cho rieng package demo.tripgo.controller, controller o package khac khong dinh gi. URL cong khai cua API khong doi mot ky tu nao. Hai cho phai sua theo, neu quen se hong am tham: - SecurityConfig: servlet container khong con tach context-path nen moi requestMatchers phai gom ca /api/v1. - RateLimitFilter loc theo path.startsWith("/auth/") -> chong brute-force login se tat ma khong bao loi gi. Swagger UI doi cho: /api/v1/swagger-ui.html -> /swagger-ui.html. Cac endpoint trong tai lieu van ghi /api/v1/... nhu cu. Test: bo 210 loi goi .contextPath()/.servletPath() o 10 file. UnlistedController cua test nam ngoai package controller nen tu khai tien to. Co-Authored-By: Claude Opus 5 (1M context) --- .../demo/tripgo/config/ApiPathConfig.java | 48 +++++++++++++++++++ .../demo/tripgo/config/SecurityConfig.java | 40 ++++++++++------ .../demo/tripgo/config/StorageProperties.java | 23 +++++++++ .../demo/tripgo/security/RateLimitFilter.java | 9 +++- src/main/resources/application.yaml | 24 +++++++++- ...wtAuthenticationFilterIntegrationTest.java | 5 -- .../config/SecurityConfigIntegrationTest.java | 11 ++--- .../AuthControllerIntegrationTest.java | 37 ++++---------- .../controller/BookingIntegrationTest.java | 31 ++++++------ .../CategoryControllerIntegrationTest.java | 6 +-- .../DestinationControllerIntegrationTest.java | 6 +-- .../TourControllerIntegrationTest.java | 41 ++++++++-------- ...TourReviewAvailabilityIntegrationTest.java | 26 ++-------- .../controller/WishlistIntegrationTest.java | 38 +++++++-------- .../tripgo/security/RateLimitFilterTest.java | 8 ++-- 15 files changed, 205 insertions(+), 148 deletions(-) create mode 100644 src/main/java/demo/tripgo/config/ApiPathConfig.java create mode 100644 src/main/java/demo/tripgo/config/StorageProperties.java diff --git a/src/main/java/demo/tripgo/config/ApiPathConfig.java b/src/main/java/demo/tripgo/config/ApiPathConfig.java new file mode 100644 index 0000000..1b6d8d4 --- /dev/null +++ b/src/main/java/demo/tripgo/config/ApiPathConfig.java @@ -0,0 +1,48 @@ +package demo.tripgo.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.HandlerTypePredicate; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Paths; + +// Tiền tố /api/v1 gắn theo package thay vì bằng server.servlet.context-path, vì context-path bọc +// TOÀN BỘ ứng dụng — kể cả khu quản trị /admin/** vốn không phải là API. +// Controller trong demo.tripgo.controller vẫn khai @RequestMapping("/tours") như cũ và URL thật +// vẫn là /api/v1/tours; controller ở package khác (admin) không bị thêm gì. +@Configuration +@EnableConfigurationProperties(StorageProperties.class) +public class ApiPathConfig implements WebMvcConfigurer { + + public static final String API_PREFIX = "/api/v1"; + + // Package chứa controller của API công khai. Đặt ở đây để SecurityConfig và RateLimitFilter + // dùng chung một nguồn sự thật với cấu hình định tuyến. + private static final String API_CONTROLLER_PACKAGE = "demo.tripgo.controller"; + + private final StorageProperties storageProperties; + + public ApiPathConfig(StorageProperties storageProperties) { + this.storageProperties = storageProperties; + } + + // Ảnh tải lên nằm ngoài classpath nên phải khai resource handler trỏ vào thư mục trên đĩa; + // không có nó thì URL /uploads/... trả 404 dù file đã ghi xuống. + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String location = Paths.get(storageProperties.uploadDir()).toAbsolutePath().normalize() + .toUri().toString(); + registry.addResourceHandler(storageProperties.publicPath() + "/**") + .addResourceLocations(location); + } + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.addPathPrefix( + API_PREFIX, + HandlerTypePredicate.forBasePackage(API_CONTROLLER_PACKAGE)); + } +} diff --git a/src/main/java/demo/tripgo/config/SecurityConfig.java b/src/main/java/demo/tripgo/config/SecurityConfig.java index ac4d95e..299d232 100644 --- a/src/main/java/demo/tripgo/config/SecurityConfig.java +++ b/src/main/java/demo/tripgo/config/SecurityConfig.java @@ -20,6 +20,8 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import static demo.tripgo.config.ApiPathConfig.API_PREFIX; + import java.util.Arrays; import java.util.List; @@ -28,6 +30,7 @@ @EnableMethodSecurity public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; private final SecurityErrorResponder securityErrorResponder; @@ -114,40 +117,49 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { // trên ERROR dispatch). Không permitAll thì client nhận 401 che mất lỗi thật. .requestMatchers("/error").permitAll() + // Tài nguyên tĩnh của khu quản trị (CSS/JS/ảnh). Chúng nằm NGOÀI /admin/** nên + // rơi vào chain này; không mở thì trang đăng nhập admin sẽ mất hết định dạng. + .requestMatchers("/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll() + + // Ảnh tour do admin tải lên: trang bán hàng công khai phải xem được. + .requestMatchers(HttpMethod.GET, "/uploads/**").permitAll() + // Cho phép người chưa đăng nhập gọi API đăng ký và đăng nhập. - // Matcher không gồm context-path /api/v1 vì servlet container đã tách phần này. + // Matcher phải gồm cả tiền tố /api/v1: không còn context-path nên servlet + // container không tách phần này ra nữa (xem ApiPathConfig). .requestMatchers( HttpMethod.POST, - "/auth/register", - "/auth/login" + API_PREFIX + "/auth/register", + API_PREFIX + "/auth/login" ).permitAll() - .requestMatchers(HttpMethod.GET, "/auth/me").authenticated() + .requestMatchers(HttpMethod.GET, API_PREFIX + "/auth/me").authenticated() // Danh sách, chi tiết, ngày khởi hành và đánh giá của tour cho khách xem không cần đăng nhập. .requestMatchers(HttpMethod.GET, - "/tours", "/tours/*", "/tours/*/availability", "/tours/*/reviews").permitAll() + API_PREFIX + "/tours", API_PREFIX + "/tours/*", + API_PREFIX + "/tours/*/availability", API_PREFIX + "/tours/*/reviews").permitAll() // Danh sách điểm đến & loại hình tour (cho dropdown lọc). - .requestMatchers(HttpMethod.GET, "/destinations", "/categories").permitAll() + .requestMatchers(HttpMethod.GET, API_PREFIX + "/destinations", API_PREFIX + "/categories").permitAll() - .requestMatchers(HttpMethod.POST, "/tours/*/reviews").authenticated() + .requestMatchers(HttpMethod.POST, API_PREFIX + "/tours/*/reviews").authenticated() .requestMatchers( HttpMethod.POST, - "/bookings" + API_PREFIX + "/bookings" ).authenticated() .requestMatchers( HttpMethod.GET, - "/bookings", - "/bookings/*" + API_PREFIX + "/bookings", + API_PREFIX + "/bookings/*" ).authenticated() - .requestMatchers(HttpMethod.PATCH, "/bookings/*/cancel").authenticated() + .requestMatchers(HttpMethod.PATCH, API_PREFIX + "/bookings/*/cancel").authenticated() // Wishlist: toàn bộ đều là thao tác trên dữ liệu riêng của user đang đăng nhập. - .requestMatchers(HttpMethod.GET, "/wishlist").authenticated() - .requestMatchers(HttpMethod.POST, "/wishlist").authenticated() - .requestMatchers(HttpMethod.DELETE, "/wishlist/*").authenticated() + .requestMatchers(HttpMethod.GET, API_PREFIX + "/wishlist").authenticated() + .requestMatchers(HttpMethod.POST, API_PREFIX + "/wishlist").authenticated() + .requestMatchers(HttpMethod.DELETE, API_PREFIX + "/wishlist/*").authenticated() // Mọi endpoint chưa được liệt kê ở trên đều yêu cầu xác thực. .anyRequest().authenticated() diff --git a/src/main/java/demo/tripgo/config/StorageProperties.java b/src/main/java/demo/tripgo/config/StorageProperties.java new file mode 100644 index 0000000..e10404b --- /dev/null +++ b/src/main/java/demo/tripgo/config/StorageProperties.java @@ -0,0 +1,23 @@ +package demo.tripgo.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import java.util.List; + +// Nơi lưu ảnh admin tải lên. Cố ý đặt NGOÀI src/main/resources/static: thư mục đó được đóng gói +// vào jar lúc build, nên file ghi vào đó lúc chạy sẽ không phục vụ được và mất khi build lại. +@ConfigurationProperties(prefix = "storage") +public record StorageProperties( + // Đường dẫn thư mục trên đĩa (tương đối so với nơi chạy ứng dụng, hoặc tuyệt đối). + @DefaultValue("./uploads") String uploadDir, + + // Tiền tố URL để trình duyệt lấy ảnh; ResourceHandler ánh xạ nó về uploadDir. + @DefaultValue("/uploads") String publicPath, + + @DefaultValue("5242880") long maxFileSizeBytes, + + @DefaultValue({"image/jpeg", "image/png", "image/webp", "image/gif"}) + List allowedContentTypes +) { +} diff --git a/src/main/java/demo/tripgo/security/RateLimitFilter.java b/src/main/java/demo/tripgo/security/RateLimitFilter.java index 2569be7..151987e 100644 --- a/src/main/java/demo/tripgo/security/RateLimitFilter.java +++ b/src/main/java/demo/tripgo/security/RateLimitFilter.java @@ -1,5 +1,7 @@ package demo.tripgo.security; +import demo.tripgo.config.ApiPathConfig; + import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -37,12 +39,15 @@ public RateLimitFilter( this.windowMillis = windowSeconds * 1000L; } - // Chỉ áp cho POST /auth/** (login/register); còn lại bỏ qua. + // Chỉ áp cho POST /api/v1/auth/** (login/register); còn lại bỏ qua. + // Tiền tố nằm trong đường dẫn vì ứng dụng không dùng context-path (xem ApiPathConfig). + private static final String AUTH_PATH_PREFIX = ApiPathConfig.API_PREFIX + "/auth/"; + @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getServletPath(); return !("POST".equalsIgnoreCase(request.getMethod()) - && path != null && path.startsWith("/auth/")); + && path != null && path.startsWith(AUTH_PATH_PREFIX)); } @Override diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8bad3d1..f049e0a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,6 +7,12 @@ spring: username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} + # Mặc định của Spring chỉ 1MB; nới ra cho khớp giới hạn 5MB của storage bên dưới. + servlet: + multipart: + max-file-size: 5MB + max-request-size: 25MB + jpa: hibernate: ddl-auto: update @@ -19,8 +25,8 @@ spring: server: port: ${SERVER_PORT:8080} - servlet: - context-path: /api/v1 +# Không dùng context-path: tiền tố /api/v1 do ApiPathConfig gắn riêng cho package controller, +# để khu quản trị /admin/** nằm ngoài tiền tố đó. jwt: secret: ${JWT_SECRET} @@ -31,6 +37,20 @@ jwt: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:*} +# Ảnh admin tải lên. Lưu ngoài classpath để không mất khi đóng gói lại (xem StorageProperties). +storage: + upload-dir: ${UPLOAD_DIR:./uploads} + public-path: /uploads + max-file-size-bytes: ${UPLOAD_MAX_BYTES:5242880} + +# Tài khoản quản trị đầu tiên, tạo lúc khởi động nếu email chưa tồn tại (xem AdminAccountSeeder). +# Bỏ trống -> không tạo gì cả. +admin: + seed: + email: ${ADMIN_EMAIL:} + password: ${ADMIN_PASSWORD:} + name: ${ADMIN_NAME:Quản trị viên} + # Rate limit chống brute-force cho POST /auth/** (số request mỗi IP mỗi cửa sổ). ratelimit: auth: diff --git a/src/test/java/demo/tripgo/config/JwtAuthenticationFilterIntegrationTest.java b/src/test/java/demo/tripgo/config/JwtAuthenticationFilterIntegrationTest.java index f1a8d5e..824c565 100644 --- a/src/test/java/demo/tripgo/config/JwtAuthenticationFilterIntegrationTest.java +++ b/src/test/java/demo/tripgo/config/JwtAuthenticationFilterIntegrationTest.java @@ -50,7 +50,6 @@ void existingTokenLosesAccessWhenAccountIsDisabled(UserStatus status) throws Exc String token = jwt.generateToken(user); mvc.perform(get("/api/v1/test/unlisted") - .contextPath("/api/v1").servletPath("/test/unlisted") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); @@ -58,7 +57,6 @@ void existingTokenLosesAccessWhenAccountIsDisabled(UserStatus status) throws Exc users.saveAndFlush(user); mvc.perform(get("/api/v1/test/unlisted") - .contextPath("/api/v1").servletPath("/test/unlisted") .header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) @@ -66,7 +64,6 @@ void existingTokenLosesAccessWhenAccountIsDisabled(UserStatus status) throws Exc // The public login handler still runs and reports the account status. mvc.perform(post("/api/v1/auth/login") - .contextPath("/api/v1").servletPath("/auth/login") .header("Authorization", "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -111,7 +108,6 @@ void tokenDoesNotBlockLoginAndOnlyValidTokenGrantsProtectedAccess(String kind) t } mvc.perform(post("/api/v1/auth/login") - .contextPath("/api/v1").servletPath("/auth/login") .header("Authorization", "Bearer " + token) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -121,7 +117,6 @@ void tokenDoesNotBlockLoginAndOnlyValidTokenGrantsProtectedAccess(String kind) t .andExpect(jsonPath("$.token").isNotEmpty()); mvc.perform(get("/api/v1/test/unlisted") - .contextPath("/api/v1").servletPath("/test/unlisted") .header("Authorization", "Bearer " + token)) .andExpect(status().is(kind.equals("valid") ? 200 : 401)); } diff --git a/src/test/java/demo/tripgo/config/SecurityConfigIntegrationTest.java b/src/test/java/demo/tripgo/config/SecurityConfigIntegrationTest.java index 43d8ce3..88044ac 100644 --- a/src/test/java/demo/tripgo/config/SecurityConfigIntegrationTest.java +++ b/src/test/java/demo/tripgo/config/SecurityConfigIntegrationTest.java @@ -24,9 +24,11 @@ class SecurityConfigIntegrationTest { private MockMvc mvc; // Exists only in tests and deliberately has no matcher in SecurityConfig. + // Nam ngoai package demo.tripgo.controller nen ApiPathConfig khong gan tien to ho: + // phai tu khai /api/v1 de duong dan giong endpoint that. @RestController static class UnlistedController { - @GetMapping("/test/unlisted") + @GetMapping("/api/v1/test/unlisted") String unlisted() { return "protected"; } @@ -34,8 +36,7 @@ String unlisted() { @Test void unlistedEndpointRequiresAuthentication() throws Exception { - mvc.perform(get("/api/v1/test/unlisted") - .contextPath("/api/v1").servletPath("/test/unlisted")) + mvc.perform(get("/api/v1/test/unlisted")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.error.message").isNotEmpty()); } @@ -43,8 +44,7 @@ void unlistedEndpointRequiresAuthentication() throws Exception { @Test @WithMockUser void authenticatedUserCanReachUnlistedEndpoint() throws Exception { - mvc.perform(get("/api/v1/test/unlisted") - .contextPath("/api/v1").servletPath("/test/unlisted")) + mvc.perform(get("/api/v1/test/unlisted")) .andExpect(status().isOk()) .andExpect(content().string("protected")); } @@ -53,7 +53,6 @@ void authenticatedUserCanReachUnlistedEndpoint() throws Exception { @Test void corsPreflightIsAllowed() throws Exception { mvc.perform(options("/api/v1/tours") - .contextPath("/api/v1").servletPath("/tours") .header("Origin", "http://localhost:3000") .header("Access-Control-Request-Method", "GET")) .andExpect(status().isOk()) diff --git a/src/test/java/demo/tripgo/controller/AuthControllerIntegrationTest.java b/src/test/java/demo/tripgo/controller/AuthControllerIntegrationTest.java index 143e010..ed2516c 100644 --- a/src/test/java/demo/tripgo/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/AuthControllerIntegrationTest.java @@ -61,7 +61,6 @@ void meReturnsCurrentUserWithoutPassword() throws Exception { String token = jwtService.generateToken(user); mockMvc.perform(get("/api/v1/auth/me") - .contextPath("/api/v1").servletPath("/auth/me") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(user.getId())) @@ -72,8 +71,7 @@ void meReturnsCurrentUserWithoutPassword() throws Exception { @Test void meWithoutTokenReturns401() throws Exception { - mockMvc.perform(get("/api/v1/auth/me") - .contextPath("/api/v1").servletPath("/auth/me")) + mockMvc.perform(get("/api/v1/auth/me")) .andExpect(status().isUnauthorized()) // Thiếu token bị chặn ở entry-point của Spring Security -> code là tên HttpStatus, // khác với INVALID_CREDENTIALS (dành cho sai email/mật khẩu ở /auth/login). @@ -87,8 +85,7 @@ void meWithNonUserPrincipalReturns401NotNpe() throws Exception { // Principal của @WithMockUser là UserDetails, không phải entity User, nên // @AuthenticationPrincipal User bind về null. Controller phải trả 401 ErrorResponse // thay vì để NullPointerException lọt ra format lỗi mặc định của Spring Boot. - mockMvc.perform(get("/api/v1/auth/me") - .contextPath("/api/v1").servletPath("/auth/me")) + mockMvc.perform(get("/api/v1/auth/me")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.error.code").value("INVALID_CREDENTIALS")) .andExpect(jsonPath("$.error.message").isNotEmpty()); @@ -99,7 +96,6 @@ void loginReturnsTokenAndUserWithoutPassword() throws Exception { registerLoginUser("login-success@example.com"); mockMvc.perform(post("/api/v1/auth/login") - .contextPath("/api/v1").servletPath("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" {"email":"login-success@example.com","password":"password123"} @@ -116,7 +112,6 @@ void loginAcceptsDifferentEmailCaseAfterRegistration() throws Exception { for (String email : new String[]{"login.mixed.case@example.com", "LOGIN.MIXED.CASE@EXAMPLE.COM"}) { mockMvc.perform(post("/api/v1/auth/login") - .contextPath("/api/v1").servletPath("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" {"email":"%s","password":"password123"} @@ -138,7 +133,6 @@ void loginRejectsNonActiveAccount(UserStatus accountStatus) throws Exception { userRepository.saveAndFlush(user); mockMvc.perform(post("/api/v1/auth/login") - .contextPath("/api/v1").servletPath("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" {"email":"%s","password":"password123"} @@ -152,7 +146,6 @@ void loginRejectsNonActiveAccount(UserStatus accountStatus) throws Exception { private void registerLoginUser(String email) throws Exception { mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1").servletPath("/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(""" {"name":"Login Test","email":"%s","password":"password123"} @@ -171,7 +164,6 @@ void shortIncorrectPasswordReturns401() throws Exception { for (String password : new String[]{"1", "1234567", "wrongpassword"}) { mockMvc.perform(post("/api/v1/auth/login") - .contextPath("/api/v1").servletPath("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" {"email":"short-login@example.com","password":"%s"} @@ -191,7 +183,6 @@ void missingOrBlankLoginPasswordReturns422() throws Exception { "{\"email\":\"login@example.com\"}", "{\"email\":\"login@example.com\",\"password\":\" \"}"}) { mockMvc.perform(post("/api/v1/auth/login") - .contextPath("/api/v1").servletPath("/auth/login") .contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.error.fields.password").value("Mật khẩu không được để trống")); @@ -205,8 +196,7 @@ void normalizesEmailAndRejectsDuplicateWithDifferentCase() throws Exception { """; mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(request.formatted("Mixed.Case@Example.com"))) .andExpect(status().isCreated()) @@ -218,8 +208,7 @@ void normalizesEmailAndRejectsDuplicateWithDifferentCase() throws Exception { assertThat(passwordEncoder.matches("12345678", savedUser.getPassword())).isTrue(); mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(request.formatted("MIXED.CASE@EXAMPLE.COM"))) .andExpect(status().isConflict()) @@ -233,8 +222,7 @@ void returnsConflictWhenDatabaseDetectsDuplicateAfterApplicationCheck() throws E """; mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isCreated()); @@ -243,8 +231,7 @@ void returnsConflictWhenDatabaseDetectsDuplicateAfterApplicationCheck() throws E doReturn(false).when(userRepository).existsByEmail("race@example.com"); mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(request)) .andExpect(status().isConflict()) @@ -261,8 +248,7 @@ static Stream oversizedPasswords() { @MethodSource("oversizedPasswords") void rejectsPasswordsExceeding72Utf8Bytes(String password) throws Exception { mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(""" {"name":"Nguyen Van An","email":"oversized@example.com","password":"%s"} @@ -279,8 +265,7 @@ static Stream boundaryPasswords() { @MethodSource("boundaryPasswords") void acceptsPasswordsAt72Utf8Bytes(String password) throws Exception { mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(""" {"name":"Nguyen Van An","email":"boundary-%s@example.com","password":"%s"} @@ -291,8 +276,7 @@ void acceptsPasswordsAt72Utf8Bytes(String password) throws Exception { @Test void rejectsInvalidFieldsWithoutSavingUser() throws Exception { mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(""" {"name":" ","email":"invalid-email","password":"1234567"} @@ -310,8 +294,7 @@ void rejectsInvalidFieldsWithoutSavingUser() throws Exception { @Test void registerIsPublicUnderApiV1BasePath() throws Exception { mockMvc.perform(post("/api/v1/auth/register") - .contextPath("/api/v1") - .servletPath("/auth/register") + .contentType(MediaType.APPLICATION_JSON) .content(""" { diff --git a/src/test/java/demo/tripgo/controller/BookingIntegrationTest.java b/src/test/java/demo/tripgo/controller/BookingIntegrationTest.java index b776dbd..1b6f3cd 100644 --- a/src/test/java/demo/tripgo/controller/BookingIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/BookingIntegrationTest.java @@ -118,7 +118,7 @@ private String body(Long tourId, LocalDate day, int adults, int children) { void createBookingGeneratesCodeComputesPriceAndDecrementsSeats() throws Exception { String token = tokenFor(saveUser()); - mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(post("/api/v1/bookings") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(body(tour.getId(), date, 2, 1))) .andExpect(status().isCreated()) @@ -135,7 +135,7 @@ void createBookingGeneratesCodeComputesPriceAndDecrementsSeats() throws Exceptio assertThat(remainingSeatsOn(date)).isEqualTo(7); // Đơn xuất hiện ở GET /bookings (paginated) với tour gọn { title, thumbnail }. - mvc.perform(get("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(get("/api/v1/bookings") .header("Authorization", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(1)) @@ -148,7 +148,7 @@ void createBookingGeneratesCodeComputesPriceAndDecrementsSeats() throws Exceptio @Test void createBookingWithoutTokenReturns401() throws Exception { - mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(post("/api/v1/bookings") .contentType(MediaType.APPLICATION_JSON).content(body(tour.getId(), date, 2, 0))) .andExpect(status().isUnauthorized()); } @@ -161,7 +161,7 @@ void createBookingWithInvalidBodyReturns422WithFieldErrors() throws Exception { {"date":"2000-01-01","adults":0,"children":-1, "contact":{"fullName":"","email":"not-an-email"}} """; - mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(post("/api/v1/bookings") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(bad)) .andExpect(status().isUnprocessableEntity()) @@ -181,7 +181,7 @@ void createBookingWithInvalidPhoneReturns422() throws Exception { {"tourId":%s,"date":"%s","adults":1,"children":0, "contact":{"fullName":"Nguyen A","email":"a@example.com","phone":"abc123","note":"gọi trước"}} """.formatted(tour.getId(), date); - mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(post("/api/v1/bookings") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON).content(bad)) .andExpect(status().isUnprocessableEntity()) @@ -190,7 +190,7 @@ void createBookingWithInvalidPhoneReturns422() throws Exception { @Test void createBookingForUnknownTourReturns404() throws Exception { - mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(post("/api/v1/bookings") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON).content(body(999999L, date, 1, 0))) .andExpect(status().isNotFound()) @@ -199,7 +199,7 @@ void createBookingForUnknownTourReturns404() throws Exception { @Test void createBookingOnDateWithoutDepartureReturns422() throws Exception { - mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(post("/api/v1/bookings") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON) .content(body(tour.getId(), date.plusDays(1), 1, 0))) @@ -212,7 +212,7 @@ void createBookingOnDateWithoutDepartureReturns422() throws Exception { @Test void createBookingWhenSoldOutReturns409() throws Exception { saveDeparture(date.plusDays(2), 2, 0); // chỉ 2 chỗ - mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(post("/api/v1/bookings") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON) .content(body(tour.getId(), date.plusDays(2), 2, 1))) // xin 3 chỗ @@ -225,7 +225,7 @@ void createBookingWhenSoldOutReturns409() throws Exception { @Test void userCannotSeeOthersBooking() throws Exception { String tokenA = tokenFor(saveUser()); - String location = mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + String location = mvc.perform(post("/api/v1/bookings") .header("Authorization", tokenA) .contentType(MediaType.APPLICATION_JSON).content(body(tour.getId(), date, 1, 0))) .andExpect(status().isCreated()) @@ -233,18 +233,18 @@ void userCannotSeeOthersBooking() throws Exception { long bookingId = Long.parseLong(location.replaceAll(".*\"id\":(\\d+).*", "$1")); // User A xem được đơn của mình. - mvc.perform(get("/api/v1/bookings/" + bookingId).contextPath("/api/v1").servletPath("/bookings/" + bookingId) + mvc.perform(get("/api/v1/bookings/" + bookingId) .header("Authorization", tokenA)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(bookingId)); // User B không thấy → 404 (không lộ tồn tại). - mvc.perform(get("/api/v1/bookings/" + bookingId).contextPath("/api/v1").servletPath("/bookings/" + bookingId) + mvc.perform(get("/api/v1/bookings/" + bookingId) .header("Authorization", tokenFor(saveUser()))) .andExpect(status().isNotFound()); // GET /bookings của B rỗng. - mvc.perform(get("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + mvc.perform(get("/api/v1/bookings") .header("Authorization", tokenFor(saveUser()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isEmpty()); @@ -255,7 +255,7 @@ void userCannotSeeOthersBooking() throws Exception { @Test void cancelBookingRestoresSeatsAndIsIdempotentlyGuarded() throws Exception { String token = tokenFor(saveUser()); - String created = mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + String created = mvc.perform(post("/api/v1/bookings") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(body(tour.getId(), date, 2, 0))) .andExpect(status().isCreated()) @@ -263,7 +263,6 @@ void cancelBookingRestoresSeatsAndIsIdempotentlyGuarded() throws Exception { long bookingId = Long.parseLong(created.replaceAll(".*\"id\":(\\d+).*", "$1")); mvc.perform(patch("/api/v1/bookings/" + bookingId + "/cancel") - .contextPath("/api/v1").servletPath("/bookings/" + bookingId + "/cancel") .header("Authorization", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("cancelled")); @@ -273,7 +272,6 @@ void cancelBookingRestoresSeatsAndIsIdempotentlyGuarded() throws Exception { // Huỷ lần nữa → 409. mvc.perform(patch("/api/v1/bookings/" + bookingId + "/cancel") - .contextPath("/api/v1").servletPath("/bookings/" + bookingId + "/cancel") .header("Authorization", token)) .andExpect(status().isConflict()) .andExpect(jsonPath("$.error.code").value("CONFLICT")); @@ -281,7 +279,7 @@ void cancelBookingRestoresSeatsAndIsIdempotentlyGuarded() throws Exception { @Test void cancelOthersBookingReturns404() throws Exception { - String created = mvc.perform(post("/api/v1/bookings").contextPath("/api/v1").servletPath("/bookings") + String created = mvc.perform(post("/api/v1/bookings") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON).content(body(tour.getId(), date, 1, 0))) .andExpect(status().isCreated()) @@ -289,7 +287,6 @@ void cancelOthersBookingReturns404() throws Exception { long bookingId = Long.parseLong(created.replaceAll(".*\"id\":(\\d+).*", "$1")); mvc.perform(patch("/api/v1/bookings/" + bookingId + "/cancel") - .contextPath("/api/v1").servletPath("/bookings/" + bookingId + "/cancel") .header("Authorization", tokenFor(saveUser()))) .andExpect(status().isNotFound()); } diff --git a/src/test/java/demo/tripgo/controller/CategoryControllerIntegrationTest.java b/src/test/java/demo/tripgo/controller/CategoryControllerIntegrationTest.java index ec6a725..ec32957 100644 --- a/src/test/java/demo/tripgo/controller/CategoryControllerIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/CategoryControllerIntegrationTest.java @@ -50,7 +50,7 @@ void setUp() { // Public: dropdown lọc phải xem được khi chưa đăng nhập. @Test void listsAllCategoriesWithVietnameseLabelsWithoutToken() throws Exception { - mvc.perform(get("/api/v1/categories").contextPath("/api/v1").servletPath("/categories")) + mvc.perform(get("/api/v1/categories")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(6)) .andExpect(jsonPath("$.data[0].slug").value("beach")) @@ -63,7 +63,7 @@ void listsAllCategoriesWithVietnameseLabelsWithoutToken() throws Exception { @Test void everySlugIsAcceptedByTourCategoryFilter() throws Exception { for (String[] row : SEED) { - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("category", row[0])) .andExpect(status().isOk()); } @@ -72,7 +72,7 @@ void everySlugIsAcceptedByTourCategoryFilter() throws Exception { // slug không có trong bảng -> 400, không lặng lẽ trả danh sách rỗng. @Test void unknownCategorySlugReturns400() throws Exception { - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("category", "khong-ton-tai")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error.code").value("BAD_REQUEST")); diff --git a/src/test/java/demo/tripgo/controller/DestinationControllerIntegrationTest.java b/src/test/java/demo/tripgo/controller/DestinationControllerIntegrationTest.java index 34a59a7..025e2a7 100644 --- a/src/test/java/demo/tripgo/controller/DestinationControllerIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/DestinationControllerIntegrationTest.java @@ -63,7 +63,7 @@ void listReturnsDestinationsSortedByNameWithTourCount() throws Exception { saveTour("DN 2", daNang); saveTour("HN 1", haNoi); - mvc.perform(get("/api/v1/destinations").contextPath("/api/v1").servletPath("/destinations")) + mvc.perform(get("/api/v1/destinations")) .andExpect(status().isOk()) // Object envelope, không phải array trần. .andExpect(jsonPath("$.data").isArray()) @@ -79,7 +79,7 @@ void listReturnsDestinationsSortedByNameWithTourCount() throws Exception { @Test void listWithNoDestinationsReturnsEmptyData() throws Exception { - mvc.perform(get("/api/v1/destinations").contextPath("/api/v1").servletPath("/destinations")) + mvc.perform(get("/api/v1/destinations")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isEmpty()); } @@ -88,7 +88,7 @@ void listWithNoDestinationsReturnsEmptyData() throws Exception { void listIsPublicAndDoesNotRequireAuth() throws Exception { saveDestination("Da Nang", "da-nang"); // Không gửi token vẫn 200 (không phải 401). - mvc.perform(get("/api/v1/destinations").contextPath("/api/v1").servletPath("/destinations")) + mvc.perform(get("/api/v1/destinations")) .andExpect(status().isOk()); } diff --git a/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java b/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java index 96ced56..6d5b0ed 100644 --- a/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java @@ -76,7 +76,7 @@ void filtersByDestinationSortsByPriceAscAndPaginates() throws Exception { saveTour("HN-noise", haNoi, category("city", "Thành phố"), 2, "150", 5.0); // Trang 1, limit 2, sort giá tăng -> [DN-cheap, DN-mid]. - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("destination", "da-nang").param("sort", "price_asc") .param("page", "1").param("limit", "2")) .andExpect(status().isOk()) @@ -89,7 +89,7 @@ void filtersByDestinationSortsByPriceAscAndPaginates() throws Exception { .andExpect(jsonPath("$.data[0].destination").value("Da Nang")); // Trang 2 -> [DN-expensive], không lẫn tour Ha Noi. - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("destination", "da-nang").param("sort", "price_asc") .param("page", "2").param("limit", "2")) .andExpect(status().isOk()) @@ -106,7 +106,7 @@ void sortRatingAndRatingDescAreEquivalent() throws Exception { saveTour("high", daNang, category("beach", "Biển đảo"), 3, "100", 4.9); for (String value : new String[]{"rating", "rating_desc"}) { - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("sort", value)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].title").value("high")); @@ -119,7 +119,7 @@ void sortByRatingDescReturnsHighestRatedFirst() throws Exception { saveTour("high", daNang, category("beach", "Biển đảo"), 3, "100", 4.9); saveTour("mid", daNang, category("beach", "Biển đảo"), 3, "100", 4.0); - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("sort", "rating_desc")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].title").value("high")) @@ -133,7 +133,7 @@ void combinesCategoryPriceDurationRatingAndKeywordFilters() throws Exception { saveTour("Beach budget", daNang, category("beach", "Biển đảo"), 5, "150", 4.5); saveTour("Mountain trek", daNang, category("mountain", "Núi rừng"), 3, "500", 4.5); - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("q", "beach") .param("category", "beach") .param("minPrice", "300") @@ -153,14 +153,14 @@ void keywordTreatsUnderscoreAndPercentAsLiteralNotWildcard() throws Exception { saveTour("50 nights Tour", daNang, category("beach", "Biển đảo"), 3, "100", 4.0); // "_" phải là ký tự literal: chỉ khớp "A_B ...", không khớp "AXB ...". - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("q", "A_B")) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(1)) .andExpect(jsonPath("$.data[0].title").value("A_B Special Tour")); // "%" cũng phải literal: chỉ khớp "50% Off ...", không khớp "50 nights ...". - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("q", "50%")) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(1)) @@ -171,7 +171,7 @@ void keywordTreatsUnderscoreAndPercentAsLiteralNotWildcard() throws Exception { void emptyResultReturnsEmptyDataAndZeroTotal() throws Exception { saveTour("only-tour", daNang, category("beach", "Biển đảo"), 3, "100", 4.0); - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("destination", "nowhere")) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(0)) @@ -181,13 +181,13 @@ void emptyResultReturnsEmptyDataAndZeroTotal() throws Exception { @Test void invalidPaginationParamsReturn422() throws Exception { - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("page", "0")) .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.error.code").value("VALIDATION")) .andExpect(jsonPath("$.error.fields.page").isNotEmpty()); - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("limit", "100")) .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.error.code").value("VALIDATION")) @@ -196,12 +196,12 @@ void invalidPaginationParamsReturn422() throws Exception { @Test void invalidSortAndCategoryReturn400() throws Exception { - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("sort", "cheapest")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error.code").value("BAD_REQUEST")); - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("category", "spaceship")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error.code").value("BAD_REQUEST")); @@ -246,7 +246,7 @@ void detailReturnsFullTourForClientDetailScreen() throws Exception { tour.addItineraryDay(d2); Long id = tours.save(tour).getId(); - mvc.perform(get("/api/v1/tours/" + id).contextPath("/api/v1").servletPath("/tours/" + id)) + mvc.perform(get("/api/v1/tours/" + id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(id)) .andExpect(jsonPath("$.title").value("Full Da Nang Tour")) @@ -267,7 +267,7 @@ void detailReturnsFullTourForClientDetailScreen() throws Exception { @Test void detailForUnknownIdReturns404() throws Exception { - mvc.perform(get("/api/v1/tours/999999").contextPath("/api/v1").servletPath("/tours/999999")) + mvc.perform(get("/api/v1/tours/999999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error.code").value("NOT_FOUND")) .andExpect(jsonPath("$.error.message").isNotEmpty()); @@ -279,8 +279,7 @@ void detailBySlugReturnsSameTourAsById() throws Exception { tour.setSlug("slug-tour-abc"); Long id = tours.save(tour).getId(); - mvc.perform(get("/api/v1/tours/slug-tour-abc") - .contextPath("/api/v1").servletPath("/tours/slug-tour-abc")) + mvc.perform(get("/api/v1/tours/slug-tour-abc")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(id)) .andExpect(jsonPath("$.slug").value("slug-tour-abc")) @@ -289,7 +288,7 @@ void detailBySlugReturnsSameTourAsById() throws Exception { .andExpect(jsonPath("$.startDates").isArray()); // Cùng một tour, tra bằng id dạng số vẫn ra kết quả như vậy. - mvc.perform(get("/api/v1/tours/" + id).contextPath("/api/v1").servletPath("/tours/" + id)) + mvc.perform(get("/api/v1/tours/" + id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.slug").value("slug-tour-abc")); } @@ -297,8 +296,7 @@ void detailBySlugReturnsSameTourAsById() throws Exception { // /tours/{slug} nhận chuỗi nên "not-a-number" là slug hợp lệ về kiểu, chỉ là không tồn tại. @Test void detailForUnknownSlugReturns404() throws Exception { - mvc.perform(get("/api/v1/tours/not-a-number") - .contextPath("/api/v1").servletPath("/tours/not-a-number")) + mvc.perform(get("/api/v1/tours/not-a-number")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error.code").value("NOT_FOUND")) .andExpect(jsonPath("$.error.message").value("Không tìm thấy tour")); @@ -307,8 +305,7 @@ void detailForUnknownSlugReturns404() throws Exception { // Endpoint con vẫn định danh bằng id kiểu Long -> vẫn phải trả 400 với message sạch. @Test void pathVariableTypeMismatchReturnsCleanMessage() throws Exception { - mvc.perform(get("/api/v1/tours/not-a-number/availability") - .contextPath("/api/v1").servletPath("/tours/not-a-number/availability")) + mvc.perform(get("/api/v1/tours/not-a-number/availability")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error.code").value("BAD_REQUEST")) .andExpect(jsonPath("$.error.message").value("Tham số 'id' phải là số nguyên")) @@ -319,7 +316,7 @@ void pathVariableTypeMismatchReturnsCleanMessage() throws Exception { @Test void queryParamTypeMismatchReturnsCleanFieldMessage() throws Exception { - mvc.perform(get("/api/v1/tours").contextPath("/api/v1").servletPath("/tours") + mvc.perform(get("/api/v1/tours") .param("minPrice", "abc")) .andExpect(status().isUnprocessableEntity()) .andExpect(jsonPath("$.error.code").value("VALIDATION")) diff --git a/src/test/java/demo/tripgo/controller/TourReviewAvailabilityIntegrationTest.java b/src/test/java/demo/tripgo/controller/TourReviewAvailabilityIntegrationTest.java index 5243674..36d2f0d 100644 --- a/src/test/java/demo/tripgo/controller/TourReviewAvailabilityIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/TourReviewAvailabilityIntegrationTest.java @@ -151,7 +151,6 @@ void availabilityFilteredByMonthReturnsRemainingSeats() throws Exception { saveDeparture(month.plusMonths(1).atDay(1), 5, 0); // tháng khác, không được lấy mvc.perform(get("/api/v1/tours/" + tour.getId() + "/availability") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/availability") .param("month", month.toString())) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(2)) @@ -166,8 +165,7 @@ void availabilityWithoutMonthReturnsOnlyUpcoming() throws Exception { saveDeparture(LocalDate.now().plusDays(5), 10, 2); saveDeparture(LocalDate.now().plusDays(30), 10, 1); - mvc.perform(get("/api/v1/tours/" + tour.getId() + "/availability") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/availability")) + mvc.perform(get("/api/v1/tours/" + tour.getId() + "/availability")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(2)); } @@ -175,7 +173,6 @@ void availabilityWithoutMonthReturnsOnlyUpcoming() throws Exception { @Test void availabilityWithBadMonthReturns400() throws Exception { mvc.perform(get("/api/v1/tours/" + tour.getId() + "/availability") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/availability") .param("month", "2026-13")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error.code").value("BAD_REQUEST")); @@ -183,8 +180,7 @@ void availabilityWithBadMonthReturns400() throws Exception { @Test void availabilityForUnknownTourReturns404() throws Exception { - mvc.perform(get("/api/v1/tours/999999/availability") - .contextPath("/api/v1").servletPath("/tours/999999/availability")) + mvc.perform(get("/api/v1/tours/999999/availability")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error.code").value("NOT_FOUND")); } @@ -193,8 +189,7 @@ void availabilityForUnknownTourReturns404() throws Exception { @Test void reviewsEmptyReturnsEmptyDataZeroTotalAndZeroAverage() throws Exception { - mvc.perform(get("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews")) + mvc.perform(get("/api/v1/tours/" + tour.getId() + "/reviews")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data").isEmpty()) .andExpect(jsonPath("$.total").value(0)) @@ -204,18 +199,15 @@ void reviewsEmptyReturnsEmptyDataZeroTotalAndZeroAverage() throws Exception { @Test void reviewsReturnPaginatedListWithAverage() throws Exception { mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", tokenFor(saveUserWithBooking())) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(4, "good"))) .andExpect(status().isCreated()); mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", tokenFor(saveUserWithBooking())) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(2, "meh"))) .andExpect(status().isCreated()); mvc.perform(get("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .param("page", "1").param("limit", "10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.total").value(2)) @@ -231,7 +223,6 @@ void reviewsReturnPaginatedListWithAverage() throws Exception { @Test void createReviewUpdatesTourRatingAggregate() throws Exception { mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", tokenFor(saveUserWithBooking())) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(5, "great"))) .andExpect(status().isCreated()) @@ -240,8 +231,7 @@ void createReviewUpdatesTourRatingAggregate() throws Exception { .andExpect(jsonPath("$.review.user.name").value("Reviewer")); // Rating denormalized trên tour phải cập nhật để màn chi tiết phản ánh đúng. - mvc.perform(get("/api/v1/tours/" + tour.getId()) - .contextPath("/api/v1").servletPath("/tours/" + tour.getId())) + mvc.perform(get("/api/v1/tours/" + tour.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("$.rating").value(5.0)) .andExpect(jsonPath("$.reviewCount").value(1)); @@ -250,7 +240,6 @@ void createReviewUpdatesTourRatingAggregate() throws Exception { @Test void createReviewWithoutAuthReturns401() throws Exception { mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .contentType(MediaType.APPLICATION_JSON).content(reviewBody(4, "nice"))) .andExpect(status().isUnauthorized()); } @@ -259,12 +248,10 @@ void createReviewWithoutAuthReturns401() throws Exception { void createDuplicateReviewBySameUserReturns409() throws Exception { String token = tokenFor(saveUserWithBooking()); mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(4, "first"))) .andExpect(status().isCreated()); mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(5, "again"))) .andExpect(status().isConflict()) @@ -275,7 +262,6 @@ void createDuplicateReviewBySameUserReturns409() throws Exception { @Test void createReviewWithoutBookingReturns403() throws Exception { mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(5, "chua dat tour"))) .andExpect(status().isForbidden()) @@ -287,7 +273,6 @@ void createReviewWithoutBookingReturns403() throws Exception { void createReviewWithInvalidRatingReturns422() throws Exception { for (int rating : new int[]{0, 6}) { mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(rating, "x"))) .andExpect(status().isUnprocessableEntity()) @@ -303,13 +288,11 @@ void raceLoserOnDuplicateStillGetsConsistentMessage() throws Exception { String token = tokenFor(saveUserWithBooking()); mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(4, "first"))) .andExpect(status().isCreated()); mvc.perform(post("/api/v1/tours/" + tour.getId() + "/reviews") - .contextPath("/api/v1").servletPath("/tours/" + tour.getId() + "/reviews") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(5, "again"))) .andExpect(status().isConflict()) @@ -319,7 +302,6 @@ void raceLoserOnDuplicateStillGetsConsistentMessage() throws Exception { @Test void createReviewForUnknownTourReturns404() throws Exception { mvc.perform(post("/api/v1/tours/999999/reviews") - .contextPath("/api/v1").servletPath("/tours/999999/reviews") .header("Authorization", tokenFor(saveUser())) .contentType(MediaType.APPLICATION_JSON).content(reviewBody(4, "x"))) .andExpect(status().isNotFound()) diff --git a/src/test/java/demo/tripgo/controller/WishlistIntegrationTest.java b/src/test/java/demo/tripgo/controller/WishlistIntegrationTest.java index 30a5d37..88b9961 100644 --- a/src/test/java/demo/tripgo/controller/WishlistIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/WishlistIntegrationTest.java @@ -88,13 +88,12 @@ private String body(Long tourId) { @Test void allWishlistEndpointsRequireAuthentication() throws Exception { - mvc.perform(get("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist")) + mvc.perform(get("/api/v1/wishlist")) .andExpect(status().isUnauthorized()); - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .contentType(MediaType.APPLICATION_JSON).content(body(tourA.getId()))) .andExpect(status().isUnauthorized()); - mvc.perform(delete("/api/v1/wishlist/" + tourA.getId()) - .contextPath("/api/v1").servletPath("/wishlist/" + tourA.getId())) + mvc.perform(delete("/api/v1/wishlist/" + tourA.getId())) .andExpect(status().isUnauthorized()); } @@ -102,7 +101,7 @@ void allWishlistEndpointsRequireAuthentication() throws Exception { @Test void wishlistStartsEmpty() throws Exception { - mvc.perform(get("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(get("/api/v1/wishlist") .header("Authorization", tokenFor())) .andExpect(status().isOk()) .andExpect(jsonPath("$.tourIds").isArray()) @@ -113,27 +112,26 @@ void wishlistStartsEmpty() throws Exception { void addThenGetThenRemove() throws Exception { String token = tokenFor(); - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(body(tourA.getId()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.tourIds.length()").value(1)) .andExpect(jsonPath("$.tourIds[0]").value(tourA.getId())); - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(body(tourB.getId()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.tourIds.length()").value(2)); - mvc.perform(get("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(get("/api/v1/wishlist") .header("Authorization", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.tourIds.length()").value(2)); // DELETE báo kết quả xoá, không trả lại danh sách. mvc.perform(delete("/api/v1/wishlist/" + tourA.getId()) - .contextPath("/api/v1").servletPath("/wishlist/" + tourA.getId()) .header("Authorization", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.removed").value(true)) @@ -142,7 +140,7 @@ void addThenGetThenRemove() throws Exception { .andExpect(jsonPath("$.tourIds").doesNotExist()); // Danh sách sau khi xoá lấy bằng GET. - mvc.perform(get("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(get("/api/v1/wishlist") .header("Authorization", token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.tourIds.length()").value(1)) @@ -156,14 +154,14 @@ void addingSameTourTwiceDoesNotDuplicate() throws Exception { String token = tokenFor(); for (int i = 0; i < 3; i++) { - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON).content(body(tourA.getId()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.tourIds.length()").value(1)); } - mvc.perform(get("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(get("/api/v1/wishlist") .header("Authorization", token)) .andExpect(jsonPath("$.tourIds.length()").value(1)); } @@ -175,24 +173,23 @@ void wishlistIsIsolatedBetweenUsers() throws Exception { String tokenA = tokenFor(); String tokenB = tokenFor(); - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", tokenA) .contentType(MediaType.APPLICATION_JSON).content(body(tourA.getId()))) .andExpect(status().isOk()); // User B không thấy tour của A. - mvc.perform(get("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(get("/api/v1/wishlist") .header("Authorization", tokenB)) .andExpect(jsonPath("$.tourIds").isEmpty()); // B xoá đúng tourId đó cũng không đụng được wishlist của A. mvc.perform(delete("/api/v1/wishlist/" + tourA.getId()) - .contextPath("/api/v1").servletPath("/wishlist/" + tourA.getId()) .header("Authorization", tokenB)) .andExpect(status().isOk()) .andExpect(jsonPath("$.removed").value(false)); - mvc.perform(get("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(get("/api/v1/wishlist") .header("Authorization", tokenA)) .andExpect(jsonPath("$.tourIds.length()").value(1)); } @@ -201,7 +198,7 @@ void wishlistIsIsolatedBetweenUsers() throws Exception { @Test void addingUnknownTourReturns404() throws Exception { - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", tokenFor()) .contentType(MediaType.APPLICATION_JSON).content(body(999999L))) .andExpect(status().isNotFound()) @@ -210,7 +207,7 @@ void addingUnknownTourReturns404() throws Exception { @Test void addingWithoutTourIdReturns422() throws Exception { - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", tokenFor()) .contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isUnprocessableEntity()) @@ -221,7 +218,7 @@ void addingWithoutTourIdReturns422() throws Exception { // Từng là bug: exception lọt ra /error, filter không chạy lại -> SecurityContext rỗng -> 401. @Test void addingWithWrongTypeReturns422NotUnauthorized() throws Exception { - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", tokenFor()) .contentType(MediaType.APPLICATION_JSON).content("{\"tourId\":[\"3\",\"4\"]}")) .andExpect(status().isUnprocessableEntity()) @@ -232,7 +229,7 @@ void addingWithWrongTypeReturns422NotUnauthorized() throws Exception { // JSON hỏng hẳn (không xác định được trường nào) -> 400, vẫn không phải 401. @Test void addingWithBrokenJsonReturns400NotUnauthorized() throws Exception { - mvc.perform(post("/api/v1/wishlist").contextPath("/api/v1").servletPath("/wishlist") + mvc.perform(post("/api/v1/wishlist") .header("Authorization", tokenFor()) .contentType(MediaType.APPLICATION_JSON).content("{\"tourId\": ")) .andExpect(status().isBadRequest()) @@ -243,7 +240,6 @@ void addingWithBrokenJsonReturns400NotUnauthorized() throws Exception { @Test void removingTourNotInWishlistIsNoOp() throws Exception { mvc.perform(delete("/api/v1/wishlist/" + tourA.getId()) - .contextPath("/api/v1").servletPath("/wishlist/" + tourA.getId()) .header("Authorization", tokenFor())) .andExpect(status().isOk()) .andExpect(jsonPath("$.removed").value(false)) diff --git a/src/test/java/demo/tripgo/security/RateLimitFilterTest.java b/src/test/java/demo/tripgo/security/RateLimitFilterTest.java index 4068510..ac4305c 100644 --- a/src/test/java/demo/tripgo/security/RateLimitFilterTest.java +++ b/src/test/java/demo/tripgo/security/RateLimitFilterTest.java @@ -17,7 +17,7 @@ class RateLimitFilterTest { private HttpServletRequest authRequest(String ip) { HttpServletRequest request = mock(HttpServletRequest.class); when(request.getMethod()).thenReturn("POST"); - when(request.getServletPath()).thenReturn("/auth/login"); + when(request.getServletPath()).thenReturn("/api/v1/auth/login"); when(request.getHeader("X-Forwarded-For")).thenReturn(null); when(request.getRemoteAddr()).thenReturn(ip); return request; @@ -59,9 +59,9 @@ void differentIpsHaveSeparateBudgets() throws Exception { void onlyRateLimitsPostAuthPaths() { RateLimitFilter filter = new RateLimitFilter(mock(SecurityErrorResponder.class), 20, 60); - assertThat(filter.shouldNotFilter(request("POST", "/auth/login"))).isFalse(); - assertThat(filter.shouldNotFilter(request("POST", "/auth/register"))).isFalse(); - assertThat(filter.shouldNotFilter(request("GET", "/auth/me"))).isTrue(); + assertThat(filter.shouldNotFilter(request("POST", "/api/v1/auth/login"))).isFalse(); + assertThat(filter.shouldNotFilter(request("POST", "/api/v1/auth/register"))).isFalse(); + assertThat(filter.shouldNotFilter(request("GET", "/api/v1/auth/me"))).isTrue(); assertThat(filter.shouldNotFilter(request("POST", "/tours/1/reviews"))).isTrue(); assertThat(filter.shouldNotFilter(request("POST", "/bookings"))).isTrue(); } From 8c659af7ae8a89eb5cc4e7151e1c5f8f9f129db1 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 13:10:26 +0700 Subject: [PATCH 02/23] Khu quan tri: layout Thymeleaf, dang nhap, phan quyen ADMIN Them SecurityFilterChain rieng cho /admin/**, nguoc hoan toan voi chain API: co session + BAT CSRF + form login, thay vi stateless + Bearer JWT. securityMatcher gioi han pham vi nen API khong bi anh huong. AdminUserDetailsService doc bang users cho form login. Truoc day khong co bean nay nen Spring Boot tu tao user mac dinh (dong "Using generated security password" luc khoi dong) - gio het. Tach hai loai "bi tu choi" thay vi gop lam mot: thieu/sai token CSRF tra 403 (loi ky thuat), con dung nguoi sai quyen thi ve /admin/login?denied. Gop chung thi loi CSRF hien thanh "khong co quyen", che mat nguyen nhan that. GlobalExceptionHandler thu hep ve basePackages = demo.tripgo.controller, neu khong no bat luon exception cua trang quan tri va tra JSON giua man hinh. Bu lai co AdminExceptionHandler tra trang 404 HTML. AdminAccountSeeder tao tai khoan admin dau tien tu ADMIN_EMAIL/ADMIN_PASSWORD. Chi TAO MOI, khong bao gio ghi de tai khoan da co: nguoc lai thi ai doc duoc bien moi truong cung chiem duoc tai khoan admin. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 7 + README.md | 33 +- pom.xml | 5 + .../admin/AdminDashboardController.java | 17 + .../tripgo/admin/AdminExceptionHandler.java | 23 + .../tripgo/admin/AdminLoginController.java | 32 + .../demo/tripgo/admin/AdminModelAdvice.java | 16 + .../tripgo/config/AdminAccountSeeder.java | 58 ++ .../tripgo/config/AdminSecurityConfig.java | 72 ++ .../exception/GlobalExceptionHandler.java | 4 +- .../security/AdminUserDetailsService.java | 45 ++ src/main/resources/static/css/admin.css | 666 ++++++++++++++++++ .../resources/static/js/admin-itinerary.js | 52 ++ .../resources/templates/admin/dashboard.html | 32 + .../resources/templates/admin/layout.html | 55 ++ src/main/resources/templates/admin/login.html | 35 + src/main/resources/templates/error/404.html | 23 + src/main/resources/templates/error/500.html | 19 + .../admin/AdminSecurityIntegrationTest.java | 151 ++++ 19 files changed, 1342 insertions(+), 3 deletions(-) create mode 100644 src/main/java/demo/tripgo/admin/AdminDashboardController.java create mode 100644 src/main/java/demo/tripgo/admin/AdminExceptionHandler.java create mode 100644 src/main/java/demo/tripgo/admin/AdminLoginController.java create mode 100644 src/main/java/demo/tripgo/admin/AdminModelAdvice.java create mode 100644 src/main/java/demo/tripgo/config/AdminAccountSeeder.java create mode 100644 src/main/java/demo/tripgo/config/AdminSecurityConfig.java create mode 100644 src/main/java/demo/tripgo/security/AdminUserDetailsService.java create mode 100644 src/main/resources/static/css/admin.css create mode 100644 src/main/resources/static/js/admin-itinerary.js create mode 100644 src/main/resources/templates/admin/dashboard.html create mode 100644 src/main/resources/templates/admin/layout.html create mode 100644 src/main/resources/templates/admin/login.html create mode 100644 src/main/resources/templates/error/404.html create mode 100644 src/main/resources/templates/error/500.html create mode 100644 src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java diff --git a/.env.example b/.env.example index 26d6b3d..efc4499 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,10 @@ 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" diff --git a/README.md b/README.md index 55a7b1b..48b4fae 100644 --- a/README.md +++ b/README.md @@ -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) --- @@ -64,6 +66,33 @@ 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`. + +--- + ## 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. @@ -132,7 +161,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ử diff --git a/pom.xml b/pom.xml index 4e18c02..a426c6a 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,11 @@ org.springframework.boot spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-thymeleaf + org.postgresql diff --git a/src/main/java/demo/tripgo/admin/AdminDashboardController.java b/src/main/java/demo/tripgo/admin/AdminDashboardController.java new file mode 100644 index 0000000..3dd5bba --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminDashboardController.java @@ -0,0 +1,17 @@ +package demo.tripgo.admin; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +// Khung dashboard của giai đoạn này: bố cục và điều hướng đã chạy, 4 ô số liệu sẽ được nối vào +// dữ liệu thật ở giai đoạn sau (tổng tour, đơn chờ xác nhận, đơn tháng này, doanh thu tháng). +@Controller +public class AdminDashboardController { + + @GetMapping("/admin") + public String dashboard(Model model) { + model.addAttribute("activeMenu", "dashboard"); + return "admin/dashboard"; + } +} diff --git a/src/main/java/demo/tripgo/admin/AdminExceptionHandler.java b/src/main/java/demo/tripgo/admin/AdminExceptionHandler.java new file mode 100644 index 0000000..77875bf --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminExceptionHandler.java @@ -0,0 +1,23 @@ +package demo.tripgo.admin; + +import demo.tripgo.exception.ResourceNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +// GlobalExceptionHandler chỉ còn phục vụ package controller của API (trả JSON), nên khu quản trị +// cần bản riêng trả về TRANG HTML. Không có nó, một link cũ trỏ tới tour đã bị xoá hẳn sẽ ném +// exception ra ngoài và admin nhận trang 500 thay vì "không tìm thấy". +@ControllerAdvice(basePackages = "demo.tripgo.admin") +public class AdminExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public String handleNotFound(HttpServletRequest request, Model model) { + model.addAttribute("path", request.getRequestURI()); + return "error/404"; + } +} diff --git a/src/main/java/demo/tripgo/admin/AdminLoginController.java b/src/main/java/demo/tripgo/admin/AdminLoginController.java new file mode 100644 index 0000000..6a5f8ec --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminLoginController.java @@ -0,0 +1,32 @@ +package demo.tripgo.admin; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +// Chỉ trả về trang đăng nhập. Việc kiểm tra email/mật khẩu do formLogin của Spring Security làm, +// nên ở đây không có xử lý POST nào cả. +@Controller +public class AdminLoginController { + + @GetMapping("/admin/login") + public String loginPage( + @RequestParam(required = false) String error, + @RequestParam(required = false) String logout, + @RequestParam(required = false) String denied, + Model model + ) { + if (error != null) { + model.addAttribute("message", "Email hoặc mật khẩu không đúng"); + model.addAttribute("messageType", "error"); + } else if (denied != null) { + model.addAttribute("message", "Tài khoản của bạn không có quyền vào khu quản trị"); + model.addAttribute("messageType", "error"); + } else if (logout != null) { + model.addAttribute("message", "Bạn đã đăng xuất"); + model.addAttribute("messageType", "info"); + } + return "admin/login"; + } +} diff --git a/src/main/java/demo/tripgo/admin/AdminModelAdvice.java b/src/main/java/demo/tripgo/admin/AdminModelAdvice.java new file mode 100644 index 0000000..46e00b1 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminModelAdvice.java @@ -0,0 +1,16 @@ +package demo.tripgo.admin; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +// Topbar cần biết ai đang đăng nhập ở MỌI trang quản trị. Đưa vào model một chỗ thay vì lặp +// ở từng controller (và tránh phải thêm thư viện thymeleaf-extras-springsecurity chỉ để lấy tên). +@ControllerAdvice(basePackages = "demo.tripgo.admin") +public class AdminModelAdvice { + + @ModelAttribute("currentAdmin") + public String currentAdmin(Authentication authentication) { + return authentication == null ? null : authentication.getName(); + } +} diff --git a/src/main/java/demo/tripgo/config/AdminAccountSeeder.java b/src/main/java/demo/tripgo/config/AdminAccountSeeder.java new file mode 100644 index 0000000..9df9d2e --- /dev/null +++ b/src/main/java/demo/tripgo/config/AdminAccountSeeder.java @@ -0,0 +1,58 @@ +package demo.tripgo.config; + +import demo.tripgo.entity.Role; +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Locale; + +// POST /auth/register luôn tạo Role.USER nên không có đường nào tạo admin đầu tiên. +// Bean này tạo tài khoản đó từ ADMIN_EMAIL/ADMIN_PASSWORD lúc khởi động. +// +// Chỉ TẠO MỚI, không bao giờ sửa tài khoản đã có: nếu không, đổi biến môi trường sẽ âm thầm +// ghi đè mật khẩu admin đang dùng, và ai đọc được biến môi trường cũng chiếm được tài khoản. +@Configuration +public class AdminAccountSeeder { + + private static final Logger log = LoggerFactory.getLogger(AdminAccountSeeder.class); + + @Bean + public ApplicationRunner seedAdminAccount( + UserRepository userRepository, + PasswordEncoder passwordEncoder, + @Value("${admin.seed.email:}") String email, + @Value("${admin.seed.password:}") String password, + @Value("${admin.seed.name:Quản trị viên}") String name + ) { + return args -> { + if (email.isBlank() || password.isBlank()) { + log.info("Bỏ qua tạo tài khoản admin: chưa đặt ADMIN_EMAIL/ADMIN_PASSWORD"); + return; + } + + String normalizedEmail = email.trim().toLowerCase(Locale.ROOT); + if (userRepository.existsByEmail(normalizedEmail)) { + log.info("Tài khoản admin {} đã tồn tại, không tạo lại", normalizedEmail); + return; + } + + User admin = new User(); + admin.setEmail(normalizedEmail); + admin.setFullName(name); + admin.setPassword(passwordEncoder.encode(password)); + admin.setRole(Role.ADMIN); + admin.setStatus(UserStatus.ACTIVE); + userRepository.save(admin); + + log.info("Đã tạo tài khoản admin {}", normalizedEmail); + }; + } +} diff --git a/src/main/java/demo/tripgo/config/AdminSecurityConfig.java b/src/main/java/demo/tripgo/config/AdminSecurityConfig.java new file mode 100644 index 0000000..7be4077 --- /dev/null +++ b/src/main/java/demo/tripgo/config/AdminSecurityConfig.java @@ -0,0 +1,72 @@ +package demo.tripgo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.AccessDeniedHandlerImpl; +import org.springframework.security.web.csrf.CsrfException; + +// Khu quản trị chạy bằng một SecurityFilterChain RIÊNG, ngược hoàn toàn với chain của API: +// +// chain API (SecurityConfig) chain admin (file này) +// Phiên STATELESS có session (form login) +// CSRF tắt (không dùng cookie) BẬT (form Thymeleaf tự chèn token) +// Xác thực Bearer JWT form email + mật khẩu +// Quyền authenticated() hasRole("ADMIN") +// +// securityMatcher giới hạn chain này đúng /admin/**, nên API không bị ảnh hưởng gì. +@Configuration +public class AdminSecurityConfig { + + public static final String ADMIN_BASE = "/admin"; + public static final String LOGIN_PAGE = ADMIN_BASE + "/login"; + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher(ADMIN_BASE + "/**") + // CSRF bật mặc định: đây là chỗ áp dụng đúng nghĩa, vì có form + session cookie. + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(LOGIN_PAGE).permitAll() + // Cả khu quản trị chỉ dành cho ADMIN; user thường đăng nhập được nhưng vào là 403. + .anyRequest().hasRole("ADMIN")) + .formLogin(form -> form + .loginPage(LOGIN_PAGE) + .loginProcessingUrl(LOGIN_PAGE) + .usernameParameter("email") + .passwordParameter("password") + .defaultSuccessUrl(ADMIN_BASE, true) + .failureUrl(LOGIN_PAGE + "?error") + .permitAll()) + .logout(logout -> logout + .logoutUrl(ADMIN_BASE + "/logout") + .logoutSuccessUrl(LOGIN_PAGE + "?logout") + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID")) + .exceptionHandling(exceptions -> exceptions.accessDeniedHandler(accessDeniedHandler())); + + return http.build(); + } + + // Hai loại "bị từ chối" rất khác nhau, không được gộp làm một: + // - thiếu/sai token CSRF -> lỗi kỹ thuật, trả 403 để thấy ngay là form hỏng; + // - đúng người nhưng sai quyền (user thường vào /admin) -> đưa về trang login kèm lời nhắn. + // Gộp chung thì lỗi CSRF sẽ hiện thành "không có quyền", che mất nguyên nhân thật. + private AccessDeniedHandler accessDeniedHandler() { + AccessDeniedHandler csrfHandler = new AccessDeniedHandlerImpl(); + return (request, response, exception) -> { + if (exception instanceof CsrfException) { + csrfHandler.handle(request, response, exception); + return; + } + response.sendRedirect(request.getContextPath() + LOGIN_PAGE + "?denied"); + }; + } +} diff --git a/src/main/java/demo/tripgo/exception/GlobalExceptionHandler.java b/src/main/java/demo/tripgo/exception/GlobalExceptionHandler.java index 1e1c1b2..2ab9dd2 100644 --- a/src/main/java/demo/tripgo/exception/GlobalExceptionHandler.java +++ b/src/main/java/demo/tripgo/exception/GlobalExceptionHandler.java @@ -22,7 +22,9 @@ // Trả lỗi theo định dạng thống nhất { "error": { "code", "message", "fields"? } }. // code = tên HttpStatus (vd "NOT_FOUND"); fields chỉ có ở lỗi validate. Không lộ stack trace/secret. -@RestControllerAdvice +// Giới hạn đúng package controller của API: nếu để mặc định, handler này bắt luôn exception của +// khu quản trị và trả JSON giữa màn hình thay vì trang lỗi HTML. +@RestControllerAdvice(basePackages = "demo.tripgo.controller") public class GlobalExceptionHandler { @ExceptionHandler(DataIntegrityViolationException.class) diff --git a/src/main/java/demo/tripgo/security/AdminUserDetailsService.java b/src/main/java/demo/tripgo/security/AdminUserDetailsService.java new file mode 100644 index 0000000..b131814 --- /dev/null +++ b/src/main/java/demo/tripgo/security/AdminUserDetailsService.java @@ -0,0 +1,45 @@ +package demo.tripgo.security; + +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.repository.UserRepository; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Locale; + +// Form login của khu quản trị cần một UserDetailsService để đọc bảng users; API dùng JWT nên +// trước đây không có bean nào, khiến Spring Boot tự tạo user mặc định (dòng "Using generated +// security password" lúc khởi động). Có bean này là hết cảnh báo đó. +// +// Ở đây KHÔNG chặn theo vai trò: mọi tài khoản đều xác thực được, còn quyền vào /admin do +// hasRole("ADMIN") trong SecurityConfig quyết định — tách xác thực khỏi phân quyền. +@Service +public class AdminUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public AdminUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email.trim().toLowerCase(Locale.ROOT)) + .orElseThrow(() -> new UsernameNotFoundException("Email hoặc mật khẩu không đúng")); + + return org.springframework.security.core.userdetails.User + .withUsername(user.getEmail()) + .password(user.getPassword()) + .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))) + // Tài khoản bị khoá/chưa kích hoạt không đăng nhập được vào khu quản trị. + .disabled(user.getStatus() != UserStatus.ACTIVE) + .build(); + } +} diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css new file mode 100644 index 0000000..b682203 --- /dev/null +++ b/src/main/resources/static/css/admin.css @@ -0,0 +1,666 @@ +/* Giao diện khu quản trị. Bootstrap lo phần reset/typography, file này lo bố cục riêng + (sidebar + topbar) và các thành phần đặc thù của TripGo Admin. */ + +:root { + --admin-sidebar-bg: #1f2937; + --admin-sidebar-fg: #d1d5db; + --admin-accent: #0d6efd; + --admin-border: #e5e7eb; + --admin-bg: #f8f9fa; +} + +/* ---------- Bố cục chung ---------- */ + +.admin-shell { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + width: 220px; + flex-shrink: 0; + background: var(--admin-sidebar-bg); + color: var(--admin-sidebar-fg); + padding: 20px 0; +} + +.admin-brand { + font-size: 20px; + font-weight: 700; + color: #fff; + padding: 0 20px 20px; +} + +.admin-brand span { + display: block; + font-size: 12px; + font-weight: 400; + letter-spacing: 2px; + text-transform: uppercase; + color: #9ca3af; +} + +.admin-sidebar nav a, +.admin-sidebar .logout-form button { + display: block; + width: 100%; + text-align: left; + padding: 10px 20px; + color: var(--admin-sidebar-fg); + text-decoration: none; + background: none; + border: none; + font-size: 15px; +} + +.admin-sidebar nav a:hover, +.admin-sidebar .logout-form button:hover { + background: #374151; + color: #fff; +} + +.admin-sidebar nav a.active { + background: var(--admin-accent); + color: #fff; + font-weight: 600; +} + +.admin-sidebar hr { + border-color: #374151; + margin: 12px 20px; +} + +.admin-main { + flex: 1; + display: flex; + flex-direction: column; + background: var(--admin-bg); +} + +.admin-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + background: #fff; + border-bottom: 1px solid var(--admin-border); +} + +.admin-topbar-title { + font-weight: 600; +} + +.admin-topbar-user { + color: #6b7280; + font-size: 14px; +} + +.admin-content { + padding: 24px; +} + +.page-title { + font-size: 22px; + font-weight: 600; + margin-bottom: 20px; +} + +/* ---------- Ô số liệu của dashboard ---------- */ + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +.stat-card { + background: #fff; + border: 1px solid var(--admin-border); + border-radius: 8px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.stat-label { + color: #6b7280; + font-size: 14px; +} + +.stat-value { + font-size: 28px; + font-weight: 700; +} + +/* ---------- Trang đăng nhập ---------- */ + +.login-body { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--admin-bg); + margin: 0; +} + +.login-card { + width: 360px; + background: #fff; + border: 1px solid var(--admin-border); + border-radius: 10px; + padding: 32px; +} + +.login-title { + font-size: 22px; + font-weight: 700; + text-align: center; + margin-bottom: 24px; +} + +.login-card label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; +} + +.login-card input { + width: 100%; + padding: 9px 12px; + margin-bottom: 16px; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 15px; +} + +.login-card input:focus { + outline: none; + border-color: var(--admin-accent); + box-shadow: 0 0 0 3px rgba(13, 110, 253, .15); +} + +.login-card button { + width: 100%; + padding: 10px; + background: var(--admin-accent); + color: #fff; + border: none; + border-radius: 6px; + font-size: 15px; + font-weight: 600; +} + +.login-card button:hover { + background: #0b5ed7; +} + +.login-alert { + padding: 10px 12px; + border-radius: 6px; + font-size: 14px; + margin-bottom: 16px; +} + +.login-alert-error { + background: #fdecea; + color: #b42318; +} + +.login-alert-info { + background: #e7f1ff; + color: #0a58ca; +} + +/* ---------- Trang lỗi ---------- */ + +.error-card { + text-align: center; +} + +.error-status { + font-size: 48px; + font-weight: 700; + color: var(--admin-accent); + margin-bottom: 4px; +} + +.error-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 12px; +} + +.error-path, +.error-hint { + color: #6b7280; + font-size: 14px; + margin-bottom: 20px; + word-break: break-all; +} + +.error-link { + display: inline-block; + color: var(--admin-accent); + text-decoration: none; + font-weight: 500; +} + +.error-link:hover { + text-decoration: underline; +} + +/* ---------- Đầu trang có nút hành động ---------- */ + +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.page-header .page-title { + margin-bottom: 0; +} + +.page-count { + color: #6b7280; + font-weight: 400; + font-size: 16px; +} + +.btn-primary { + background: var(--admin-accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 9px 16px; + font-size: 14px; + font-weight: 500; + text-decoration: none; +} + +.btn-primary:hover { + background: #0b5ed7; + color: #fff; +} + +/* ---------- Thanh tìm kiếm ---------- */ + +.search-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.search-bar input { + flex: 0 1 320px; + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 14px; +} + +.search-bar button { + padding: 8px 16px; + border: 1px solid #ced4da; + border-radius: 6px; + background: #fff; + font-size: 14px; +} + +.search-bar button:hover { + background: #f1f3f5; +} + +.search-clear { + color: #6b7280; + font-size: 14px; + text-decoration: none; +} + +.search-clear:hover { + text-decoration: underline; +} + +/* ---------- Bảng dữ liệu ---------- */ + +.data-table { + width: 100%; + background: #fff; + border: 1px solid var(--admin-border); + border-radius: 8px; + border-collapse: collapse; + overflow: hidden; +} + +.data-table th, +.data-table td { + padding: 11px 14px; + text-align: left; + border-bottom: 1px solid var(--admin-border); + font-size: 14px; +} + +.data-table th { + background: #f8f9fa; + font-weight: 600; + color: #374151; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:hover { + background: #f8f9fa; +} + +.data-table .num { + text-align: right; +} + +.empty-row { + text-align: center; + color: #6b7280; + padding: 28px; +} + +.btn-link { + background: none; + border: none; + padding: 0; + color: var(--admin-accent); + font-size: 14px; + text-decoration: none; +} + +.btn-link:hover { + text-decoration: underline; +} + +.btn-link.danger { + color: #b42318; +} + +/* Để nút Xoá (nằm trong form riêng vì cần CSRF) đứng cùng hàng với link Sửa. */ +.inline-form { + display: inline; + margin-left: 10px; +} + +/* ---------- Phân trang ---------- */ + +.pagination { + display: flex; + align-items: center; + gap: 4px; + margin-top: 16px; +} + +.pagination a { + display: inline-block; + min-width: 34px; + padding: 6px 10px; + text-align: center; + border: 1px solid var(--admin-border); + border-radius: 6px; + background: #fff; + color: #374151; + text-decoration: none; + font-size: 14px; +} + +.pagination a:hover { + background: #f1f3f5; +} + +.pagination a.current { + background: var(--admin-accent); + border-color: var(--admin-accent); + color: #fff; + font-weight: 600; +} + +/* ---------- Thông báo sau khi chuyển hướng ---------- */ + +.flash-wrap { + padding-bottom: 0; +} + +.flash { + padding: 11px 14px; + border-radius: 6px; + font-size: 14px; + margin: 0; +} + +.flash-success { + background: #e7f6ec; + color: #12683a; +} + +.flash-error { + background: #fdecea; + color: #b42318; +} + +/* ---------- Nút phụ & ghi chú ---------- */ + +.header-actions { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.btn-secondary { + background: #fff; + color: #374151; + border: 1px solid #ced4da; + border-radius: 6px; + padding: 8px 14px; + font-size: 14px; + text-decoration: none; +} + +.btn-secondary:hover { + background: #f1f3f5; + color: #374151; +} + +.hint { + color: #6b7280; + font-size: 14px; + margin-bottom: 16px; +} + +/* ---------- Form ---------- */ + +.form-card { + background: #fff; + border: 1px solid var(--admin-border); + border-radius: 8px; + padding: 24px; + max-width: 860px; +} + +.form-row { + margin-bottom: 18px; +} + +/* Hai ô cạnh nhau trên desktop, tự xuống dòng khi màn hình hẹp. */ +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0 16px; +} + +.form-card label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; +} + +.required { + color: #b42318; +} + +.form-card input, +.form-card select, +.form-card textarea { + width: 100%; + padding: 9px 12px; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} + +.form-card input:focus, +.form-card select:focus, +.form-card textarea:focus { + outline: none; + border-color: var(--admin-accent); + box-shadow: 0 0 0 3px rgba(13, 110, 253, .15); +} + +.field-error { + color: #b42318; + font-size: 13px; + margin: 6px 0 0; +} + +/* Viền đỏ cho chính ô sai, để mắt tìm được ngay chỗ cần sửa. */ +.form-row:has(.field-error) input, +.form-row:has(.field-error) select, +.form-row:has(.field-error) textarea { + border-color: #b42318; +} + +.field-hint { + color: #6b7280; + font-size: 13px; + margin: 6px 0 0; +} + +.field-hint code { + background: #f1f3f5; + padding: 1px 5px; + border-radius: 4px; +} + +.form-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--admin-border); +} + +/* ---------- Ảnh ---------- */ + +.thumb-current { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; +} + +.thumb-current img { + width: 120px; + height: 80px; + object-fit: cover; + border: 1px solid var(--admin-border); + border-radius: 6px; +} + +.checkbox-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 400; + margin: 0; +} + +/* Bootstrap đặt width:100% cho input; checkbox phải kéo về kích thước thật. */ +.checkbox-inline input[type="checkbox"] { + width: auto; +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.gallery-item img { + width: 100%; + height: 80px; + object-fit: cover; + border: 1px solid var(--admin-border); + border-radius: 6px; + display: block; + margin-bottom: 4px; +} + +/* ---------- Lịch trình ---------- */ + +.itinerary { + border: 1px solid var(--admin-border); + border-radius: 8px; + padding: 16px 18px; + margin: 0 0 18px; +} + +.itinerary legend { + font-size: 15px; + font-weight: 600; + padding: 0 6px; + width: auto; + float: none; +} + +.itinerary-row { + display: grid; + grid-template-columns: 70px 1fr 2fr auto; + align-items: start; + gap: 10px; + margin-bottom: 10px; +} + +.day-label { + font-size: 14px; + font-weight: 500; + padding-top: 9px; + white-space: nowrap; +} + +.itinerary-row .remove-day { + padding-top: 9px; +} + +/* Màn hẹp: xếp dọc để ô nhập không bị bóp lại. */ +@media (max-width: 760px) { + .itinerary-row { + grid-template-columns: 1fr; + } +} + +.form-global-error { + background: #fdecea; + color: #b42318; + border-radius: 6px; + padding: 11px 14px; + margin-bottom: 18px; + font-size: 14px; +} + +.form-global-error p { + margin: 0; +} diff --git a/src/main/resources/static/js/admin-itinerary.js b/src/main/resources/static/js/admin-itinerary.js new file mode 100644 index 0000000..48269da --- /dev/null +++ b/src/main/resources/static/js/admin-itinerary.js @@ -0,0 +1,52 @@ +// Thêm/xoá dòng lịch trình ngay trên form, không phải tải lại trang. +// +// Spring bind danh sách theo chỉ số liên tục (itinerary[0], itinerary[1]...). Xoá một dòng ở giữa +// mà không đánh lại tên trường sẽ làm đứt quãng chỉ số và các dòng sau bị bỏ qua khi lưu. +(function () { + const rows = document.getElementById('itinerary-rows'); + const addButton = document.getElementById('add-day'); + if (!rows || !addButton) { + return; + } + + function renumber() { + Array.from(rows.querySelectorAll('.itinerary-row')).forEach(function (row, index) { + row.dataset.index = index; + row.querySelector('.day-label').textContent = 'Ngày ' + (index + 1); + + row.querySelectorAll('[name]').forEach(function (field) { + // itinerary[3].title -> itinerary[0].title + field.name = field.name.replace(/itinerary\[\d+\]/, 'itinerary[' + index + ']'); + field.id = field.name.replace(/[\[\].]/g, ''); + }); + }); + } + + function createRow() { + const index = rows.querySelectorAll('.itinerary-row').length; + const row = document.createElement('div'); + row.className = 'itinerary-row'; + row.dataset.index = index; + row.innerHTML = + 'Ngày ' + (index + 1) + '' + + '' + + '' + + ''; + return row; + } + + addButton.addEventListener('click', function () { + rows.appendChild(createRow()); + }); + + // Uỷ quyền sự kiện: dòng thêm bằng JS cũng xoá được mà không phải gắn listener riêng. + rows.addEventListener('click', function (event) { + if (!event.target.classList.contains('remove-day')) { + return; + } + event.target.closest('.itinerary-row').remove(); + renumber(); + }); +})(); diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 0000000..2e4b69d --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,32 @@ + + + + Dashboard · TripGo Admin + + +
+

Dashboard

+ + +
+
+ Tổng tour + — +
+
+ Đơn chờ xác nhận + — +
+
+ Đơn tháng này + — +
+
+ Doanh thu tháng + — +
+
+
+ + diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html new file mode 100644 index 0000000..6c929cd --- /dev/null +++ b/src/main/resources/templates/admin/layout.html @@ -0,0 +1,55 @@ + + + + + + + TripGo Admin + + + + +
+ + + +
+
+ Quản trị + admin@tripgo +
+ + +
+

Thông báo

+
+ +
+

Nội dung trang

+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 0000000..aeef654 --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,35 @@ + + + + + + + Đăng nhập · TripGo Admin + + + + + + + + + diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000..814d910 --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,23 @@ + + + + + + + Không tìm thấy trang · TripGo + + + + + + diff --git a/src/main/resources/templates/error/500.html b/src/main/resources/templates/error/500.html new file mode 100644 index 0000000..f409b5a --- /dev/null +++ b/src/main/resources/templates/error/500.html @@ -0,0 +1,19 @@ + + + + + + + Lỗi hệ thống · TripGo + + + + + + diff --git a/src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java new file mode 100644 index 0000000..81caa31 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java @@ -0,0 +1,151 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Role; +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.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.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.logout; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +// Bám sát tiêu chí chấp nhận của khu quản trị: chưa đăng nhập -> về /admin/login; +// user thường không vào được; admin vào được. +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminSecurityIntegrationTest { + + private static final String ADMIN_EMAIL = "admin@tripgo.local"; + private static final String USER_EMAIL = "khach@tripgo.local"; + private static final String PASSWORD = "MatKhau123"; + + @Autowired MockMvc mvc; + @Autowired UserRepository users; + @Autowired PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + users.deleteAll(); + users.save(account(ADMIN_EMAIL, Role.ADMIN)); + users.save(account(USER_EMAIL, Role.USER)); + } + + private User account(String email, Role role) { + User user = new User(); + user.setEmail(email); + user.setFullName("Tài khoản " + role); + user.setPassword(passwordEncoder.encode(PASSWORD)); + user.setRole(role); + user.setStatus(UserStatus.ACTIVE); + return user; + } + + // ---- AC: chưa đăng nhập admin -> chuyển tới /admin/login ---- + + @Test + void anonymousIsRedirectedToLogin() throws Exception { + mvc.perform(get("/admin")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/login")); + } + + @Test + void loginPageIsPublic() throws Exception { + mvc.perform(get("/admin/login")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/login")) + .andExpect(content().string(containsString("TripGo Admin"))); + } + + // ---- AC: chỉ tài khoản role=admin vào được ---- + + @Test + void adminLogsInWithEmailAndPassword() throws Exception { + mvc.perform(formLogin("/admin/login").user("email", ADMIN_EMAIL).password("password", PASSWORD)) + .andExpect(authenticated().withRoles("ADMIN")) + .andExpect(redirectedUrl("/admin")); + } + + @Test + @WithMockUser(username = ADMIN_EMAIL, roles = "ADMIN") + void adminReachesDashboard() throws Exception { + mvc.perform(get("/admin")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/dashboard")) + .andExpect(content().string(containsString("Dashboard"))); + } + + // User thường xác thực được (đúng mật khẩu) nhưng không có quyền vào khu quản trị. + @Test + void normalUserAuthenticatesButIsDeniedAdminArea() throws Exception { + mvc.perform(formLogin("/admin/login").user("email", USER_EMAIL).password("password", PASSWORD)) + .andExpect(authenticated().withRoles("USER")); + } + + @Test + @WithMockUser(username = USER_EMAIL, roles = "USER") + void normalUserCannotOpenAdminArea() throws Exception { + mvc.perform(get("/admin")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/login?denied")); + } + + @Test + void wrongPasswordGoesBackToLoginWithError() throws Exception { + mvc.perform(formLogin("/admin/login").user("email", ADMIN_EMAIL).password("password", "sai-roi")) + .andExpect(unauthenticated()) + .andExpect(redirectedUrl("/admin/login?error")); + } + + // ---- CSRF: lý do khu quản trị bật CSRF còn API thì không ---- + + @Test + @WithMockUser(username = ADMIN_EMAIL, roles = "ADMIN") + void postWithoutCsrfTokenIsRejected() throws Exception { + mvc.perform(post("/admin/logout")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = ADMIN_EMAIL, roles = "ADMIN") + void postWithCsrfTokenIsAccepted() throws Exception { + mvc.perform(post("/admin/logout").with(csrf())) + .andExpect(redirectedUrl("/admin/login?logout")); + } + + @Test + void logoutClearsSession() throws Exception { + mvc.perform(logout("/admin/logout")) + .andExpect(unauthenticated()) + .andExpect(redirectedUrl("/admin/login?logout")); + } + + // ---- Khu quản trị không được ảnh hưởng tới API ---- + + @Test + void apiChainIsUnaffected() throws Exception { + mvc.perform(get("/api/v1/tours")) + .andExpect(status().isOk()); + mvc.perform(get("/api/v1/wishlist")) + .andExpect(status().isUnauthorized()); + } +} From af2cdbf39bb8a962ed9f7484449ddb4b9d435d20 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 13:10:42 +0700 Subject: [PATCH 03/23] Xoa mem cho tour va diem den Them cot deleted_at (null = con song). Du lieu cu tu dong hop le nen khong can script migration. Co y KHONG dung @SQLRestriction du no gon hon mot dong: annotation do loc ca khi nap quan he, nen booking.getTour() cua don cu se nem EntityNotFound - dung cai ma xoa mem sinh ra de tranh. Thay vao do loc tuong minh: findActiveById() cho luong cong khai, findById() giu nguyen cho khu quan tri. Tour da xoa mem gio vo hinh o: danh sach, chi tiet, availability, reviews, dat tour, them wishlist, dem tour theo diem den. Hang van nam trong DB. Dieu kien deletedAt cua Tour dat trong ON chu khong phai WHERE o truy van dem tour: de o WHERE se bien left join thanh inner join va lam rot nhung diem den chua co tour nao. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/demo/tripgo/entity/Destination.java | 13 ++ src/main/java/demo/tripgo/entity/Tour.java | 11 + .../tripgo/repository/BookingRepository.java | 3 + .../repository/DestinationRepository.java | 10 +- .../tripgo/repository/TourRepository.java | 26 ++- .../tripgo/repository/TourSpecifications.java | 2 + .../demo/tripgo/service/BookingService.java | 2 +- .../demo/tripgo/service/ReviewService.java | 4 +- .../java/demo/tripgo/service/TourService.java | 2 +- .../demo/tripgo/service/WishlistService.java | 5 +- .../service/SoftDeleteIntegrationTest.java | 211 ++++++++++++++++++ 11 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 src/test/java/demo/tripgo/service/SoftDeleteIntegrationTest.java diff --git a/src/main/java/demo/tripgo/entity/Destination.java b/src/main/java/demo/tripgo/entity/Destination.java index a16ddf4..4f4b589 100644 --- a/src/main/java/demo/tripgo/entity/Destination.java +++ b/src/main/java/demo/tripgo/entity/Destination.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.Setter; +import java.time.LocalDateTime; + // Điểm đến của tour; slug dùng làm khoá lọc thân thiện URL (vd "da-nang"). @Getter @Entity @@ -27,4 +29,15 @@ public class Destination { @Column(length = 500) @Setter private String image; + + // null = đang hoạt động. Xoá mềm chỉ ghi mốc thời gian, hàng vẫn nằm lại trong bảng để đơn đặt + // và đánh giá cũ còn tra được (đó là lý do không dùng DELETE thật). + @Column(name = "deleted_at") + @Setter + private LocalDateTime deletedAt; + + public boolean isDeleted() { + return deletedAt != null; + } + } diff --git a/src/main/java/demo/tripgo/entity/Tour.java b/src/main/java/demo/tripgo/entity/Tour.java index dd6f8f2..5bb3fde 100644 --- a/src/main/java/demo/tripgo/entity/Tour.java +++ b/src/main/java/demo/tripgo/entity/Tour.java @@ -116,6 +116,17 @@ public void addItineraryDay(ItineraryDay day) { @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + // null = đang hoạt động. Xoá mềm chỉ ghi mốc thời gian, hàng vẫn nằm lại trong bảng để đơn đặt + // và đánh giá cũ còn tra được (đó là lý do không dùng DELETE thật). + @Column(name = "deleted_at") + @Setter + private LocalDateTime deletedAt; + + public boolean isDeleted() { + return deletedAt != null; + } + + @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); diff --git a/src/main/java/demo/tripgo/repository/BookingRepository.java b/src/main/java/demo/tripgo/repository/BookingRepository.java index 673d9c6..a365ddb 100644 --- a/src/main/java/demo/tripgo/repository/BookingRepository.java +++ b/src/main/java/demo/tripgo/repository/BookingRepository.java @@ -21,6 +21,9 @@ public interface BookingRepository extends JpaRepository { @EntityGraph(attributePaths = {"tour", "departure"}) Optional findByIdAndUserId(Long id, Long userId); + // Số đơn còn hiệu lực của một tour: dùng để cảnh báo admin trước khi xoá. + long countByTourIdAndStatusNot(Long tourId, BookingStatus status); + // Điều kiện được đánh giá: user đã đặt tour này và đơn chưa bị huỷ. boolean existsByUserIdAndTourIdAndStatusNot(Long userId, Long tourId, BookingStatus status); } diff --git a/src/main/java/demo/tripgo/repository/DestinationRepository.java b/src/main/java/demo/tripgo/repository/DestinationRepository.java index f40d71e..8b5fcc6 100644 --- a/src/main/java/demo/tripgo/repository/DestinationRepository.java +++ b/src/main/java/demo/tripgo/repository/DestinationRepository.java @@ -3,6 +3,7 @@ import demo.tripgo.entity.Destination; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -13,11 +14,18 @@ public interface DestinationRepository extends JpaRepository Optional findBySlug(String slug); + // Điểm đến đã xoá mềm coi như không tồn tại với khách. + @Query("select d from Destination d where d.id = :id and d.deletedAt is null") + Optional findActiveById(@Param("id") Long id); + // Đếm số tour cho từng điểm đến trong một truy vấn (left join để điểm đến 0 tour vẫn xuất hiện). + // Điều kiện deletedAt của Tour nằm trong ON chứ không phải WHERE: đặt ở WHERE sẽ biến left join + // thành inner join và làm rớt mất những điểm đến chưa có tour nào. @Query(""" select d.slug as slug, d.name as name, d.image as image, count(t.id) as tourCount from Destination d - left join Tour t on t.destination = d + left join Tour t on t.destination = d and t.deletedAt is null + where d.deletedAt is null group by d.id, d.slug, d.name, d.image order by d.name asc """) diff --git a/src/main/java/demo/tripgo/repository/TourRepository.java b/src/main/java/demo/tripgo/repository/TourRepository.java index d0fe4c4..4955acf 100644 --- a/src/main/java/demo/tripgo/repository/TourRepository.java +++ b/src/main/java/demo/tripgo/repository/TourRepository.java @@ -1,6 +1,8 @@ package demo.tripgo.repository; import demo.tripgo.entity.Tour; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; @@ -12,26 +14,44 @@ @Repository public interface TourRepository extends JpaRepository, JpaSpecificationExecutor { + // Tour đã xoá mềm coi như không tồn tại với khách: dùng hàm này thay cho findById ở mọi + // luồng công khai (đặt tour, đánh giá, wishlist). Khu quản trị vẫn dùng findById để xem + // và khôi phục hàng trong thùng rác. + @Query("select t from Tour t where t.id = :id and t.deletedAt is null") + Optional findActiveById(@Param("id") Long id); + // Fetch join điểm đến + loại hình + ảnh trong một truy vấn để dựng màn chi tiết, tránh N+1. @Query(""" select t from Tour t left join fetch t.destination left join fetch t.category left join fetch t.images - where t.id = :id + where t.id = :id and t.deletedAt is null """) Optional findDetailById(@Param("id") Long id); // Tra id từ slug để tái dùng hai truy vấn fetch bên dưới (chỉ lấy id nên rất nhẹ). - @Query("select t.id from Tour t where t.slug = :slug") + @Query("select t.id from Tour t where t.slug = :slug and t.deletedAt is null") Optional findIdBySlug(@Param("slug") String slug); + // Thùng rác của khu quản trị: chỉ những tour đã xoá mềm, fetch điểm đến để bảng không N+1. + @Query(value = """ + select t from Tour t + left join fetch t.destination + where t.deletedAt is not null + """, + countQuery = "select count(t) from Tour t where t.deletedAt is not null") + Page findDeleted(Pageable pageable); + + // Slug là unique trên TOÀN bảng, kể cả hàng đã xoá mềm -> phải kiểm tra không lọc deletedAt. + boolean existsBySlugAndIdNot(String slug, Long id); + // Khởi tạo tiếp lịch trình theo ngày cho cùng entity đã nạp (tránh MultipleBagFetchException // khi join fetch nhiều collection List cùng lúc). @Query(""" select t from Tour t left join fetch t.itinerary - where t.id = :id + where t.id = :id and t.deletedAt is null """) Optional fetchItinerary(@Param("id") Long id); } diff --git a/src/main/java/demo/tripgo/repository/TourSpecifications.java b/src/main/java/demo/tripgo/repository/TourSpecifications.java index 4a7eee0..f0f0abd 100644 --- a/src/main/java/demo/tripgo/repository/TourSpecifications.java +++ b/src/main/java/demo/tripgo/repository/TourSpecifications.java @@ -37,6 +37,8 @@ public static Specification withFilter(TourFilter filter) { : (Join) root.fetch("category", JoinType.INNER); List predicates = new ArrayList<>(); + // Luôn loại tour đã xoá mềm khỏi danh sách công khai, không phụ thuộc filter nào. + predicates.add(cb.isNull(root.get("deletedAt"))); if (hasText(filter.q())) { // Escape \, %, _ để chúng được hiểu là ký tự literal, không phải wildcard của LIKE. diff --git a/src/main/java/demo/tripgo/service/BookingService.java b/src/main/java/demo/tripgo/service/BookingService.java index e808593..e8e1684 100644 --- a/src/main/java/demo/tripgo/service/BookingService.java +++ b/src/main/java/demo/tripgo/service/BookingService.java @@ -57,7 +57,7 @@ public BookingService( @Transactional public BookingResponse createBooking(User user, CreateBookingRequest request) { - Tour tour = tourRepository.findById(request.tourId()) + Tour tour = tourRepository.findActiveById(request.tourId()) .orElseThrow(() -> new ResourceNotFoundException("tour")); // Khoá hàng khởi hành để kiểm tra & trừ chỗ an toàn với request đồng thời. diff --git a/src/main/java/demo/tripgo/service/ReviewService.java b/src/main/java/demo/tripgo/service/ReviewService.java index 3f0abf2..9847e8e 100644 --- a/src/main/java/demo/tripgo/service/ReviewService.java +++ b/src/main/java/demo/tripgo/service/ReviewService.java @@ -46,7 +46,7 @@ public ReviewService( // Đánh giá phân trang (mới nhất trước) + điểm trung bình của tour (lấy từ rating_avg đã denormalized). public ReviewPageResponse getReviews(Long tourId, int page, int limit) { - Tour tour = tourRepository.findById(tourId) + Tour tour = tourRepository.findActiveById(tourId) .orElseThrow(() -> new ResourceNotFoundException("tour")); Pageable pageable = PageRequest.of( page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id"))); @@ -57,7 +57,7 @@ public ReviewPageResponse getReviews(Long tourId, int page, int limit) { @Transactional public CreateReviewResponse createReview(Long tourId, User user, CreateReviewRequest request) { - Tour tour = tourRepository.findById(tourId) + Tour tour = tourRepository.findActiveById(tourId) .orElseThrow(() -> new ResourceNotFoundException("tour")); // Theo user story F8 ("là người dùng ĐÃ ĐẶT TOUR"): chỉ ai từng đặt tour này mới được đánh giá. // Đơn đã huỷ không tính -> chặn việc đặt rồi huỷ ngay chỉ để lấy quyền đánh giá. diff --git a/src/main/java/demo/tripgo/service/TourService.java b/src/main/java/demo/tripgo/service/TourService.java index 7014ab6..b1fd008 100644 --- a/src/main/java/demo/tripgo/service/TourService.java +++ b/src/main/java/demo/tripgo/service/TourService.java @@ -117,7 +117,7 @@ private BigDecimal effectivePrice(Tour tour) { // Ngày khởi hành & số chỗ còn. month=YYYY-MM lọc theo tháng; bỏ trống thì lấy các ngày sắp tới. public ListResponse getAvailability(Long id, String month) { // Nạp tour một lần để lấy giá hiệu lực (giá KM nếu có) gắn vào từng ngày khởi hành. - Tour tour = tourRepository.findById(id) + Tour tour = tourRepository.findActiveById(id) .orElseThrow(() -> new ResourceNotFoundException("tour")); BigDecimal price = effectivePrice(tour); List departures; diff --git a/src/main/java/demo/tripgo/service/WishlistService.java b/src/main/java/demo/tripgo/service/WishlistService.java index 2847f87..38ecc47 100644 --- a/src/main/java/demo/tripgo/service/WishlistService.java +++ b/src/main/java/demo/tripgo/service/WishlistService.java @@ -33,7 +33,7 @@ public WishlistResponse getWishlist(User user) { @Transactional public WishlistResponse addTour(User user, Long tourId) { - Tour tour = tourRepository.findById(tourId) + Tour tour = tourRepository.findActiveById(tourId) .orElseThrow(() -> new ResourceNotFoundException("tour")); // Set.add trả false nếu đã có -> thêm trùng không sinh bản ghi lặp, cũng không báo lỗi // (thao tác idempotent, bấm tim hai lần vẫn chỉ là "đã thích"). @@ -62,8 +62,11 @@ private User loadWithWishlist(User user) { } // Sắp theo id để thứ tự ổn định giữa các lần gọi (Set không đảm bảo thứ tự sau khi reload). + // Tour bị xoá mềm sau khi user đã lưu thì biến khỏi wishlist, nhưng hàng trong user_wishlist + // vẫn còn: khôi phục tour từ thùng rác là nó hiện lại đúng chỗ cũ. private WishlistResponse toResponse(User user) { List tourIds = user.getWishlist().stream() + .filter(tour -> !tour.isDeleted()) .map(Tour::getId) .sorted(Comparator.naturalOrder()) .toList(); diff --git a/src/test/java/demo/tripgo/service/SoftDeleteIntegrationTest.java b/src/test/java/demo/tripgo/service/SoftDeleteIntegrationTest.java new file mode 100644 index 0000000..1465412 --- /dev/null +++ b/src/test/java/demo/tripgo/service/SoftDeleteIntegrationTest.java @@ -0,0 +1,211 @@ +package demo.tripgo.service; + +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.repository.UserRepository; +import demo.tripgo.security.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +// Xoá mềm phải "vô hình" với khách ở MỌI luồng công khai, nhưng hàng vẫn còn trong DB. +// Mỗi test ở đây tương ứng một điểm chạm đã rà trong mã nguồn. +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SoftDeleteIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired UserRepository users; + @Autowired PasswordEncoder encoder; + @Autowired JwtService jwt; + + private Destination destination; + private Tour live; + private Tour deleted; + + @BeforeEach + void setUp() { + users.deleteAll(); + tours.deleteAll(); + destinations.deleteAll(); + + destination = new Destination(); + destination.setName("Da Nang"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + live = saveTour("Tour còn bán"); + deleted = saveTour("Tour đã xoá"); + deleted.setDeletedAt(LocalDateTime.now()); + deleted = tours.save(deleted); + } + + // ---- Danh sách & chi tiết ---- + + @Test + void deletedTourDisappearsFromPublicList() throws Exception { + mvc.perform(get("/api/v1/tours")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].id").value(live.getId())); + } + + @Test + void deletedTourIsNotFoundByIdOrSlug() throws Exception { + mvc.perform(get("/api/v1/tours/" + deleted.getId())) + .andExpect(status().isNotFound()); + mvc.perform(get("/api/v1/tours/" + live.getId())) + .andExpect(status().isOk()); + } + + @Test + void deletedTourHidesItsAvailabilityAndReviews() throws Exception { + mvc.perform(get("/api/v1/tours/" + deleted.getId() + "/availability")) + .andExpect(status().isNotFound()); + mvc.perform(get("/api/v1/tours/" + deleted.getId() + "/reviews")) + .andExpect(status().isNotFound()); + } + + // ---- Không đặt/đánh giá được tour đã xoá ---- + + @Test + void cannotBookDeletedTour() throws Exception { + mvc.perform(post("/api/v1/bookings") + .header("Authorization", tokenFor()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"tourId":%d,"date":"2030-01-01","adults":2,"children":0, + "contact":{"fullName":"Khach","email":"k@example.com","phone":"0900000000"}} + """.formatted(deleted.getId()))) + .andExpect(status().isNotFound()); + } + + @Test + void cannotAddDeletedTourToWishlist() throws Exception { + mvc.perform(post("/api/v1/wishlist") + .header("Authorization", tokenFor()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tourId\":%d}".formatted(deleted.getId()))) + .andExpect(status().isNotFound()); + } + + // Tour đã lưu rồi mới bị xoá: biến khỏi wishlist nhưng hàng nối vẫn còn để khôi phục được. + @Test + void tourDeletedAfterBeingSavedDropsOutOfWishlist() throws Exception { + String token = tokenFor(); + + mvc.perform(post("/api/v1/wishlist") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tourId\":%d}".formatted(live.getId()))) + .andExpect(jsonPath("$.tourIds.length()").value(1)); + + live.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(live); + + mvc.perform(get("/api/v1/wishlist").header("Authorization", token)) + .andExpect(jsonPath("$.tourIds").isEmpty()); + + // Khôi phục -> hiện lại. + live.setDeletedAt(null); + tours.saveAndFlush(live); + + mvc.perform(get("/api/v1/wishlist").header("Authorization", token)) + .andExpect(jsonPath("$.tourIds.length()").value(1)); + } + + // ---- Điểm đến ---- + + @Test + void destinationTourCountIgnoresDeletedTours() throws Exception { + mvc.perform(get("/api/v1/destinations")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].tourCount").value(1)); + } + + @Test + void deletedDestinationDisappearsButZeroTourOneStays() throws Exception { + Destination empty = new Destination(); + empty.setName("Sapa"); + empty.setSlug("sapa"); + destinations.save(empty); + + // Điểm đến chưa có tour nào vẫn phải xuất hiện (left join, không bị biến thành inner join). + mvc.perform(get("/api/v1/destinations")) + .andExpect(jsonPath("$.data.length()").value(2)); + + empty.setDeletedAt(LocalDateTime.now()); + destinations.saveAndFlush(empty); + + mvc.perform(get("/api/v1/destinations")) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].slug").value("da-nang")); + } + + // ---- Dữ liệu vẫn còn: đây là điểm khác biệt với xoá cứng ---- + + @Test + void deletedRowIsStillInDatabase() { + assertThat(tours.findById(deleted.getId())).isPresent(); + assertThat(tours.findActiveById(deleted.getId())).isEmpty(); + assertThat(tours.count()).isEqualTo(2); + } + + private Tour saveTour(String title) { + Tour tour = new Tour(); + tour.setTitle(title); + tour.setSlug(UUID.randomUUID().toString()); + tour.setDestination(destination); + tour.setCategory(category()); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("1000000")); + tour.setMaxGuests(20); + tour.setDescription("desc"); + return tours.save(tour); + } + + private Category category() { + return categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + } + + private String tokenFor() { + User user = new User(); + user.setFullName("Khach"); + user.setEmail(UUID.randomUUID() + "@example.com"); + user.setPassword(encoder.encode("password123")); + user.setRole(Role.USER); + return "Bearer " + jwt.generateToken(users.save(user)); + } +} From e4c748af003f363439f63ff99e5a4f64c20fc47a Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 13:11:02 +0700 Subject: [PATCH 04/23] Quan ly tour trong khu quan tri: danh sach, form, upload anh, thung rac AdminTourController dung lai TourService cua API cong khai thay vi viet truy van rieng. Nho vay tieu chi "thay doi o giao dien phan anh ngay o API cong khai" la he qua tu nhien, va tour da xoa mem tu dong khong lot vao danh sach. Xoa = xoa mem, co thung rac va khoi phuc. Khong chan xoa tour da co don (khac diem den): xoa mem chi an tour khoi trang ban, khach da dat van di binh thuong - chi canh bao so don con hieu luc. Luc xoa thi "tra" slug lai bang hau to -deleted-, neu khong thi tao tour moi trung ten se bao loi trung slug trong khi admin nhin danh sach chang thay tour nao ten do. TourForm tach khoi entity: form khong cham duoc ratingAvg, reviewCount, deletedAt, createdAt - du lieu he thong tu quan. Upload anh luu NGOAI classpath (StorageProperties.uploadDir): thu muc static/ duoc dong goi vao jar luc build nen file ghi vao do luc chay se khong phuc vu duoc va mat khi build lai. Duoi file suy ra tu content type chu khong lay tu ten file client gui len - ten file la du lieu client kiem soat, dung thang la mo duong cho path traversal va cho duoi .jsp/.html. Lich trinh dong dung AutoPopulatingList: ArrayList thuong se nem IndexOutOfBounds ngay khi JS them dong moi. JS danh lai ten truong sau khi xoa dong giua, vi chi so dut quang la cac dong sau bi bo qua khi luu. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + .../tripgo/admin/AdminTourController.java | 214 ++++++++++++ .../demo/tripgo/admin/ItineraryDayForm.java | 22 ++ src/main/java/demo/tripgo/admin/TourForm.java | 96 ++++++ .../java/demo/tripgo/admin/TrashTourRow.java | 13 + .../tripgo/service/FileStorageService.java | 126 +++++++ .../demo/tripgo/service/SlugGenerator.java | 30 ++ .../demo/tripgo/service/TourAdminService.java | 298 +++++++++++++++++ .../resources/templates/admin/tours/form.html | 164 +++++++++ .../resources/templates/admin/tours/list.html | 77 +++++ .../templates/admin/tours/trash.html | 60 ++++ ...AdminTourDeleteRestoreIntegrationTest.java | 279 ++++++++++++++++ .../admin/AdminTourFormIntegrationTest.java | 314 ++++++++++++++++++ .../admin/AdminTourListIntegrationTest.java | 172 ++++++++++ ...minTourUploadItineraryIntegrationTest.java | 292 ++++++++++++++++ 15 files changed, 2160 insertions(+) create mode 100644 src/main/java/demo/tripgo/admin/AdminTourController.java create mode 100644 src/main/java/demo/tripgo/admin/ItineraryDayForm.java create mode 100644 src/main/java/demo/tripgo/admin/TourForm.java create mode 100644 src/main/java/demo/tripgo/admin/TrashTourRow.java create mode 100644 src/main/java/demo/tripgo/service/FileStorageService.java create mode 100644 src/main/java/demo/tripgo/service/SlugGenerator.java create mode 100644 src/main/java/demo/tripgo/service/TourAdminService.java create mode 100644 src/main/resources/templates/admin/tours/form.html create mode 100644 src/main/resources/templates/admin/tours/list.html create mode 100644 src/main/resources/templates/admin/tours/trash.html create mode 100644 src/test/java/demo/tripgo/admin/AdminTourDeleteRestoreIntegrationTest.java create mode 100644 src/test/java/demo/tripgo/admin/AdminTourFormIntegrationTest.java create mode 100644 src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java create mode 100644 src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java diff --git a/.gitignore b/.gitignore index 334ddcf..b6e214f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src/main/java/demo/tripgo/admin/AdminTourController.java b/src/main/java/demo/tripgo/admin/AdminTourController.java new file mode 100644 index 0000000..3f8850a --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminTourController.java @@ -0,0 +1,214 @@ +package demo.tripgo.admin; + +import demo.tripgo.dto.request.TourListRequest; +import demo.tripgo.entity.Tour; +import demo.tripgo.exception.InvalidRequestParameterException; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.dto.response.PageResponse; +import demo.tripgo.dto.response.TourSummaryResponse; +import demo.tripgo.service.TourAdminService; +import demo.tripgo.service.TourService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +// Dùng lại TourService của API công khai thay vì viết truy vấn riêng cho admin. Nhờ vậy tiêu chí +// "thay đổi ở giao diện phản ánh ngay ở API công khai" là hệ quả tự nhiên, không phải nhớ đồng bộ +// hai đường code — và tour đã xoá mềm tự động không lọt vào danh sách. +@Controller +@RequestMapping("/admin/tours") +public class AdminTourController { + + // Bảng quản trị xem được nhiều dòng hơn danh sách cho khách (mặc định 10). + private static final int PAGE_SIZE = 20; + + private final TourService tourService; + private final TourAdminService tourAdminService; + private final DestinationRepository destinationRepository; + private final CategoryRepository categoryRepository; + + public AdminTourController( + TourService tourService, + TourAdminService tourAdminService, + DestinationRepository destinationRepository, + CategoryRepository categoryRepository + ) { + this.tourService = tourService; + this.tourAdminService = tourAdminService; + this.destinationRepository = destinationRepository; + this.categoryRepository = categoryRepository; + } + + @GetMapping + public String list( + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "1") int page, + Model model + ) { + // page < 1 (người dùng sửa tay trên URL) sẽ làm PageRequest ném lỗi -> chặn tại đây. + int safePage = Math.max(page, 1); + + PageResponse tours = tourService.listTours(new TourListRequest( + q, null, null, null, null, null, null, "newest", safePage, PAGE_SIZE)); + + model.addAttribute("activeMenu", "tours"); + model.addAttribute("pageHeading", "Tour"); + model.addAttribute("tours", tours.data()); + model.addAttribute("q", q); + model.addAttribute("currentPage", tours.page()); + model.addAttribute("totalPages", totalPages(tours)); + model.addAttribute("total", tours.total()); + return "admin/tours/list"; + } + + // ---- Tạo / sửa ---- + + @GetMapping("/new") + public String createForm(Model model) { + prepareForm(model, new TourForm(), "Thêm tour"); + return "admin/tours/form"; + } + + @PostMapping + public String create( + @Valid @ModelAttribute("form") TourForm form, + BindingResult binding, + Model model, + RedirectAttributes redirect + ) { + if (hasErrors(form, binding)) { + prepareForm(model, form, "Thêm tour"); + return "admin/tours/form"; + } + + Tour saved; + try { + saved = tourAdminService.create(form); + } catch (InvalidRequestParameterException exception) { + // Ảnh sai định dạng/quá nặng: hiện lỗi ngay trên form, giữ nguyên dữ liệu đã gõ, + // thay vì để exception lọt ra thành trang 500. + return rejectUpload(form, binding, model, exception, "Thêm tour"); + } + redirect.addFlashAttribute("flashMessage", "Đã tạo tour \"" + saved.getTitle() + "\""); + redirect.addFlashAttribute("flashType", "success"); + return "redirect:/admin/tours"; + } + + @GetMapping("/{id}/edit") + public String editForm(@PathVariable Long id, Model model) { + prepareForm(model, tourAdminService.loadForm(id), "Sửa tour"); + return "admin/tours/form"; + } + + @PostMapping("/{id}") + public String update( + @PathVariable Long id, + @Valid @ModelAttribute("form") TourForm form, + BindingResult binding, + Model model, + RedirectAttributes redirect + ) { + if (hasErrors(form, binding)) { + form.setId(id); + prepareForm(model, form, "Sửa tour"); + return "admin/tours/form"; + } + Tour saved; + try { + saved = tourAdminService.update(id, form); + } catch (InvalidRequestParameterException exception) { + return rejectUpload(form, binding, model, exception, "Sửa tour"); + } + redirect.addFlashAttribute("flashMessage", "Đã lưu tour \"" + saved.getTitle() + "\""); + redirect.addFlashAttribute("flashType", "success"); + return "redirect:/admin/tours"; + } + + private String rejectUpload( + TourForm form, + BindingResult binding, + Model model, + InvalidRequestParameterException exception, + String heading + ) { + binding.reject("upload.invalid", exception.getMessage()); + prepareForm(model, form, heading); + return "admin/tours/form"; + } + + // Ràng buộc giữa hai trường không diễn đạt được bằng annotation trên một trường, + // nên kiểm ở đây rồi gắn lỗi vào đúng ô để form hiện đỏ đúng chỗ. + private boolean hasErrors(TourForm form, BindingResult binding) { + if (form.hasInvalidDiscount()) { + binding.rejectValue("discountPrice", "discount.invalid", + "Giá khuyến mãi phải nhỏ hơn giá gốc"); + } + return binding.hasErrors(); + } + + // Dropdown điểm đến/loại hình phải nạp lại ở MỌI lần trả view, kể cả khi validate lỗi — + // quên chỗ này thì form lỗi sẽ hiện hai ô chọn trống trơn. + private void prepareForm(Model model, TourForm form, String heading) { + model.addAttribute("form", form); + model.addAttribute("activeMenu", "tours"); + model.addAttribute("pageHeading", heading); + model.addAttribute("destinations", destinationRepository.findAll().stream() + .filter(destination -> !destination.isDeleted()) + .toList()); + model.addAttribute("categories", categoryRepository.findAllByOrderByIdAsc()); + } + + // Thùng rác: xem và khôi phục tour đã xoá mềm. + @GetMapping("/trash") + public String trash(@RequestParam(defaultValue = "1") int page, Model model) { + Page deleted = tourAdminService.listDeleted(Math.max(page, 1), PAGE_SIZE); + + model.addAttribute("activeMenu", "tours"); + model.addAttribute("pageHeading", "Thùng rác"); + model.addAttribute("tours", deleted.getContent()); + model.addAttribute("currentPage", deleted.getNumber() + 1); + model.addAttribute("totalPages", Math.max(deleted.getTotalPages(), 1)); + model.addAttribute("total", deleted.getTotalElements()); + return "admin/tours/trash"; + } + + // POST + CSRF: xoá là thao tác thay đổi dữ liệu, không được để gọi bằng GET. + @PostMapping("/{id}/delete") + public String delete(@PathVariable Long id, RedirectAttributes redirect) { + TourAdminService.DeleteResult result = tourAdminService.softDelete(id); + + String message = "Đã chuyển \"" + result.title() + "\" vào thùng rác"; + if (result.activeBookings() > 0) { + // Không chặn xoá, nhưng admin cần biết: khách đã đặt vẫn đi tour này bình thường. + message += " — lưu ý tour còn " + result.activeBookings() + " đơn đặt còn hiệu lực"; + } + redirect.addFlashAttribute("flashMessage", message); + redirect.addFlashAttribute("flashType", "success"); + return "redirect:/admin/tours"; + } + + @PostMapping("/{id}/restore") + public String restore(@PathVariable Long id, RedirectAttributes redirect) { + String title = tourAdminService.restore(id); + redirect.addFlashAttribute("flashMessage", "Đã khôi phục \"" + title + "\""); + redirect.addFlashAttribute("flashType", "success"); + return "redirect:/admin/tours/trash"; + } + + private int totalPages(PageResponse page) { + if (page.total() == 0) { + return 1; + } + return (int) Math.ceil((double) page.total() / page.limit()); + } +} diff --git a/src/main/java/demo/tripgo/admin/ItineraryDayForm.java b/src/main/java/demo/tripgo/admin/ItineraryDayForm.java new file mode 100644 index 0000000..334cd78 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/ItineraryDayForm.java @@ -0,0 +1,22 @@ +package demo.tripgo.admin; + +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +// Một dòng trong phần lịch trình của form. Số ngày không nhập tay: nó chính là thứ tự dòng, +// được đánh lại khi lưu nên xoá dòng giữa không để lại lỗ hổng (Ngày 1, 2, 4...). +@Getter +@Setter +public class ItineraryDayForm { + + @Size(max = 200, message = "Tiêu đề ngày không được quá 200 ký tự") + private String title; + + private String description; + + // Dòng người dùng thêm rồi bỏ trống sẽ bị loại khi lưu thay vì báo lỗi bắt buộc nhập. + public boolean isBlank() { + return (title == null || title.isBlank()) && (description == null || description.isBlank()); + } +} diff --git a/src/main/java/demo/tripgo/admin/TourForm.java b/src/main/java/demo/tripgo/admin/TourForm.java new file mode 100644 index 0000000..ff403e7 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/TourForm.java @@ -0,0 +1,96 @@ +package demo.tripgo.admin; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +import org.springframework.util.AutoPopulatingList; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +// Dữ liệu của form tạo/sửa tour. Dùng class có getter/setter (không phải record) vì Thymeleaf +// truy cập theo quy ước JavaBean khi bind th:object và hiển thị lại giá trị sau khi validate lỗi. +// +// Tách khỏi entity Tour có chủ ý: form không được phép đụng tới ratingAvg, reviewCount, deletedAt +// hay createdAt — đó là dữ liệu hệ thống tự quản, không phải thứ admin gõ vào. +@Getter +@Setter +public class TourForm { + + private Long id; + + @NotBlank(message = "Tên tour không được để trống") + @Size(max = 200, message = "Tên tour không được quá 200 ký tự") + private String title; + + // Bỏ trống -> tự sinh từ tên tour. Cho sửa tay vì slug nằm trên URL công khai. + @Size(max = 250, message = "Slug không được quá 250 ký tự") + private String slug; + + @NotNull(message = "Vui lòng chọn điểm đến") + private Long destinationId; + + @NotNull(message = "Vui lòng chọn loại hình") + private Long categoryId; + + @NotNull(message = "Giá không được để trống") + @DecimalMin(value = "0", message = "Giá không được âm") + private BigDecimal price; + + // Không @NotNull: tour không khuyến mãi thì để trống. + @DecimalMin(value = "0", message = "Giá khuyến mãi không được âm") + private BigDecimal discountPrice; + + @NotNull(message = "Thời lượng không được để trống") + @Min(value = 1, message = "Thời lượng phải ít nhất 1 ngày") + @Max(value = 365, message = "Thời lượng không được quá 365 ngày") + private Integer durationDays; + + @NotNull(message = "Số khách tối đa không được để trống") + @Min(value = 1, message = "Số khách tối đa phải ít nhất 1") + @Max(value = 1000, message = "Số khách tối đa không được quá 1000") + private Integer maxGuests; + + // URL ảnh hiện tại (đã lưu trong DB). Là hidden field để lần submit sau không làm mất ảnh cũ + // khi admin không chọn file mới. + @Size(max = 500, message = "Đường dẫn ảnh không được quá 500 ký tự") + private String thumbnailUrl; + + // File admin vừa chọn; có file thì thay ảnh cũ, không có thì giữ nguyên thumbnailUrl. + private MultipartFile thumbnailFile; + + // Tick vào để gỡ ảnh đại diện mà không cần tải ảnh khác lên. + private boolean removeThumbnail; + + // Ảnh gallery tải thêm; ảnh cũ giữ trong existingImages, ảnh bị bỏ tick sẽ bị gỡ. + private List galleryFiles = new ArrayList<>(); + + private List keepImageUrls = new ArrayList<>(); + + // Ảnh gallery đang có, chỉ để hiển thị lại trên form (không bind từ request). + private List existingImageUrls = new ArrayList<>(); + + private String description; + + // Lịch trình theo ngày. Khởi tạo sẵn để Spring bind được itinerary[0].title, itinerary[1]... + // mà không ném IndexOutOfBounds khi JS thêm dòng mới. + // AutoPopulatingList tự tạo phần tử khi Spring bind itinerary[n] vượt quá kích thước hiện tại; + // ArrayList thường sẽ ném IndexOutOfBounds ngay khi JS thêm dòng mới. + @Valid + private List itinerary = new AutoPopulatingList<>(ItineraryDayForm.class); + + // "Giá KM phải nhỏ hơn giá gốc" là ràng buộc giữa HAI trường nên không đặt được bằng annotation + // trên một trường; kiểm ở đây rồi gắn lỗi vào đúng ô discountPrice ở controller. + public boolean hasInvalidDiscount() { + return price != null && discountPrice != null && discountPrice.compareTo(price) >= 0; + } +} diff --git a/src/main/java/demo/tripgo/admin/TrashTourRow.java b/src/main/java/demo/tripgo/admin/TrashTourRow.java new file mode 100644 index 0000000..3932cc0 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/TrashTourRow.java @@ -0,0 +1,13 @@ +package demo.tripgo.admin; + +import java.time.LocalDateTime; + +// Một dòng trong thùng rác. Map sang record ngay trong transaction thay vì đẩy entity ra template, +// để view không vô tình chạm vào quan hệ lazy sau khi session đã đóng. +public record TrashTourRow( + Long id, + String title, + String destination, + LocalDateTime deletedAt +) { +} diff --git a/src/main/java/demo/tripgo/service/FileStorageService.java b/src/main/java/demo/tripgo/service/FileStorageService.java new file mode 100644 index 0000000..4206da8 --- /dev/null +++ b/src/main/java/demo/tripgo/service/FileStorageService.java @@ -0,0 +1,126 @@ +package demo.tripgo.service; + +import demo.tripgo.config.StorageProperties; +import demo.tripgo.exception.InvalidRequestParameterException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDate; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +// Lưu ảnh admin tải lên xuống đĩa và trả về URL công khai để gắn vào DB. +@Service +public class FileStorageService { + + private static final Logger log = LoggerFactory.getLogger(FileStorageService.class); + + // Phần mở rộng suy ra từ content type chứ KHÔNG lấy từ tên file người dùng gửi lên: + // tên file là dữ liệu do client kiểm soát, dùng thẳng là mở đường cho path traversal + // (../../etc/passwd) và cho việc đặt đuôi .jsp/.html nhằm lừa trình duyệt thực thi. + private static final Map EXTENSION_BY_TYPE = Map.of( + "image/jpeg", ".jpg", + "image/png", ".png", + "image/webp", ".webp", + "image/gif", ".gif" + ); + + private final StorageProperties properties; + private final Path root; + + public FileStorageService(StorageProperties properties) { + this.properties = properties; + this.root = Paths.get(properties.uploadDir()).toAbsolutePath().normalize(); + try { + Files.createDirectories(root); + } catch (IOException exception) { + throw new UncheckedIOException("Không tạo được thư mục lưu ảnh: " + root, exception); + } + log.info("Ảnh tải lên được lưu tại {}", root); + } + + // Trả về URL công khai dạng /uploads/2026/09/.jpg, hoặc null nếu không có file nào được chọn. + public String store(MultipartFile file) { + if (file == null || file.isEmpty()) { + return null; + } + validate(file); + + // Chia theo năm/tháng để một thư mục không phình ra hàng chục nghìn file. + LocalDate today = LocalDate.now(); + String relativeDir = "%d/%02d".formatted(today.getYear(), today.getMonthValue()); + String fileName = UUID.randomUUID() + extensionFor(file); + + try { + Path directory = root.resolve(relativeDir); + Files.createDirectories(directory); + Path target = directory.resolve(fileName); + try (var input = file.getInputStream()) { + Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); + } + return properties.publicPath() + "/" + relativeDir + "/" + fileName; + } catch (IOException exception) { + throw new UncheckedIOException("Không lưu được ảnh " + fileName, exception); + } + } + + // Xoá file cũ khi ảnh bị thay/gỡ. Không tìm thấy thì bỏ qua: DB là nguồn sự thật, file thừa + // trên đĩa không được phép làm hỏng thao tác của admin. + public void delete(String publicUrl) { + Path target = resolvePublicUrl(publicUrl); + if (target == null) { + return; + } + try { + Files.deleteIfExists(target); + } catch (IOException exception) { + log.warn("Không xoá được file {}: {}", target, exception.getMessage()); + } + } + + // Chỉ chấp nhận URL do chính service này sinh ra, và kết quả phải nằm trong thư mục gốc — + // chặn trường hợp giá trị trong DB bị sửa thành ../../ để xoá file ngoài vùng cho phép. + private Path resolvePublicUrl(String publicUrl) { + if (publicUrl == null || !publicUrl.startsWith(properties.publicPath() + "/")) { + return null; + } + String relative = publicUrl.substring(properties.publicPath().length() + 1); + Path resolved = root.resolve(relative).normalize(); + return resolved.startsWith(root) ? resolved : null; + } + + private void validate(MultipartFile file) { + if (file.getSize() > properties.maxFileSizeBytes()) { + throw new InvalidRequestParameterException( + "Ảnh vượt quá %d MB".formatted(properties.maxFileSizeBytes() / 1024 / 1024)); + } + String contentType = normalizeContentType(file.getContentType()); + if (!properties.allowedContentTypes().contains(contentType)) { + throw new InvalidRequestParameterException( + "Chỉ chấp nhận ảnh JPG, PNG, WEBP hoặc GIF"); + } + } + + private String extensionFor(MultipartFile file) { + return EXTENSION_BY_TYPE.getOrDefault(normalizeContentType(file.getContentType()), ".bin"); + } + + private String normalizeContentType(String contentType) { + if (contentType == null) { + return ""; + } + // Bỏ phần tham số kiểu "image/jpeg; charset=..." và chuẩn hoá hoa/thường. + int separator = contentType.indexOf(';'); + String base = separator == -1 ? contentType : contentType.substring(0, separator); + return base.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/demo/tripgo/service/SlugGenerator.java b/src/main/java/demo/tripgo/service/SlugGenerator.java new file mode 100644 index 0000000..fa455ac --- /dev/null +++ b/src/main/java/demo/tripgo/service/SlugGenerator.java @@ -0,0 +1,30 @@ +package demo.tripgo.service; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.regex.Pattern; + +// Sinh slug từ tiêu đề tiếng Việt: "Đà Nẵng 3N2Đ" -> "da-nang-3n2d". +public final class SlugGenerator { + + private static final Pattern DIACRITICS = Pattern.compile("\\p{InCombiningDiacriticalMarks}+"); + private static final Pattern NON_SLUG_CHARS = Pattern.compile("[^a-z0-9]+"); + private static final Pattern EDGE_DASHES = Pattern.compile("^-+|-+$"); + + private SlugGenerator() { + } + + public static String from(String text) { + if (text == null || text.isBlank()) { + return ""; + } + // Normalizer tách dấu thành ký tự tổ hợp rồi xoá đi; riêng đ/Đ không có dạng tổ hợp + // nên phải thay thủ công trước. + String ascii = text.replace('đ', 'd').replace('Đ', 'D'); + ascii = Normalizer.normalize(ascii, Normalizer.Form.NFD); + ascii = DIACRITICS.matcher(ascii).replaceAll(""); + + String slug = NON_SLUG_CHARS.matcher(ascii.toLowerCase(Locale.ROOT)).replaceAll("-"); + return EDGE_DASHES.matcher(slug).replaceAll(""); + } +} diff --git a/src/main/java/demo/tripgo/service/TourAdminService.java b/src/main/java/demo/tripgo/service/TourAdminService.java new file mode 100644 index 0000000..ca76eb5 --- /dev/null +++ b/src/main/java/demo/tripgo/service/TourAdminService.java @@ -0,0 +1,298 @@ +package demo.tripgo.service; + +import demo.tripgo.admin.ItineraryDayForm; +import demo.tripgo.admin.TourForm; +import demo.tripgo.admin.TrashTourRow; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.ItineraryDay; +import demo.tripgo.entity.TourImage; +import demo.tripgo.entity.Tour; +import demo.tripgo.exception.ResourceNotFoundException; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +// Thao tác chỉ khu quản trị dùng: xoá mềm, khôi phục, xem thùng rác. +// Tách khỏi TourService (phục vụ API công khai) để hai bên không lẫn: TourService chỉ thấy tour +// còn sống, service này cố ý làm việc với cả tour đã xoá. +@Service +public class TourAdminService { + + // Slug là unique trên toàn bảng. Tour đã xoá mà vẫn giữ slug thì tạo tour mới trùng tên sẽ báo + // lỗi trùng, trong khi admin nhìn danh sách chẳng thấy tour nào tên đó -> rất khó hiểu. + // Vì vậy lúc xoá thì "trả" slug lại bằng cách gắn hậu tố, lúc khôi phục thì đòi lại nếu còn trống. + private static final String DELETED_SLUG_MARKER = "-deleted-"; + private static final int SLUG_MAX_LENGTH = 250; + + private final TourRepository tourRepository; + private final BookingRepository bookingRepository; + private final DestinationRepository destinationRepository; + private final CategoryRepository categoryRepository; + private final FileStorageService fileStorageService; + + public TourAdminService( + TourRepository tourRepository, + BookingRepository bookingRepository, + DestinationRepository destinationRepository, + CategoryRepository categoryRepository, + FileStorageService fileStorageService + ) { + this.tourRepository = tourRepository; + this.bookingRepository = bookingRepository; + this.destinationRepository = destinationRepository; + this.categoryRepository = categoryRepository; + this.fileStorageService = fileStorageService; + } + + // ---- Tạo / sửa ---- + + @Transactional(readOnly = true) + public TourForm loadForm(Long id) { + // Đọc trong transaction nên hai collection lazy (images, itinerary) nạp được bình thường. + Tour tour = tourRepository.findActiveById(id) + .orElseThrow(() -> new ResourceNotFoundException("tour")); + + TourForm form = new TourForm(); + form.setId(tour.getId()); + form.setTitle(tour.getTitle()); + form.setSlug(tour.getSlug()); + form.setDestinationId(tour.getDestination().getId()); + form.setCategoryId(tour.getCategory().getId()); + form.setPrice(tour.getPrice()); + form.setDiscountPrice(tour.getDiscountPrice()); + form.setDurationDays(tour.getDurationDays()); + form.setMaxGuests(tour.getMaxGuests()); + form.setThumbnailUrl(tour.getThumbnailUrl()); + form.setDescription(tour.getDescription()); + + form.setExistingImageUrls(tour.getImages().stream().map(TourImage::getUrl).toList()); + // Mặc định giữ lại toàn bộ ảnh cũ; admin bỏ tick ảnh nào thì ảnh đó bị gỡ khi lưu. + form.setKeepImageUrls(new ArrayList<>(form.getExistingImageUrls())); + + for (ItineraryDay day : tour.getItinerary()) { + ItineraryDayForm row = new ItineraryDayForm(); + row.setTitle(day.getTitle()); + row.setDescription(day.getDescription()); + form.getItinerary().add(row); + } + return form; + } + + @Transactional + public Tour create(TourForm form) { + Tour tour = new Tour(); + apply(form, tour); + return tourRepository.save(tour); + } + + @Transactional + public Tour update(Long id, TourForm form) { + Tour tour = tourRepository.findActiveById(id) + .orElseThrow(() -> new ResourceNotFoundException("tour")); + apply(form, tour); + return tourRepository.save(tour); + } + + // Chỉ chép những trường admin thực sự nhập. ratingAvg/reviewCount/deletedAt do hệ thống quản, + // cố tình không đụng tới ở đây. + private void apply(TourForm form, Tour tour) { + Destination destination = destinationRepository.findActiveById(form.getDestinationId()) + .orElseThrow(() -> new ResourceNotFoundException("điểm đến")); + Category category = categoryRepository.findById(form.getCategoryId()) + .orElseThrow(() -> new ResourceNotFoundException("loại hình")); + + tour.setTitle(form.getTitle().trim()); + tour.setSlug(uniqueSlug(form, tour)); + tour.setDestination(destination); + tour.setCategory(category); + tour.setPrice(form.getPrice()); + tour.setDiscountPrice(form.getDiscountPrice()); + tour.setDurationDays(form.getDurationDays()); + tour.setMaxGuests(form.getMaxGuests()); + tour.setThumbnailUrl(resolveThumbnail(form, tour)); + tour.setDescription(blankToNull(form.getDescription())); + + applyGallery(form, tour); + applyItinerary(form, tour); + } + + // Thứ tự ưu tiên: file mới > yêu cầu gỡ > giữ nguyên ảnh cũ. + private String resolveThumbnail(TourForm form, Tour tour) { + String current = tour.getThumbnailUrl(); + + String uploaded = fileStorageService.store(form.getThumbnailFile()); + if (uploaded != null) { + fileStorageService.delete(current); + return uploaded; + } + if (form.isRemoveThumbnail()) { + fileStorageService.delete(current); + return null; + } + return current != null ? current : blankToNull(form.getThumbnailUrl()); + } + + private void applyGallery(TourForm form, Tour tour) { + List keep = form.getKeepImageUrls() == null ? List.of() : form.getKeepImageUrls(); + + // Ảnh cũ bị bỏ tick: gỡ khỏi tour (orphanRemoval xoá hàng) và xoá luôn file trên đĩa. + List removed = tour.getImages().stream() + .filter(image -> !keep.contains(image.getUrl())) + .toList(); + removed.forEach(image -> fileStorageService.delete(image.getUrl())); + tour.getImages().removeAll(removed); + + if (form.getGalleryFiles() != null) { + form.getGalleryFiles().stream() + .map(fileStorageService::store) + .filter(url -> url != null) + .forEach(url -> { + TourImage image = new TourImage(); + image.setUrl(url); + tour.addImage(image); + }); + } + + // Đánh lại thứ tự để gallery không có lỗ hổng sau khi xoá ảnh giữa. + int position = 0; + for (TourImage image : tour.getImages()) { + image.setPosition(position++); + } + } + + // Thay toàn bộ lịch trình thay vì so khớp từng dòng: đơn giản hơn nhiều mà kết quả như nhau, + // vì ItineraryDay không được tham chiếu từ đâu khác (orphanRemoval dọn hàng cũ). + private void applyItinerary(TourForm form, Tour tour) { + tour.getItinerary().clear(); + if (form.getItinerary() == null) { + return; + } + int dayNumber = 1; + for (ItineraryDayForm row : form.getItinerary()) { + if (row == null || row.isBlank()) { + continue; + } + ItineraryDay day = new ItineraryDay(); + day.setDayNumber(dayNumber++); + // title là NOT NULL; dòng chỉ có mô tả vẫn lưu được với nhãn mặc định. + day.setTitle(row.getTitle() == null || row.getTitle().isBlank() + ? "Ngày " + (dayNumber - 1) + : row.getTitle().trim()); + day.setDescription(blankToNull(row.getDescription())); + tour.addItineraryDay(day); + } + } + + // Admin bỏ trống slug -> sinh từ tên tour. Trùng với tour khác (kể cả tour trong thùng rác, + // vì ràng buộc unique tính trên cả bảng) -> nối thêm -2, -3... cho tới khi còn trống. + private String uniqueSlug(TourForm form, Tour tour) { + String base = form.getSlug() == null || form.getSlug().isBlank() + ? SlugGenerator.from(form.getTitle()) + : SlugGenerator.from(form.getSlug()); + if (base.isEmpty()) { + base = "tour"; + } + + Long id = tour.getId(); + String candidate = base; + int suffix = 2; + while (tourRepository.existsBySlugAndIdNot(candidate, id == null ? -1L : id)) { + candidate = base + "-" + suffix++; + } + return candidate; + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + @Transactional(readOnly = true) + public Page listDeleted(int page, int size) { + return tourRepository + .findDeleted(PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "deletedAt"))) + .map(tour -> new TrashTourRow( + tour.getId(), + tour.getTitle(), + tour.getDestination() == null ? null : tour.getDestination().getName(), + tour.getDeletedAt())); + } + + // Trả về số đơn còn hiệu lực để báo lại cho admin. KHÔNG chặn xoá: xoá mềm chỉ ẩn tour khỏi + // danh sách bán, đơn đã đặt vẫn giữ nguyên tham chiếu và khách vẫn đi tour bình thường. + @Transactional + public DeleteResult softDelete(Long id) { + Tour tour = tourRepository.findActiveById(id) + .orElseThrow(() -> new ResourceNotFoundException("tour")); + + long activeBookings = + bookingRepository.countByTourIdAndStatusNot(tour.getId(), BookingStatus.CANCELLED); + + tour.setDeletedAt(LocalDateTime.now()); + tour.setSlug(releaseSlug(tour)); + tourRepository.save(tour); + + return new DeleteResult(tour.getTitle(), activeBookings); + } + + @Transactional + public String restore(Long id) { + Tour tour = tourRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("tour")); + if (!tour.isDeleted()) { + // Bấm khôi phục hai lần (F5 lại trang) không được coi là lỗi. + return tour.getTitle(); + } + + tour.setSlug(reclaimSlug(tour)); + tour.setDeletedAt(null); + tourRepository.save(tour); + return tour.getTitle(); + } + + // "da-nang-3n2d" -> "da-nang-3n2d-deleted-12". Cắt bớt phần đầu nếu chạm giới hạn cột. + private String releaseSlug(Tour tour) { + if (tour.getSlug() == null || tour.getSlug().contains(DELETED_SLUG_MARKER)) { + return tour.getSlug(); + } + String suffix = DELETED_SLUG_MARKER + tour.getId(); + String base = tour.getSlug(); + int room = SLUG_MAX_LENGTH - suffix.length(); + if (base.length() > room) { + base = base.substring(0, room); + } + return base + suffix; + } + + // Đòi lại slug gốc; nếu trong lúc nằm thùng rác đã có tour khác dùng slug đó thì giữ nguyên + // slug có hậu tố — thà URL xấu còn hơn khôi phục thất bại vì vi phạm ràng buộc unique. + private String reclaimSlug(Tour tour) { + String slug = tour.getSlug(); + if (slug == null) { + return null; + } + String suffix = DELETED_SLUG_MARKER + tour.getId(); + if (!slug.endsWith(suffix)) { + return slug; + } + String original = slug.substring(0, slug.length() - suffix.length()); + if (original.isEmpty() || tourRepository.existsBySlugAndIdNot(original, tour.getId())) { + return slug; + } + return original; + } + + public record DeleteResult(String title, long activeBookings) { + } +} diff --git a/src/main/resources/templates/admin/tours/form.html b/src/main/resources/templates/admin/tours/form.html new file mode 100644 index 0000000..2ad9906 --- /dev/null +++ b/src/main/resources/templates/admin/tours/form.html @@ -0,0 +1,164 @@ + + + + Tour + + +
+ + + + +
+ + +
+

Lỗi

+
+ +
+ + +

Lỗi

+
+ +
+ + +

Phần định danh trên URL công khai, ví dụ da-nang-3n2d.

+

Lỗi

+
+ +
+
+ + +

Lỗi

+
+ +
+ + +

Lỗi

+
+
+ +
+
+ + +

Lỗi

+
+ +
+ + +

Lỗi

+
+
+ +
+
+ + +

Lỗi

+
+ +
+ + +

Lỗi

+
+
+ +
+ + + + + +
+ Ảnh đại diện hiện tại + +
+ + +

JPG, PNG, WEBP hoặc GIF, tối đa 5 MB. Chọn file mới sẽ thay ảnh cũ.

+

Lỗi

+
+ +
+ + + + + +

Chọn nhiều ảnh cùng lúc để thêm vào gallery.

+
+ +
+ + +
+ +
+ Lịch trình + +
+ +
+ Ngày 1 + + + +
+
+ + +

Dòng để trống sẽ bị bỏ qua khi lưu. Số ngày tự đánh lại theo thứ tự.

+
+ +
+ + Huỷ +
+
+ +
+ + + diff --git a/src/main/resources/templates/admin/tours/list.html b/src/main/resources/templates/admin/tours/list.html new file mode 100644 index 0000000..cba70a6 --- /dev/null +++ b/src/main/resources/templates/admin/tours/list.html @@ -0,0 +1,77 @@ + + + + Tour · TripGo Admin + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTên tourĐiểm đếnGiáSố ngàyThao tác
1Tên tourĐiểm đến00 + Sửa + +
+ +
+
Chưa có tour nào
+ + + + +
+ + diff --git a/src/main/resources/templates/admin/tours/trash.html b/src/main/resources/templates/admin/tours/trash.html new file mode 100644 index 0000000..115088f --- /dev/null +++ b/src/main/resources/templates/admin/tours/trash.html @@ -0,0 +1,60 @@ + + + + Thùng rác · TripGo Admin + + +
+ + + +

+ Tour ở đây đã bị ẩn khỏi trang bán và API công khai, nhưng dữ liệu vẫn còn nguyên — + đơn đặt và đánh giá cũ không bị ảnh hưởng. Khôi phục là tour hiện lại như trước. +

+ + + + + + + + + + + + + + + + + + + + + + + +
IDTên tourĐiểm đếnXoá lúcThao tác
1Tên tourĐiểm đến— +
+ +
+
Thùng rác trống
+ + + +
+ + diff --git a/src/test/java/demo/tripgo/admin/AdminTourDeleteRestoreIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminTourDeleteRestoreIntegrationTest.java new file mode 100644 index 0000000..29b76b8 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminTourDeleteRestoreIntegrationTest.java @@ -0,0 +1,279 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.ContactInfo; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.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.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminTourDeleteRestoreIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + @Autowired UserRepository users; + + private Destination destination; + private Tour tour; + + @BeforeEach + void setUp() { + bookings.deleteAll(); + departures.deleteAll(); + tours.deleteAll(); + destinations.deleteAll(); + users.deleteAll(); + + destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + tour = saveTour("Đà Nẵng 3N2Đ", "da-nang-3n2d"); + } + + // ---- Xoá ---- + + @Test + @WithMockUser(roles = "ADMIN") + void deleteMovesTourToTrashInsteadOfRemovingRow() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())) + .andExpect(redirectedUrl("/admin/tours")) + .andExpect(flash().attribute("flashMessage", containsString("vào thùng rác"))); + + // Hàng vẫn còn — đây là điểm khác biệt với xoá cứng. + assertThat(tours.findById(tour.getId())).isPresent(); + assertThat(tours.findActiveById(tour.getId())).isEmpty(); + assertThat(tours.count()).isEqualTo(1); + } + + @Test + @WithMockUser(roles = "ADMIN") + void deletedTourVanishesFromListAndPublicApi() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())); + + mvc.perform(get("/admin/tours")) + .andExpect(content().string(org.hamcrest.Matchers.not(containsString("Đà Nẵng 3N2Đ")))); + mvc.perform(get("/api/v1/tours")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.not(containsString("Đà Nẵng 3N2Đ")))); + } + + // Không chặn xoá tour đã có đơn, nhưng phải nói cho admin biết. + @Test + @WithMockUser(roles = "ADMIN") + void deleteWarnsWhenTourStillHasActiveBookings() throws Exception { + saveBooking(BookingStatus.CONFIRMED); + saveBooking(BookingStatus.CANCELLED); + + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())) + .andExpect(flash().attribute("flashMessage", containsString("còn 1 đơn đặt còn hiệu lực"))); + } + + // Đơn cũ vẫn tra được tour sau khi xoá mềm — lý do chính chọn xoá mềm thay vì cascade. + @Test + @WithMockUser(roles = "ADMIN") + void existingBookingStillResolvesItsTour() throws Exception { + Booking booking = saveBooking(BookingStatus.CONFIRMED); + // getId() trên proxy lazy không cần nạp entity, nên đọc được ngoài transaction. + Long userId = booking.getUser().getId(); + + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())); + + // findByIdAndUserId có @EntityGraph nạp sẵn tour -> đọc được sau khi session đóng. + Booking reloaded = bookings.findByIdAndUserId(booking.getId(), userId).orElseThrow(); + assertThat(reloaded.getTour().getTitle()).isEqualTo("Đà Nẵng 3N2Đ"); + assertThat(reloaded.getTour().isDeleted()).isTrue(); + } + + // ---- Slug ---- + + @Test + @WithMockUser(roles = "ADMIN") + void deleteFreesSlugSoANewTourCanReuseIt() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())); + + assertThat(tours.findById(tour.getId()).orElseThrow().getSlug()) + .isEqualTo("da-nang-3n2d-deleted-" + tour.getId()); + + // Slug gốc giờ dùng lại được cho tour mới. + Tour fresh = saveTour("Đà Nẵng bản mới", "da-nang-3n2d"); + assertThat(fresh.getId()).isNotNull(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void restoreReclaimsOriginalSlugWhenStillFree() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())); + mvc.perform(post("/admin/tours/{id}/restore", tour.getId()).with(csrf())) + .andExpect(redirectedUrl("/admin/tours/trash")); + + Tour restored = tours.findById(tour.getId()).orElseThrow(); + assertThat(restored.getSlug()).isEqualTo("da-nang-3n2d"); + assertThat(restored.isDeleted()).isFalse(); + } + + // Slug gốc đã bị tour khác chiếm trong lúc nằm thùng rác -> giữ hậu tố, không được ném lỗi. + @Test + @WithMockUser(roles = "ADMIN") + void restoreKeepsSuffixWhenOriginalSlugWasTaken() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())); + saveTour("Tour khác", "da-nang-3n2d"); + + mvc.perform(post("/admin/tours/{id}/restore", tour.getId()).with(csrf())); + + Tour restored = tours.findById(tour.getId()).orElseThrow(); + assertThat(restored.getSlug()).isEqualTo("da-nang-3n2d-deleted-" + tour.getId()); + assertThat(restored.isDeleted()).isFalse(); + } + + // ---- Thùng rác ---- + + @Test + @WithMockUser(roles = "ADMIN") + void trashListsOnlyDeletedTours() throws Exception { + saveTour("Tour còn bán", "tour-con-ban"); + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())); + + mvc.perform(get("/admin/tours/trash")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Đà Nẵng 3N2Đ"))) + .andExpect(content().string(org.hamcrest.Matchers.not(containsString("Tour còn bán")))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void emptyTrashSaysSo() throws Exception { + mvc.perform(get("/admin/tours/trash")) + .andExpect(content().string(containsString("Thùng rác trống"))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void restoringTwiceIsNotAnError() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())); + mvc.perform(post("/admin/tours/{id}/restore", tour.getId()).with(csrf())); + mvc.perform(post("/admin/tours/{id}/restore", tour.getId()).with(csrf())) + .andExpect(redirectedUrl("/admin/tours/trash")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void deletingUnknownTourReturns404() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", 999999).with(csrf())) + .andExpect(status().isNotFound()); + } + + // ---- Bảo vệ ---- + + @Test + @WithMockUser(roles = "ADMIN") + void deleteWithoutCsrfIsRejected() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId())) + .andExpect(status().isForbidden()); + assertThat(tours.findActiveById(tour.getId())).isPresent(); + } + + @Test + @WithMockUser(roles = "USER") + void normalUserCannotDelete() throws Exception { + mvc.perform(post("/admin/tours/{id}/delete", tour.getId()).with(csrf())) + .andExpect(redirectedUrl("/admin/login?denied")); + assertThat(tours.findActiveById(tour.getId())).isPresent(); + } + + private Tour saveTour(String title, String slug) { + Tour t = new Tour(); + t.setTitle(title); + t.setSlug(slug); + t.setDestination(destination); + t.setCategory(category()); + t.setDurationDays(3); + t.setPrice(new BigDecimal("4500000")); + t.setMaxGuests(20); + t.setDescription("mô tả"); + return tours.save(t); + } + + private Category category() { + return categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + } + + private Booking saveBooking(BookingStatus status) { + User user = new User(); + user.setFullName("Khách"); + user.setEmail(UUID.randomUUID() + "@example.com"); + user.setPassword("x"); + user.setRole(Role.USER); + user = users.save(user); + + // Booking trỏ tới Departure (ngày khởi hành) chứ không giữ ngày trực tiếp. + Departure departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(2); + departure = departures.save(departure); + + ContactInfo contact = new ContactInfo(); + contact.setFullName("Khách"); + contact.setEmail("k@example.com"); + contact.setPhone("0900000000"); + + Booking booking = new Booking(); + booking.setUser(user); + booking.setTour(tour); + booking.setDeparture(departure); + booking.setCode(UUID.randomUUID().toString().substring(0, 8)); + booking.setAdults(2); + booking.setChildren(0); + booking.setTotalPrice(new BigDecimal("9000000")); + booking.setStatus(status); + booking.setContact(contact); + return bookings.save(booking); + } +} diff --git a/src/test/java/demo/tripgo/admin/AdminTourFormIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminTourFormIntegrationTest.java new file mode 100644 index 0000000..c581693 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminTourFormIntegrationTest.java @@ -0,0 +1,314 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Tour; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasProperty; +import static org.hamcrest.Matchers.is; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminTourFormIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + + private Destination destination; + private Category category; + + @BeforeEach + void setUp() { + tours.deleteAll(); + destinations.deleteAll(); + + destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + category = categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + } + + // ---- Hiển thị form ---- + + @Test + @WithMockUser(roles = "ADMIN") + void createFormShowsEmptyFieldsAndDropdowns() throws Exception { + mvc.perform(get("/admin/tours/new")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/tours/form")) + .andExpect(content().string(containsString("Đà Nẵng"))) + .andExpect(content().string(containsString("Biển đảo"))); + } + + // Kiểm qua model chứ không so chuỗi HTML: Thymeleaf escape ký tự có named entity khi render + // attribute ("à" -> "à"), nên containsString trên value= sẽ trượt dù dữ liệu vẫn đúng. + @Test + @WithMockUser(roles = "ADMIN") + void editFormIsPrefilled() throws Exception { + Tour tour = saveTour("Đà Nẵng 3N2Đ", "da-nang-3n2d"); + + mvc.perform(get("/admin/tours/{id}/edit", tour.getId())) + .andExpect(status().isOk()) + .andExpect(model().attribute("form", allOf( + hasProperty("id", is(tour.getId())), + hasProperty("title", is("Đà Nẵng 3N2Đ")), + hasProperty("slug", is("da-nang-3n2d")), + hasProperty("destinationId", is(destination.getId())), + hasProperty("categoryId", is(category.getId())), + hasProperty("durationDays", is(3))))) + .andExpect(content().string(containsString("da-nang-3n2d"))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void editFormForDeletedTourReturns404() throws Exception { + Tour tour = saveTour("Tour đã xoá", "tour-da-xoa"); + tour.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(tour); + + mvc.perform(get("/admin/tours/{id}/edit", tour.getId())) + .andExpect(status().isNotFound()); + } + + // ---- Tạo ---- + + @Test + @WithMockUser(roles = "ADMIN") + void createsTourAndGeneratesSlugFromTitle() throws Exception { + mvc.perform(post("/admin/tours").with(csrf()) + .param("title", "Đà Nẵng 3N2Đ") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "4500000") + .param("durationDays", "3") + .param("maxGuests", "20")) + .andExpect(redirectedUrl("/admin/tours")); + + Tour saved = tours.findAll().getFirst(); + assertThat(saved.getTitle()).isEqualTo("Đà Nẵng 3N2Đ"); + assertThat(saved.getSlug()).isEqualTo("da-nang-3n2d"); + assertThat(saved.getPrice()).isEqualByComparingTo("4500000"); + } + + // Slug trùng (kể cả với tour trong thùng rác) phải tự nối hậu tố, không được ném lỗi unique. + @Test + @WithMockUser(roles = "ADMIN") + void duplicateSlugGetsNumericSuffix() throws Exception { + saveTour("Đà Nẵng 3N2Đ", "da-nang-3n2d"); + + mvc.perform(post("/admin/tours").with(csrf()) + .param("title", "Đà Nẵng 3N2Đ") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "5000000") + .param("durationDays", "3") + .param("maxGuests", "20")) + .andExpect(redirectedUrl("/admin/tours")); + + assertThat(tours.findAll()).extracting(Tour::getSlug) + .containsExactlyInAnyOrder("da-nang-3n2d", "da-nang-3n2d-2"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void newTourAppearsInPublicApi() throws Exception { + mvc.perform(post("/admin/tours").with(csrf()) + .param("title", "Tour mới toanh") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "1000000") + .param("durationDays", "2") + .param("maxGuests", "10")); + + // AC: thay đổi qua giao diện phản ánh ngay ở API công khai. + mvc.perform(get("/api/v1/tours")) + .andExpect(content().string(containsString("Tour mới toanh"))); + } + + // ---- Validation ---- + + @Test + @WithMockUser(roles = "ADMIN") + void emptyFormShowsFieldErrorsAndDoesNotSave() throws Exception { + mvc.perform(post("/admin/tours").with(csrf()) + .param("title", "") + .param("price", "") + .param("durationDays", "") + .param("maxGuests", "")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/tours/form")) + .andExpect(model().attributeHasFieldErrors("form", + "title", "destinationId", "categoryId", "price", "durationDays", "maxGuests")) + .andExpect(content().string(containsString("Tên tour không được để trống"))) + .andExpect(content().string(containsString("Vui lòng chọn điểm đến"))); + + assertThat(tours.count()).isZero(); + } + + // Ràng buộc giữa hai trường: annotation trên một trường không diễn đạt được. + @Test + @WithMockUser(roles = "ADMIN") + void discountMustBeLowerThanPrice() throws Exception { + mvc.perform(post("/admin/tours").with(csrf()) + .param("title", "Tour A") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "1000000") + .param("discountPrice", "1000000") + .param("durationDays", "3") + .param("maxGuests", "20")) + .andExpect(view().name("admin/tours/form")) + .andExpect(model().attributeHasFieldErrors("form", "discountPrice")) + .andExpect(content().string(containsString("Giá khuyến mãi phải nhỏ hơn giá gốc"))); + + assertThat(tours.count()).isZero(); + } + + // Form lỗi vẫn phải còn dropdown, nếu không admin không sửa lại được. + @Test + @WithMockUser(roles = "ADMIN") + void invalidFormKeepsDropdownsAndTypedValues() throws Exception { + mvc.perform(post("/admin/tours").with(csrf()) + .param("title", "") + .param("price", "123456") + .param("destinationId", destination.getId().toString())) + .andExpect(model().attributeExists("destinations", "categories")) + .andExpect(model().attribute("form", hasProperty("price", is(new BigDecimal("123456"))))) + .andExpect(content().string(containsString("123456"))); + } + + // ---- Sửa ---- + + @Test + @WithMockUser(roles = "ADMIN") + void updatesExistingTour() throws Exception { + Tour tour = saveTour("Tên cũ", "ten-cu"); + + mvc.perform(post("/admin/tours/{id}", tour.getId()).with(csrf()) + .param("title", "Tên mới") + .param("slug", "ten-cu") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "7000000") + .param("durationDays", "5") + .param("maxGuests", "30")) + .andExpect(redirectedUrl("/admin/tours")); + + Tour updated = tours.findById(tour.getId()).orElseThrow(); + assertThat(updated.getTitle()).isEqualTo("Tên mới"); + assertThat(updated.getSlug()).isEqualTo("ten-cu"); + assertThat(updated.getDurationDays()).isEqualTo(5); + } + + // Sửa mà giữ nguyên slug của chính nó thì không được coi là trùng với chính mình. + @Test + @WithMockUser(roles = "ADMIN") + void keepingOwnSlugDoesNotAddSuffix() throws Exception { + Tour tour = saveTour("Tour A", "tour-a"); + + mvc.perform(post("/admin/tours/{id}", tour.getId()).with(csrf()) + .param("title", "Tour A") + .param("slug", "tour-a") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "1000000") + .param("durationDays", "3") + .param("maxGuests", "20")); + + assertThat(tours.findById(tour.getId()).orElseThrow().getSlug()).isEqualTo("tour-a"); + } + + // Đánh giá/lượt đánh giá là dữ liệu hệ thống, form không được đụng vào. + @Test + @WithMockUser(roles = "ADMIN") + void updateDoesNotResetRating() throws Exception { + Tour tour = saveTour("Tour A", "tour-a"); + tour.setRatingAvg(4.5); + tour.setReviewCount(12); + tours.saveAndFlush(tour); + + mvc.perform(post("/admin/tours/{id}", tour.getId()).with(csrf()) + .param("title", "Tour A đổi tên") + .param("slug", "tour-a") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "1000000") + .param("durationDays", "3") + .param("maxGuests", "20")); + + Tour updated = tours.findById(tour.getId()).orElseThrow(); + assertThat(updated.getRatingAvg()).isEqualTo(4.5); + assertThat(updated.getReviewCount()).isEqualTo(12); + } + + // ---- Bảo vệ ---- + + @Test + @WithMockUser(roles = "ADMIN") + void createWithoutCsrfIsRejected() throws Exception { + mvc.perform(post("/admin/tours") + .param("title", "Tour A") + .param("destinationId", destination.getId().toString()) + .param("categoryId", category.getId().toString()) + .param("price", "1000000") + .param("durationDays", "3") + .param("maxGuests", "20")) + .andExpect(status().isForbidden()); + assertThat(tours.count()).isZero(); + } + + @Test + @WithMockUser(roles = "USER") + void normalUserCannotOpenForm() throws Exception { + mvc.perform(get("/admin/tours/new")) + .andExpect(redirectedUrl("/admin/login?denied")); + } + + private Tour saveTour(String title, String slug) { + Tour tour = new Tour(); + tour.setTitle(title); + tour.setSlug(slug); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("4500000")); + tour.setMaxGuests(20); + tour.setDescription("mô tả"); + return tours.save(tour); + } +} diff --git a/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java new file mode 100644 index 0000000..8cd5a7a --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java @@ -0,0 +1,172 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Tour; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminTourListIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + + private Destination destination; + + @BeforeEach + void setUp() { + tours.deleteAll(); + destinations.deleteAll(); + + destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + } + + @Test + void requiresAdminRole() throws Exception { + mvc.perform(get("/admin/tours")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/login")); + } + + @Test + @WithMockUser(roles = "USER") + void normalUserIsDenied() throws Exception { + mvc.perform(get("/admin/tours")) + .andExpect(redirectedUrl("/admin/login?denied")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void listsToursWithDestinationAndPrice() throws Exception { + saveTour("Đà Nẵng 3N2Đ"); + + mvc.perform(get("/admin/tours")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/tours/list")) + .andExpect(model().attribute("total", 1L)) + .andExpect(content().string(containsString("Đà Nẵng 3N2Đ"))) + .andExpect(content().string(containsString("+ Thêm tour"))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void emptyListShowsPlaceholderRow() throws Exception { + mvc.perform(get("/admin/tours")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Chưa có tour nào"))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void searchFiltersByTitle() throws Exception { + saveTour("Sapa săn mây"); + saveTour("Đà Nẵng 3N2Đ"); + + mvc.perform(get("/admin/tours").param("q", "sapa")) + .andExpect(model().attribute("total", 1L)) + .andExpect(content().string(containsString("Sapa săn mây"))) + .andExpect(content().string(not(containsString("Đà Nẵng 3N2Đ")))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void searchWithNoMatchExplainsWhy() throws Exception { + saveTour("Sapa săn mây"); + + mvc.perform(get("/admin/tours").param("q", "khong-co-gi")) + .andExpect(content().string(containsString("Không tìm thấy tour nào khớp từ khoá"))); + } + + // Tour đã xoá mềm không hiện ở danh sách chính (thùng rác là màn riêng, làm sau). + @Test + @WithMockUser(roles = "ADMIN") + void deletedTourIsNotListed() throws Exception { + saveTour("Tour còn bán"); + Tour gone = saveTour("Tour đã xoá"); + gone.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(gone); + + mvc.perform(get("/admin/tours")) + .andExpect(model().attribute("total", 1L)) + .andExpect(content().string(not(containsString("Tour đã xoá")))); + } + + // 21 tour / 20 dòng mỗi trang -> đúng 2 trang. + @Test + @WithMockUser(roles = "ADMIN") + void paginatesBeyondOnePage() throws Exception { + for (int i = 1; i <= 21; i++) { + saveTour("Tour số " + i); + } + + mvc.perform(get("/admin/tours")) + .andExpect(model().attribute("totalPages", 2)) + .andExpect(model().attribute("currentPage", 1)); + + mvc.perform(get("/admin/tours").param("page", "2")) + .andExpect(model().attribute("currentPage", 2)); + } + + // page=0 sẽ làm PageRequest ném lỗi nếu không chặn; sửa tay URL không được làm vỡ trang. + @Test + @WithMockUser(roles = "ADMIN") + void invalidPageNumberFallsBackToFirstPage() throws Exception { + saveTour("Tour A"); + + mvc.perform(get("/admin/tours").param("page", "0")) + .andExpect(status().isOk()) + .andExpect(model().attribute("currentPage", 1)); + } + + private Tour saveTour(String title) { + Tour tour = new Tour(); + tour.setTitle(title); + tour.setSlug(UUID.randomUUID().toString()); + tour.setDestination(destination); + tour.setCategory(category()); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("4500000")); + tour.setMaxGuests(20); + tour.setDescription("mô tả"); + return tours.save(tour); + } + + private Category category() { + return categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + } +} diff --git a/src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java new file mode 100644 index 0000000..815e195 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java @@ -0,0 +1,292 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.ItineraryDay; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.TourImage; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +// Ghi file thật vào thư mục tạm riêng của test rồi dọn sạch sau mỗi lần chạy. +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestPropertySource(properties = "storage.upload-dir=./target/test-uploads") +class AdminTourUploadItineraryIntegrationTest { + + private static final Path UPLOAD_DIR = Path.of("./target/test-uploads"); + + @Autowired MockMvc mvc; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + + private Destination destination; + private Category category; + + @BeforeEach + void setUp() { + tours.deleteAll(); + destinations.deleteAll(); + + destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + category = categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + } + + @AfterEach + void cleanUploads() throws IOException { + if (!Files.exists(UPLOAD_DIR)) { + return; + } + try (var paths = Files.walk(UPLOAD_DIR)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Dọn dẹp best-effort, không để ảnh hưởng kết quả test. + } + }); + } + } + + // ---- Upload ---- + + @Test + @WithMockUser(roles = "ADMIN") + void uploadsThumbnailAndWritesFileToDisk() throws Exception { + mvc.perform(newTour().file(image("thumbnailFile", "image/jpeg"))) + .andExpect(redirectedUrl("/admin/tours")); + + Tour saved = tours.findAll().getFirst(); + assertThat(saved.getThumbnailUrl()) + .startsWith("/uploads/") + .endsWith(".jpg"); + + // Đuôi file suy ra từ content type, KHÔNG lấy từ tên file client gửi lên. + assertThat(fileFor(saved.getThumbnailUrl())).exists(); + } + + // Sai định dạng phải quay lại form kèm lời nhắn, không phải trang 500. + @Test + @WithMockUser(roles = "ADMIN") + void rejectsNonImageFile() throws Exception { + mvc.perform(newTour().file(new MockMultipartFile( + "thumbnailFile", "shell.jsp", "application/octet-stream", "x".getBytes()))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers + .view().name("admin/tours/form")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers + .content().string(org.hamcrest.Matchers.containsString( + "Chỉ chấp nhận ảnh JPG, PNG, WEBP hoặc GIF"))); + + assertThat(tours.count()).isZero(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void uploadsMultipleGalleryImages() throws Exception { + mvc.perform(newTour() + .file(image("galleryFiles", "image/png")) + .file(image("galleryFiles", "image/webp"))) + .andExpect(redirectedUrl("/admin/tours")); + + Tour saved = tours.findAll().getFirst(); + List images = loadImages(saved.getId()); + assertThat(images).hasSize(2); + assertThat(images).extracting(TourImage::getPosition).containsExactly(0, 1); + images.forEach(image -> assertThat(fileFor(image.getUrl())).exists()); + } + + // Không chọn file mới thì ảnh cũ phải còn — hidden field thumbnailUrl giữ nó lại. + @Test + @WithMockUser(roles = "ADMIN") + void editingWithoutNewFileKeepsExistingThumbnail() throws Exception { + mvc.perform(newTour().file(image("thumbnailFile", "image/jpeg"))); + Tour saved = tours.findAll().getFirst(); + String original = saved.getThumbnailUrl(); + + mvc.perform(editTour(saved.getId()).param("thumbnailUrl", original)); + + assertThat(tours.findById(saved.getId()).orElseThrow().getThumbnailUrl()).isEqualTo(original); + assertThat(fileFor(original)).exists(); + } + + // Tick "Gỡ ảnh này" phải xoá cả bản ghi lẫn file trên đĩa. + @Test + @WithMockUser(roles = "ADMIN") + void removeThumbnailDeletesRecordAndFile() throws Exception { + mvc.perform(newTour().file(image("thumbnailFile", "image/jpeg"))); + Tour saved = tours.findAll().getFirst(); + String original = saved.getThumbnailUrl(); + + mvc.perform(editTour(saved.getId()) + .param("thumbnailUrl", original) + .param("removeThumbnail", "true")); + + assertThat(tours.findById(saved.getId()).orElseThrow().getThumbnailUrl()).isNull(); + assertThat(fileFor(original)).doesNotExist(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void unkeptGalleryImageIsRemoved() throws Exception { + mvc.perform(newTour() + .file(image("galleryFiles", "image/png")) + .file(image("galleryFiles", "image/png"))); + Tour saved = tours.findAll().getFirst(); + List before = loadImages(saved.getId()); + String kept = before.get(0).getUrl(); + String dropped = before.get(1).getUrl(); + + // Chỉ gửi lại ảnh muốn giữ; ảnh còn lại coi như đã bỏ tick. + mvc.perform(editTour(saved.getId()).param("keepImageUrls", kept)); + + List after = loadImages(saved.getId()); + assertThat(after).hasSize(1); + assertThat(after.getFirst().getUrl()).isEqualTo(kept); + assertThat(fileFor(dropped)).doesNotExist(); + } + + // ---- Lịch trình ---- + + @Test + @WithMockUser(roles = "ADMIN") + void savesItineraryDaysInOrder() throws Exception { + mvc.perform(newTour() + .param("itinerary[0].title", "Khởi hành") + .param("itinerary[0].description", "Xe đón tại khách sạn") + .param("itinerary[1].title", "Bà Nà Hills") + .param("itinerary[1].description", "Cả ngày")) + .andExpect(redirectedUrl("/admin/tours")); + + List days = loadItinerary(tours.findAll().getFirst().getId()); + assertThat(days).extracting(ItineraryDay::getDayNumber).containsExactly(1, 2); + assertThat(days).extracting(ItineraryDay::getTitle) + .containsExactly("Khởi hành", "Bà Nà Hills"); + } + + // Dòng trống do admin bấm "Thêm ngày" rồi không nhập: bỏ qua, không báo lỗi bắt buộc. + @Test + @WithMockUser(roles = "ADMIN") + void blankItineraryRowsAreIgnored() throws Exception { + mvc.perform(newTour() + .param("itinerary[0].title", "Ngày thật") + .param("itinerary[1].title", "") + .param("itinerary[1].description", " ")); + + assertThat(loadItinerary(tours.findAll().getFirst().getId())).hasSize(1); + } + + // Xoá dòng giữa: số ngày phải đánh lại liên tục, không để 1, 3. + @Test + @WithMockUser(roles = "ADMIN") + void renumbersDaysAfterMiddleRowRemoved() throws Exception { + mvc.perform(newTour() + .param("itinerary[0].title", "A") + .param("itinerary[1].title", "B") + .param("itinerary[2].title", "C")); + Long id = tours.findAll().getFirst().getId(); + + mvc.perform(editTour(id) + .param("itinerary[0].title", "A") + .param("itinerary[1].title", "C")); + + List days = loadItinerary(id); + assertThat(days).extracting(ItineraryDay::getDayNumber).containsExactly(1, 2); + assertThat(days).extracting(ItineraryDay::getTitle).containsExactly("A", "C"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void itineraryShowsUpInPublicTourDetail() throws Exception { + mvc.perform(newTour() + .param("itinerary[0].title", "Khởi hành") + .param("itinerary[0].description", "Xe đón tại khách sạn")); + Tour saved = tours.findAll().getFirst(); + + mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .get("/api/v1/tours/" + saved.getId())) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers + .jsonPath("$.itinerary[0].title").value("Khởi hành")); + } + + // ---- Tiện ích ---- + + private MockMultipartHttpServletRequestBuilder newTour() { + return withBaseFields(multipart("/admin/tours")); + } + + private MockMultipartHttpServletRequestBuilder editTour(Long id) { + return withBaseFields(multipart("/admin/tours/" + id)); + } + + private MockMultipartHttpServletRequestBuilder withBaseFields( + MockMultipartHttpServletRequestBuilder builder + ) { + builder.with(csrf()); + builder.param("title", "Đà Nẵng 3N2Đ"); + builder.param("slug", "da-nang-3n2d"); + builder.param("destinationId", destination.getId().toString()); + builder.param("categoryId", category.getId().toString()); + builder.param("price", "4500000"); + builder.param("durationDays", "3"); + builder.param("maxGuests", "20"); + return builder; + } + + private MockMultipartFile image(String field, String contentType) { + return new MockMultipartFile(field, "anh.png", contentType, "fake-image-bytes".getBytes()); + } + + private Path fileFor(String publicUrl) { + return UPLOAD_DIR.resolve(publicUrl.substring("/uploads/".length())); + } + + private List loadImages(Long tourId) { + return tours.findDetailById(tourId).orElseThrow().getImages().stream() + .sorted(Comparator.comparingInt(TourImage::getPosition)) + .toList(); + } + + private List loadItinerary(Long tourId) { + return tours.fetchItinerary(tourId).orElseThrow().getItinerary().stream() + .sorted(Comparator.comparingInt(ItineraryDay::getDayNumber)) + .toList(); + } +} From 78d36bac58f4de293f74c1db5ca98e54d1d847f0 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 13:24:33 +0700 Subject: [PATCH 05/23] Quan ly don dat trong khu quan tri: bang, loc trang thai, doi trang thai Bang don co loc theo trang thai (link chu khong phai select+JS, de URL mang theo bo loc va chia se duoc), phan trang, va nut xac nhan/huy cho don dang cho. Viec huy don KHONG duoc chep sang service moi. Ham huy hien co chua doan de sai: phai entityManager.refresh(departure, PESSIMISTIC_WRITE) vi entity dang nam trong cache se tra gia tri cu - thieu no la mat cap nhat khi hai request huy cung luc (da co BookingCancelConcurrencyTest canh). Nen tach ra ham dung chung cancelAndReleaseSeats(): khach huy don cua minh va admin huy don cua nguoi khac di qua dung mot duong. Xac nhan don khong dung toi so cho: cho da bi tru ngay luc khach dat. requirePending() chan o tang service chu khong chi an nut tren giao dien: nut an khong ngan duoc request gui thang, va hai admin thao tac cung luc tren mot don la chuyen co that. Nhan tieng Viet dat ngay tren enum BookingStatus - mot nguon su that cho ca bang lan bo loc, them trang thai moi khong phai sua rai rac trong template. Co-Authored-By: Claude Opus 5 (1M context) --- .../tripgo/admin/AdminBookingController.java | 98 ++++++ .../demo/tripgo/admin/AdminBookingRow.java | 24 ++ .../demo/tripgo/entity/BookingStatus.java | 18 +- .../tripgo/repository/BookingRepository.java | 14 + .../tripgo/service/BookingAdminService.java | 88 ++++++ .../demo/tripgo/service/BookingService.java | 17 +- src/main/resources/static/css/admin.css | 63 ++++ .../templates/admin/bookings/list.html | 94 ++++++ .../admin/AdminBookingIntegrationTest.java | 298 ++++++++++++++++++ 9 files changed, 710 insertions(+), 4 deletions(-) create mode 100644 src/main/java/demo/tripgo/admin/AdminBookingController.java create mode 100644 src/main/java/demo/tripgo/admin/AdminBookingRow.java create mode 100644 src/main/java/demo/tripgo/service/BookingAdminService.java create mode 100644 src/main/resources/templates/admin/bookings/list.html create mode 100644 src/test/java/demo/tripgo/admin/AdminBookingIntegrationTest.java diff --git a/src/main/java/demo/tripgo/admin/AdminBookingController.java b/src/main/java/demo/tripgo/admin/AdminBookingController.java new file mode 100644 index 0000000..a07594a --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminBookingController.java @@ -0,0 +1,98 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.exception.BookingAlreadyCancelledException; +import demo.tripgo.exception.InvalidBookingRequestException; +import demo.tripgo.service.BookingAdminService; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/admin/bookings") +public class AdminBookingController { + + private static final int PAGE_SIZE = 20; + + private final BookingAdminService bookingAdminService; + + public AdminBookingController(BookingAdminService bookingAdminService) { + this.bookingAdminService = bookingAdminService; + } + + @GetMapping + public String list( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + Model model + ) { + BookingStatus filter = parseStatus(status); + Page bookings = + bookingAdminService.list(filter, Math.max(page, 1), PAGE_SIZE); + + model.addAttribute("activeMenu", "bookings"); + model.addAttribute("pageHeading", "Đơn đặt"); + model.addAttribute("bookings", bookings.getContent()); + model.addAttribute("statuses", BookingStatus.values()); + model.addAttribute("selectedStatus", filter); + model.addAttribute("currentPage", bookings.getNumber() + 1); + model.addAttribute("totalPages", Math.max(bookings.getTotalPages(), 1)); + model.addAttribute("total", bookings.getTotalElements()); + return "admin/bookings/list"; + } + + @PostMapping("/{id}/confirm") + public String confirm(@PathVariable Long id, @RequestParam(required = false) String status, + RedirectAttributes redirect) { + return applyChange(redirect, status, + () -> bookingAdminService.confirm(id), "Đã xác nhận đơn %s"); + } + + @PostMapping("/{id}/cancel") + public String cancel(@PathVariable Long id, @RequestParam(required = false) String status, + RedirectAttributes redirect) { + return applyChange(redirect, status, + () -> bookingAdminService.cancel(id), "Đã huỷ đơn %s, số chỗ đã được hoàn lại"); + } + + // Đổi trạng thái hỏng (đơn không còn chờ xác nhận) không phải lỗi hệ thống mà là tình huống + // bình thường khi hai admin thao tác cùng lúc -> báo lại trên chính trang danh sách. + private String applyChange( + RedirectAttributes redirect, + String status, + java.util.function.Supplier action, + String successTemplate + ) { + try { + Booking booking = action.get(); + redirect.addFlashAttribute("flashMessage", successTemplate.formatted(booking.getCode())); + redirect.addFlashAttribute("flashType", "success"); + } catch (InvalidBookingRequestException | BookingAlreadyCancelledException exception) { + redirect.addFlashAttribute("flashMessage", exception.getMessage()); + redirect.addFlashAttribute("flashType", "error"); + } + // Giữ nguyên bộ lọc đang xem để admin không bị nhảy về "Tất cả" sau mỗi thao tác. + return status == null || status.isBlank() + ? "redirect:/admin/bookings" + : "redirect:/admin/bookings?status=" + status; + } + + // Giá trị lạ trên URL (người dùng sửa tay) coi như không lọc, thay vì ném lỗi 500. + private BookingStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + try { + return BookingStatus.valueOf(status.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return null; + } + } +} diff --git a/src/main/java/demo/tripgo/admin/AdminBookingRow.java b/src/main/java/demo/tripgo/admin/AdminBookingRow.java new file mode 100644 index 0000000..877ffc4 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminBookingRow.java @@ -0,0 +1,24 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.BookingStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; + +// Một dòng trong bảng đơn đặt. Map trong transaction để template không chạm vào quan hệ lazy. +public record AdminBookingRow( + Long id, + String code, + String customerName, + String customerEmail, + String tourTitle, + LocalDate departureDate, + int guests, + BigDecimal totalPrice, + BookingStatus status +) { + // Chỉ đơn đang chờ mới đổi được trạng thái (hợp đồng: pending -> confirmed/cancelled). + public boolean isActionable() { + return status == BookingStatus.PENDING; + } +} diff --git a/src/main/java/demo/tripgo/entity/BookingStatus.java b/src/main/java/demo/tripgo/entity/BookingStatus.java index 0b70578..f5ea6db 100644 --- a/src/main/java/demo/tripgo/entity/BookingStatus.java +++ b/src/main/java/demo/tripgo/entity/BookingStatus.java @@ -1,7 +1,19 @@ package demo.tripgo.entity; public enum BookingStatus { - PENDING, - CONFIRMED, - CANCELLED + PENDING("Chờ xác nhận"), + CONFIRMED("Đã xác nhận"), + CANCELLED("Đã huỷ"); + + // Nhãn hiển thị ở khu quản trị. Đặt ngay trên enum để giao diện và bộ lọc dùng chung một + // nguồn, thêm trạng thái mới không phải đi sửa rải rác ở template. + private final String label; + + BookingStatus(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } } diff --git a/src/main/java/demo/tripgo/repository/BookingRepository.java b/src/main/java/demo/tripgo/repository/BookingRepository.java index a365ddb..74194f5 100644 --- a/src/main/java/demo/tripgo/repository/BookingRepository.java +++ b/src/main/java/demo/tripgo/repository/BookingRepository.java @@ -21,6 +21,20 @@ public interface BookingRepository extends JpaRepository { @EntityGraph(attributePaths = {"tour", "departure"}) Optional findByIdAndUserId(Long id, Long userId); + // Bảng đơn của khu quản trị. Fetch sẵn user/tour/departure vì mỗi dòng đều hiển thị cả ba; + // không có @EntityGraph thì 20 dòng sinh 60 truy vấn phụ. + @EntityGraph(attributePaths = {"user", "tour", "departure"}) + Page findAllBy(Pageable pageable); + + @EntityGraph(attributePaths = {"user", "tour", "departure"}) + Page findByStatus(BookingStatus status, Pageable pageable); + + // Nạp kèm quan hệ để đổi trạng thái không phải truy vấn thêm. + @EntityGraph(attributePaths = {"user", "tour", "departure"}) + Optional findWithDetailsById(Long id); + + long countByStatus(BookingStatus status); + // Số đơn còn hiệu lực của một tour: dùng để cảnh báo admin trước khi xoá. long countByTourIdAndStatusNot(Long tourId, BookingStatus status); diff --git a/src/main/java/demo/tripgo/service/BookingAdminService.java b/src/main/java/demo/tripgo/service/BookingAdminService.java new file mode 100644 index 0000000..e2f7524 --- /dev/null +++ b/src/main/java/demo/tripgo/service/BookingAdminService.java @@ -0,0 +1,88 @@ +package demo.tripgo.service; + +import demo.tripgo.admin.AdminBookingRow; +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.exception.InvalidBookingRequestException; +import demo.tripgo.exception.ResourceNotFoundException; +import demo.tripgo.repository.BookingRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// Xem và đổi trạng thái đơn ở khu quản trị. Việc huỷ đơn (có hoàn chỗ, có khoá) uỷ lại cho +// BookingService thay vì chép sang đây. +@Service +public class BookingAdminService { + + private final BookingRepository bookingRepository; + private final BookingService bookingService; + + public BookingAdminService(BookingRepository bookingRepository, BookingService bookingService) { + this.bookingRepository = bookingRepository; + this.bookingService = bookingService; + } + + @Transactional(readOnly = true) + public Page list(BookingStatus status, int page, int size) { + // Đơn mới nhất lên đầu; thêm id để thứ tự ổn định khi trùng createdAt. + Pageable pageable = PageRequest.of(page - 1, size, + Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id"))); + + Page bookings = status == null + ? bookingRepository.findAllBy(pageable) + : bookingRepository.findByStatus(status, pageable); + + return bookings.map(this::toRow); + } + + @Transactional(readOnly = true) + public long countPending() { + return bookingRepository.countByStatus(BookingStatus.PENDING); + } + + @Transactional + public Booking confirm(Long id) { + Booking booking = bookingRepository.findWithDetailsById(id) + .orElseThrow(() -> new ResourceNotFoundException("đơn đặt tour")); + requirePending(booking); + + // Không đụng tới số chỗ: chỗ đã bị trừ ngay lúc khách đặt, xác nhận chỉ đổi trạng thái. + booking.setStatus(BookingStatus.CONFIRMED); + return booking; + } + + @Transactional + public Booking cancel(Long id) { + Booking booking = bookingRepository.findWithDetailsById(id) + .orElseThrow(() -> new ResourceNotFoundException("đơn đặt tour")); + requirePending(booking); + return bookingService.cancelByAdmin(id); + } + + // Chặn ở tầng service chứ không chỉ ẩn nút trên giao diện: nút ẩn không ngăn được request + // gửi thẳng, và hai admin thao tác cùng lúc trên một đơn là chuyện có thật. + private void requirePending(Booking booking) { + if (booking.getStatus() != BookingStatus.PENDING) { + throw new InvalidBookingRequestException( + "Đơn %s đang ở trạng thái \"%s\", chỉ đơn chờ xác nhận mới đổi được" + .formatted(booking.getCode(), booking.getStatus().getLabel())); + } + } + + private AdminBookingRow toRow(Booking booking) { + return new AdminBookingRow( + booking.getId(), + booking.getCode(), + booking.getContact() == null ? null : booking.getContact().getFullName(), + booking.getContact() == null ? null : booking.getContact().getEmail(), + booking.getTour().getTitle(), + booking.getDeparture().getDepartureDate(), + booking.getAdults() + booking.getChildren(), + booking.getTotalPrice(), + booking.getStatus()); + } +} diff --git a/src/main/java/demo/tripgo/service/BookingService.java b/src/main/java/demo/tripgo/service/BookingService.java index e8e1684..c4ce7e1 100644 --- a/src/main/java/demo/tripgo/service/BookingService.java +++ b/src/main/java/demo/tripgo/service/BookingService.java @@ -108,6 +108,22 @@ public BookingResponse getMyBooking(User user, Long id) { public BookingResponse cancelBooking(User user, Long id) { Booking booking = bookingRepository.findByIdAndUserId(id, user.getId()) .orElseThrow(() -> new ResourceNotFoundException("đơn đặt tour")); + cancelAndReleaseSeats(booking); + return bookingMapper.toResponse(booking); + } + + // Admin huỷ đơn của bất kỳ khách nào, nên tìm theo id chứ không ràng buộc user. + // Vẫn đi qua cùng một hàm huỷ để phần hoàn chỗ (có khoá) chỉ tồn tại ở MỘT nơi — + // chép logic này sang service khác là mời gọi bug bán vượt số chỗ. + @Transactional + public Booking cancelByAdmin(Long id) { + Booking booking = bookingRepository.findWithDetailsById(id) + .orElseThrow(() -> new ResourceNotFoundException("đơn đặt tour")); + cancelAndReleaseSeats(booking); + return booking; + } + + private void cancelAndReleaseSeats(Booking booking) { if (booking.getStatus() == BookingStatus.CANCELLED) { throw new BookingAlreadyCancelledException(booking.getCode()); } @@ -119,7 +135,6 @@ public BookingResponse cancelBooking(User user, Long id) { Departure departure = booking.getDeparture(); entityManager.refresh(departure, LockModeType.PESSIMISTIC_WRITE); departure.setBookedSeats(departure.getBookedSeats() - (booking.getAdults() + booking.getChildren())); - return bookingMapper.toResponse(booking); } // Giá mỗi khách = giá KM nếu có, ngược lại giá gốc; tổng = giá * tổng số khách. diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index b682203..6dee4d2 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -664,3 +664,66 @@ .form-global-error p { margin: 0; } + +/* ---------- Bộ lọc trạng thái ---------- */ + +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.filter-bar a { + padding: 7px 14px; + border: 1px solid var(--admin-border); + border-radius: 999px; + background: #fff; + color: #374151; + text-decoration: none; + font-size: 14px; +} + +.filter-bar a:hover { + background: #f1f3f5; +} + +.filter-bar a.active { + background: var(--admin-accent); + border-color: var(--admin-accent); + color: #fff; + font-weight: 500; +} + +/* ---------- Nhãn trạng thái ---------- */ + +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +.badge-pending { + background: #fff4e5; + color: #b45309; +} + +.badge-confirmed { + background: #e7f6ec; + color: #12683a; +} + +.badge-cancelled { + background: #f1f3f5; + color: #6b7280; +} + +/* Dòng phụ trong ô bảng (email khách, dấu gạch khi không có thao tác). */ +.data-table .sub { + display: block; + color: #6b7280; + font-size: 13px; +} diff --git a/src/main/resources/templates/admin/bookings/list.html b/src/main/resources/templates/admin/bookings/list.html new file mode 100644 index 0000000..269322a --- /dev/null +++ b/src/main/resources/templates/admin/bookings/list.html @@ -0,0 +1,94 @@ + + + + Đơn đặt · TripGo Admin + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mã đơnKháchTourNgày điKháchTổng tiềnTrạng tháiThao tác
TG-2026-000001 + Nguyễn A + a@example.com + Tour01/01/202620 + Chờ xác nhận + + + +
+ + +
+
+ + +
+
+ — +
Chưa có đơn đặt nào
+ + + +
+ + diff --git a/src/test/java/demo/tripgo/admin/AdminBookingIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminBookingIntegrationTest.java new file mode 100644 index 0000000..42ab417 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminBookingIntegrationTest.java @@ -0,0 +1,298 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.ContactInfo; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.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.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminBookingIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired UserRepository users; + + private Tour tour; + private Departure departure; + + @BeforeEach + void setUp() { + bookings.deleteAll(); + departures.deleteAll(); + tours.deleteAll(); + destinations.deleteAll(); + users.deleteAll(); + + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + + tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("4500000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(0); + departure = departures.save(departure); + } + + // ---- Danh sách & lọc ---- + + @Test + @WithMockUser(roles = "ADMIN") + void listsBookingsWithCustomerTourAndStatus() throws Exception { + saveBooking(BookingStatus.PENDING, 2); + + mvc.perform(get("/admin/bookings")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/bookings/list")) + .andExpect(model().attribute("total", 1L)) + .andExpect(content().string(containsString("Đà Nẵng 3N2Đ"))) + .andExpect(content().string(containsString("Chờ xác nhận"))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void filtersByStatus() throws Exception { + saveBooking(BookingStatus.PENDING, 2); + Booking confirmed = saveBooking(BookingStatus.CONFIRMED, 1); + + mvc.perform(get("/admin/bookings").param("status", "CONFIRMED")) + .andExpect(model().attribute("total", 1L)) + .andExpect(content().string(containsString(confirmed.getCode()))); + + mvc.perform(get("/admin/bookings").param("status", "CANCELLED")) + .andExpect(model().attribute("total", 0L)) + .andExpect(content().string(containsString("Không có đơn nào ở trạng thái này"))); + } + + // Người dùng sửa tay URL: không được làm vỡ trang. + @Test + @WithMockUser(roles = "ADMIN") + void unknownStatusFallsBackToNoFilter() throws Exception { + saveBooking(BookingStatus.PENDING, 2); + + mvc.perform(get("/admin/bookings").param("status", "KHONG-CO-THAT")) + .andExpect(status().isOk()) + .andExpect(model().attribute("selectedStatus", (Object) null)) + .andExpect(model().attribute("total", 1L)); + } + + @Test + @WithMockUser(roles = "ADMIN") + void emptyListSaysSo() throws Exception { + mvc.perform(get("/admin/bookings")) + .andExpect(content().string(containsString("Chưa có đơn đặt nào"))); + } + + // ---- Xác nhận ---- + + @Test + @WithMockUser(roles = "ADMIN") + void confirmsPendingBookingWithoutTouchingSeats() throws Exception { + Booking booking = saveBooking(BookingStatus.PENDING, 3); + departure.setBookedSeats(3); + departures.saveAndFlush(departure); + + mvc.perform(post("/admin/bookings/{id}/confirm", booking.getId()).with(csrf())) + .andExpect(redirectedUrl("/admin/bookings")) + .andExpect(flash().attribute("flashMessage", containsString("Đã xác nhận đơn"))); + + assertThat(bookings.findById(booking.getId()).orElseThrow().getStatus()) + .isEqualTo(BookingStatus.CONFIRMED); + // Chỗ đã trừ lúc khách đặt, xác nhận không được trừ thêm lần nữa. + assertThat(departures.findById(departure.getId()).orElseThrow().getBookedSeats()).isEqualTo(3); + } + + // ---- Huỷ ---- + + @Test + @WithMockUser(roles = "ADMIN") + void cancelReleasesSeats() throws Exception { + Booking booking = saveBooking(BookingStatus.PENDING, 4); + departure.setBookedSeats(4); + departures.saveAndFlush(departure); + + mvc.perform(post("/admin/bookings/{id}/cancel", booking.getId()).with(csrf())) + .andExpect(flash().attribute("flashMessage", containsString("số chỗ đã được hoàn lại"))); + + assertThat(bookings.findById(booking.getId()).orElseThrow().getStatus()) + .isEqualTo(BookingStatus.CANCELLED); + assertThat(departures.findById(departure.getId()).orElseThrow().getBookedSeats()).isZero(); + } + + // ---- Chuyển trạng thái không hợp lệ ---- + + @Test + @WithMockUser(roles = "ADMIN") + void cannotConfirmAlreadyConfirmedBooking() throws Exception { + Booking booking = saveBooking(BookingStatus.CONFIRMED, 2); + + mvc.perform(post("/admin/bookings/{id}/confirm", booking.getId()).with(csrf())) + .andExpect(flash().attribute("flashType", "error")) + .andExpect(flash().attribute("flashMessage", containsString("chỉ đơn chờ xác nhận"))); + + assertThat(bookings.findById(booking.getId()).orElseThrow().getStatus()) + .isEqualTo(BookingStatus.CONFIRMED); + } + + // Huỷ đơn đã huỷ không được trừ chỗ lần hai — đây là chỗ dễ sinh lỗi âm số chỗ. + @Test + @WithMockUser(roles = "ADMIN") + void cannotCancelTwiceAndSeatsStayCorrect() throws Exception { + Booking booking = saveBooking(BookingStatus.PENDING, 5); + departure.setBookedSeats(5); + departures.saveAndFlush(departure); + + mvc.perform(post("/admin/bookings/{id}/cancel", booking.getId()).with(csrf())); + mvc.perform(post("/admin/bookings/{id}/cancel", booking.getId()).with(csrf())) + .andExpect(flash().attribute("flashType", "error")); + + assertThat(departures.findById(departure.getId()).orElseThrow().getBookedSeats()).isZero(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void unknownBookingReturns404() throws Exception { + mvc.perform(post("/admin/bookings/{id}/confirm", 999999).with(csrf())) + .andExpect(status().isNotFound()); + } + + // ---- Giữ bộ lọc sau khi thao tác ---- + + @Test + @WithMockUser(roles = "ADMIN") + void keepsFilterAfterStatusChange() throws Exception { + Booking booking = saveBooking(BookingStatus.PENDING, 2); + + mvc.perform(post("/admin/bookings/{id}/confirm", booking.getId()).with(csrf()) + .param("status", "PENDING")) + .andExpect(redirectedUrl("/admin/bookings?status=PENDING")); + } + + // ---- Bảo vệ ---- + + @Test + @WithMockUser(roles = "ADMIN") + void changeWithoutCsrfIsRejected() throws Exception { + Booking booking = saveBooking(BookingStatus.PENDING, 2); + + mvc.perform(post("/admin/bookings/{id}/confirm", booking.getId())) + .andExpect(status().isForbidden()); + + assertThat(bookings.findById(booking.getId()).orElseThrow().getStatus()) + .isEqualTo(BookingStatus.PENDING); + } + + @Test + @WithMockUser(roles = "USER") + void normalUserCannotSeeBookings() throws Exception { + mvc.perform(get("/admin/bookings")) + .andExpect(redirectedUrl("/admin/login?denied")); + } + + @Test + void anonymousIsRedirectedToLogin() throws Exception { + mvc.perform(get("/admin/bookings")) + .andExpect(redirectedUrl("/admin/login")); + } + + // Đơn của khách khác vẫn phải hiện: admin xem toàn bộ, không giới hạn theo user. + @Test + @WithMockUser(roles = "ADMIN") + void showsBookingsOfAllCustomers() throws Exception { + Booking a = saveBooking(BookingStatus.PENDING, 1); + Booking b = saveBooking(BookingStatus.PENDING, 1); + + mvc.perform(get("/admin/bookings")) + .andExpect(model().attribute("total", 2L)) + .andExpect(content().string(containsString(a.getCode()))) + .andExpect(content().string(containsString(b.getCode()))) + .andExpect(content().string(not(containsString("Chưa có đơn đặt nào")))); + } + + private Booking saveBooking(BookingStatus status, int adults) { + User user = new User(); + user.setFullName("Khách " + UUID.randomUUID().toString().substring(0, 4)); + user.setEmail(UUID.randomUUID() + "@example.com"); + user.setPassword("x"); + user.setRole(Role.USER); + user = users.save(user); + + ContactInfo contact = new ContactInfo(); + contact.setFullName(user.getFullName()); + contact.setEmail(user.getEmail()); + contact.setPhone("0900000000"); + + Booking booking = new Booking(); + booking.setUser(user); + booking.setTour(tour); + booking.setDeparture(departure); + booking.setCode("TG-2026-" + UUID.randomUUID().toString().substring(0, 6)); + booking.setAdults(adults); + booking.setChildren(0); + booking.setTotalPrice(new BigDecimal("4500000").multiply(BigDecimal.valueOf(adults))); + booking.setStatus(status); + booking.setContact(contact); + return bookings.save(booking); + } +} From 5a4fe822783a600aef8b147b6cc2896d407ab323 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 15:39:44 +0700 Subject: [PATCH 06/23] CRUD diem den trong khu quan tri Day du them/sua/xoa mem/thung rac/khoi phuc + upload anh, cung mo hinh voi tour. Khac tour mot cho quan trong: diem den CO chan xoa khi con tour. Xoa mem khong cat quan he, nen nhung tour do se tro toi mot diem den vo hinh va bo loc theo diem den o API cong khai ra ket qua ky quac. Nut Xoa bi vo hieu kem tooltip "Con N tour thuoc diem den nay", va service chan lai lan nua. Tour da xoa mem khong tinh la "con tour". So tour lay ngay trong truy van danh sach (AdminDestinationView) thay vi dem lai cho tung dong - 20 dong se thanh 20 truy van phu. Truy van nhan san pattern LIKE ('%' khi khong loc) thay vi ":keyword is null or ...": Postgres khong suy duoc kieu cua tham so null nen bind thanh bytea va nem "function lower(bytea) does not exist". H2 trong test lai chay binh thuong, nen loi kieu nay chi lo ra o moi truong that. Pattern co escape \, %, _ de go "50%" vao o tim kiem khong bien thanh "khop tat ca". Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/AdminDestinationController.java | 163 ++++++++++ .../tripgo/admin/AdminDestinationRow.java | 14 + .../demo/tripgo/admin/DestinationForm.java | 31 ++ .../repository/AdminDestinationView.java | 19 ++ .../repository/DestinationRepository.java | 38 +++ .../tripgo/repository/TourRepository.java | 6 + .../service/DestinationAdminService.java | 212 ++++++++++++ src/main/resources/static/css/admin.css | 34 ++ .../templates/admin/destinations/form.html | 66 ++++ .../templates/admin/destinations/list.html | 78 +++++ .../templates/admin/destinations/trash.html | 58 ++++ .../AdminDestinationIntegrationTest.java | 306 ++++++++++++++++++ 12 files changed, 1025 insertions(+) create mode 100644 src/main/java/demo/tripgo/admin/AdminDestinationController.java create mode 100644 src/main/java/demo/tripgo/admin/AdminDestinationRow.java create mode 100644 src/main/java/demo/tripgo/admin/DestinationForm.java create mode 100644 src/main/java/demo/tripgo/repository/AdminDestinationView.java create mode 100644 src/main/java/demo/tripgo/service/DestinationAdminService.java create mode 100644 src/main/resources/templates/admin/destinations/form.html create mode 100644 src/main/resources/templates/admin/destinations/list.html create mode 100644 src/main/resources/templates/admin/destinations/trash.html create mode 100644 src/test/java/demo/tripgo/admin/AdminDestinationIntegrationTest.java diff --git a/src/main/java/demo/tripgo/admin/AdminDestinationController.java b/src/main/java/demo/tripgo/admin/AdminDestinationController.java new file mode 100644 index 0000000..243becc --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminDestinationController.java @@ -0,0 +1,163 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Destination; +import demo.tripgo.exception.InvalidRequestParameterException; +import demo.tripgo.service.DestinationAdminService; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/admin/destinations") +public class AdminDestinationController { + + private static final int PAGE_SIZE = 20; + + private final DestinationAdminService destinationAdminService; + + public AdminDestinationController(DestinationAdminService destinationAdminService) { + this.destinationAdminService = destinationAdminService; + } + + @GetMapping + public String list( + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "1") int page, + Model model + ) { + Page destinations = + destinationAdminService.list(q, Math.max(page, 1), PAGE_SIZE); + + model.addAttribute("activeMenu", "destinations"); + model.addAttribute("pageHeading", "Điểm đến"); + model.addAttribute("destinations", destinations.getContent()); + model.addAttribute("q", q); + model.addAttribute("currentPage", destinations.getNumber() + 1); + model.addAttribute("totalPages", Math.max(destinations.getTotalPages(), 1)); + model.addAttribute("total", destinations.getTotalElements()); + return "admin/destinations/list"; + } + + @GetMapping("/trash") + public String trash(@RequestParam(defaultValue = "1") int page, Model model) { + Page deleted = + destinationAdminService.listDeleted(Math.max(page, 1), PAGE_SIZE); + + model.addAttribute("activeMenu", "destinations"); + model.addAttribute("pageHeading", "Thùng rác điểm đến"); + model.addAttribute("destinations", deleted.getContent()); + model.addAttribute("currentPage", deleted.getNumber() + 1); + model.addAttribute("totalPages", Math.max(deleted.getTotalPages(), 1)); + model.addAttribute("total", deleted.getTotalElements()); + return "admin/destinations/trash"; + } + + // ---- Tạo / sửa ---- + + @GetMapping("/new") + public String createForm(Model model) { + prepareForm(model, new DestinationForm(), "Thêm điểm đến"); + return "admin/destinations/form"; + } + + @PostMapping + public String create( + @Valid @ModelAttribute("form") DestinationForm form, + BindingResult binding, + Model model, + RedirectAttributes redirect + ) { + if (binding.hasErrors()) { + prepareForm(model, form, "Thêm điểm đến"); + return "admin/destinations/form"; + } + try { + Destination saved = destinationAdminService.create(form); + flash(redirect, "Đã tạo điểm đến \"" + saved.getName() + "\"", "success"); + } catch (InvalidRequestParameterException exception) { + return rejectUpload(form, binding, model, exception, "Thêm điểm đến"); + } + return "redirect:/admin/destinations"; + } + + @GetMapping("/{id}/edit") + public String editForm(@PathVariable Long id, Model model) { + prepareForm(model, destinationAdminService.loadForm(id), "Sửa điểm đến"); + return "admin/destinations/form"; + } + + @PostMapping("/{id}") + public String update( + @PathVariable Long id, + @Valid @ModelAttribute("form") DestinationForm form, + BindingResult binding, + Model model, + RedirectAttributes redirect + ) { + if (binding.hasErrors()) { + form.setId(id); + prepareForm(model, form, "Sửa điểm đến"); + return "admin/destinations/form"; + } + try { + Destination saved = destinationAdminService.update(id, form); + flash(redirect, "Đã lưu điểm đến \"" + saved.getName() + "\"", "success"); + } catch (InvalidRequestParameterException exception) { + form.setId(id); + return rejectUpload(form, binding, model, exception, "Sửa điểm đến"); + } + return "redirect:/admin/destinations"; + } + + // ---- Xoá mềm / khôi phục ---- + + @PostMapping("/{id}/delete") + public String delete(@PathVariable Long id, RedirectAttributes redirect) { + try { + String name = destinationAdminService.softDelete(id); + flash(redirect, "Đã chuyển \"" + name + "\" vào thùng rác", "success"); + } catch (InvalidRequestParameterException exception) { + // Còn tour thuộc điểm đến này: không phải lỗi hệ thống, báo lại ngay trên danh sách. + flash(redirect, exception.getMessage(), "error"); + } + return "redirect:/admin/destinations"; + } + + @PostMapping("/{id}/restore") + public String restore(@PathVariable Long id, RedirectAttributes redirect) { + flash(redirect, "Đã khôi phục \"" + destinationAdminService.restore(id) + "\"", "success"); + return "redirect:/admin/destinations/trash"; + } + + private String rejectUpload( + DestinationForm form, + BindingResult binding, + Model model, + InvalidRequestParameterException exception, + String heading + ) { + binding.reject("upload.invalid", exception.getMessage()); + prepareForm(model, form, heading); + return "admin/destinations/form"; + } + + private void prepareForm(Model model, DestinationForm form, String heading) { + model.addAttribute("form", form); + model.addAttribute("activeMenu", "destinations"); + model.addAttribute("pageHeading", heading); + } + + private void flash(RedirectAttributes redirect, String message, String type) { + redirect.addFlashAttribute("flashMessage", message); + redirect.addFlashAttribute("flashType", type); + } +} diff --git a/src/main/java/demo/tripgo/admin/AdminDestinationRow.java b/src/main/java/demo/tripgo/admin/AdminDestinationRow.java new file mode 100644 index 0000000..7da7255 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminDestinationRow.java @@ -0,0 +1,14 @@ +package demo.tripgo.admin; + +import java.time.LocalDateTime; + +// Một dòng trong bảng điểm đến. tourCount cho admin biết trước có xoá được hay không. +public record AdminDestinationRow( + Long id, + String name, + String slug, + String image, + long tourCount, + LocalDateTime deletedAt +) { +} diff --git a/src/main/java/demo/tripgo/admin/DestinationForm.java b/src/main/java/demo/tripgo/admin/DestinationForm.java new file mode 100644 index 0000000..bfcb9bb --- /dev/null +++ b/src/main/java/demo/tripgo/admin/DestinationForm.java @@ -0,0 +1,31 @@ +package demo.tripgo.admin; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +// Form tạo/sửa điểm đến. Cùng quy ước với TourForm: class có getter/setter để Thymeleaf bind +// được, và ảnh dùng cặp (URL hiện tại ở hidden field) + (file mới) + (cờ gỡ ảnh). +@Getter +@Setter +public class DestinationForm { + + private Long id; + + @NotBlank(message = "Tên điểm đến không được để trống") + @Size(max = 150, message = "Tên điểm đến không được quá 150 ký tự") + private String name; + + // Bỏ trống -> tự sinh từ tên. Cho sửa tay vì slug là tham số lọc công khai (?destination=da-nang). + @Size(max = 150, message = "Slug không được quá 150 ký tự") + private String slug; + + @Size(max = 500, message = "Đường dẫn ảnh không được quá 500 ký tự") + private String image; + + private MultipartFile imageFile; + + private boolean removeImage; +} diff --git a/src/main/java/demo/tripgo/repository/AdminDestinationView.java b/src/main/java/demo/tripgo/repository/AdminDestinationView.java new file mode 100644 index 0000000..97b404e --- /dev/null +++ b/src/main/java/demo/tripgo/repository/AdminDestinationView.java @@ -0,0 +1,19 @@ +package demo.tripgo.repository; + +import java.time.LocalDateTime; + +// Projection cho bảng điểm đến ở khu quản trị: lấy kèm số tour ngay trong truy vấn danh sách, +// thay vì đếm lại cho từng dòng (20 dòng = 20 truy vấn phụ). +public interface AdminDestinationView { + Long getId(); + + String getName(); + + String getSlug(); + + String getImage(); + + long getTourCount(); + + LocalDateTime getDeletedAt(); +} diff --git a/src/main/java/demo/tripgo/repository/DestinationRepository.java b/src/main/java/demo/tripgo/repository/DestinationRepository.java index 8b5fcc6..6ef19ef 100644 --- a/src/main/java/demo/tripgo/repository/DestinationRepository.java +++ b/src/main/java/demo/tripgo/repository/DestinationRepository.java @@ -1,6 +1,8 @@ package demo.tripgo.repository; import demo.tripgo.entity.Destination; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -18,6 +20,42 @@ public interface DestinationRepository extends JpaRepository @Query("select d from Destination d where d.id = :id and d.deletedAt is null") Optional findActiveById(@Param("id") Long id); + // Danh sách cho khu quản trị, lấy kèm số tour ngay trong truy vấn. Điều kiện deletedAt của + // Tour nằm trong ON (không phải WHERE) để điểm đến chưa có tour nào vẫn xuất hiện. + // + // Nhận sẵn pattern LIKE ('%' khi không lọc) thay vì ":keyword is null or ...": Postgres không + // suy được kiểu của tham số null nên bind thành bytea và ném "function lower(bytea) does not + // exist". H2 trong test lại chạy bình thường, nên lỗi kiểu này chỉ lộ ra ở môi trường thật. + @Query(value = """ + select d.id as id, d.name as name, d.slug as slug, d.image as image, + count(t.id) as tourCount, d.deletedAt as deletedAt + from Destination d + left join Tour t on t.destination = d and t.deletedAt is null + where d.deletedAt is null + and lower(d.name) like lower(:pattern) escape '\\' + group by d.id, d.name, d.slug, d.image, d.deletedAt + """, + countQuery = """ + select count(d) from Destination d + where d.deletedAt is null + and lower(d.name) like lower(:pattern) escape '\\' + """) + Page findActiveWithTourCount( + @Param("pattern") String pattern, Pageable pageable); + + // Thùng rác: điểm đến đã xoá mềm thì theo định nghĩa không còn tour nào, nên tourCount = 0. + @Query(value = """ + select d.id as id, d.name as name, d.slug as slug, d.image as image, + 0L as tourCount, d.deletedAt as deletedAt + from Destination d + where d.deletedAt is not null + """, + countQuery = "select count(d) from Destination d where d.deletedAt is not null") + Page findDeletedForAdmin(Pageable pageable); + + // Slug unique tính trên toàn bảng, kể cả hàng đã xoá mềm -> không lọc deletedAt ở đây. + boolean existsBySlugAndIdNot(String slug, Long id); + // Đếm số tour cho từng điểm đến trong một truy vấn (left join để điểm đến 0 tour vẫn xuất hiện). // Điều kiện deletedAt của Tour nằm trong ON chứ không phải WHERE: đặt ở WHERE sẽ biến left join // thành inner join và làm rớt mất những điểm đến chưa có tour nào. diff --git a/src/main/java/demo/tripgo/repository/TourRepository.java b/src/main/java/demo/tripgo/repository/TourRepository.java index 4955acf..3a9a902 100644 --- a/src/main/java/demo/tripgo/repository/TourRepository.java +++ b/src/main/java/demo/tripgo/repository/TourRepository.java @@ -43,6 +43,12 @@ public interface TourRepository extends JpaRepository, JpaSpecificat countQuery = "select count(t) from Tour t where t.deletedAt is not null") Page findDeleted(Pageable pageable); + // Tổng số tour đang bán (không tính tour trong thùng rác) — số liệu cho dashboard. + long countByDeletedAtIsNull(); + + // Số tour còn sống thuộc một điểm đến: dùng để chặn xoá điểm đến đang có tour. + long countByDestinationIdAndDeletedAtIsNull(Long destinationId); + // Slug là unique trên TOÀN bảng, kể cả hàng đã xoá mềm -> phải kiểm tra không lọc deletedAt. boolean existsBySlugAndIdNot(String slug, Long id); diff --git a/src/main/java/demo/tripgo/service/DestinationAdminService.java b/src/main/java/demo/tripgo/service/DestinationAdminService.java new file mode 100644 index 0000000..5bd8977 --- /dev/null +++ b/src/main/java/demo/tripgo/service/DestinationAdminService.java @@ -0,0 +1,212 @@ +package demo.tripgo.service; + +import demo.tripgo.admin.AdminDestinationRow; +import demo.tripgo.admin.DestinationForm; +import demo.tripgo.entity.Destination; +import demo.tripgo.exception.InvalidRequestParameterException; +import demo.tripgo.exception.ResourceNotFoundException; +import demo.tripgo.repository.AdminDestinationView; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +// CRUD điểm đến cho khu quản trị. Cùng mô hình với TourAdminService: xoá mềm + thùng rác, +// slug được "trả lại" khi xoá để tên cũ dùng lại được. +@Service +public class DestinationAdminService { + + private static final String DELETED_SLUG_MARKER = "-deleted-"; + private static final int SLUG_MAX_LENGTH = 150; + + private final DestinationRepository destinationRepository; + private final TourRepository tourRepository; + private final FileStorageService fileStorageService; + + public DestinationAdminService( + DestinationRepository destinationRepository, + TourRepository tourRepository, + FileStorageService fileStorageService + ) { + this.destinationRepository = destinationRepository; + this.tourRepository = tourRepository; + this.fileStorageService = fileStorageService; + } + + @Transactional(readOnly = true) + public Page list(String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page - 1, size, Sort.by("name")); + return destinationRepository.findActiveWithTourCount(likePattern(keyword), pageable) + .map(this::toRow); + } + + // Không lọc -> '%' khớp mọi tên. Escape \, %, _ để chúng là ký tự thường chứ không phải + // wildcard: gõ "50%" vào ô tìm kiếm không được biến thành "khớp tất cả". + private String likePattern(String keyword) { + if (keyword == null || keyword.isBlank()) { + return "%"; + } + String escaped = keyword.trim() + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + return "%" + escaped + "%"; + } + + @Transactional(readOnly = true) + public Page listDeleted(int page, int size) { + Pageable pageable = PageRequest.of(page - 1, size, + Sort.by(Sort.Direction.DESC, "deletedAt")); + return destinationRepository.findDeletedForAdmin(pageable).map(this::toRow); + } + + @Transactional(readOnly = true) + public DestinationForm loadForm(Long id) { + Destination destination = destinationRepository.findActiveById(id) + .orElseThrow(() -> new ResourceNotFoundException("điểm đến")); + + DestinationForm form = new DestinationForm(); + form.setId(destination.getId()); + form.setName(destination.getName()); + form.setSlug(destination.getSlug()); + form.setImage(destination.getImage()); + return form; + } + + @Transactional + public Destination create(DestinationForm form) { + Destination destination = new Destination(); + apply(form, destination); + return destinationRepository.save(destination); + } + + @Transactional + public Destination update(Long id, DestinationForm form) { + Destination destination = destinationRepository.findActiveById(id) + .orElseThrow(() -> new ResourceNotFoundException("điểm đến")); + apply(form, destination); + return destinationRepository.save(destination); + } + + // Khác tour: điểm đến CÓ chặn xoá khi còn tour. Xoá mềm không cắt quan hệ, nên những tour đó + // sẽ trỏ tới một điểm đến vô hình và bộ lọc theo điểm đến ở API công khai ra kết quả kỳ quặc. + @Transactional + public String softDelete(Long id) { + Destination destination = destinationRepository.findActiveById(id) + .orElseThrow(() -> new ResourceNotFoundException("điểm đến")); + + long tourCount = tourRepository.countByDestinationIdAndDeletedAtIsNull(id); + if (tourCount > 0) { + throw new InvalidRequestParameterException( + "Không thể xoá \"%s\": còn %d tour thuộc điểm đến này".formatted( + destination.getName(), tourCount)); + } + + destination.setDeletedAt(LocalDateTime.now()); + destination.setSlug(releaseSlug(destination)); + destinationRepository.save(destination); + return destination.getName(); + } + + @Transactional + public String restore(Long id) { + Destination destination = destinationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("điểm đến")); + if (!destination.isDeleted()) { + return destination.getName(); + } + destination.setSlug(reclaimSlug(destination)); + destination.setDeletedAt(null); + destinationRepository.save(destination); + return destination.getName(); + } + + private void apply(DestinationForm form, Destination destination) { + destination.setName(form.getName().trim()); + destination.setSlug(uniqueSlug(form, destination)); + destination.setImage(resolveImage(form, destination)); + } + + // Thứ tự ưu tiên giống ảnh tour: file mới > yêu cầu gỡ > giữ nguyên ảnh cũ. + private String resolveImage(DestinationForm form, Destination destination) { + String current = destination.getImage(); + + String uploaded = fileStorageService.store(form.getImageFile()); + if (uploaded != null) { + fileStorageService.delete(current); + return uploaded; + } + if (form.isRemoveImage()) { + fileStorageService.delete(current); + return null; + } + return current != null ? current : blankToNull(form.getImage()); + } + + private String uniqueSlug(DestinationForm form, Destination destination) { + String base = form.getSlug() == null || form.getSlug().isBlank() + ? SlugGenerator.from(form.getName()) + : SlugGenerator.from(form.getSlug()); + if (base.isEmpty()) { + base = "diem-den"; + } + + Long id = destination.getId(); + String candidate = base; + int suffix = 2; + while (destinationRepository.existsBySlugAndIdNot(candidate, id == null ? -1L : id)) { + candidate = base + "-" + suffix++; + } + return candidate; + } + + private String releaseSlug(Destination destination) { + if (destination.getSlug() == null || destination.getSlug().contains(DELETED_SLUG_MARKER)) { + return destination.getSlug(); + } + String suffix = DELETED_SLUG_MARKER + destination.getId(); + String base = destination.getSlug(); + int room = SLUG_MAX_LENGTH - suffix.length(); + if (base.length() > room) { + base = base.substring(0, room); + } + return base + suffix; + } + + private String reclaimSlug(Destination destination) { + String slug = destination.getSlug(); + if (slug == null) { + return null; + } + String suffix = DELETED_SLUG_MARKER + destination.getId(); + if (!slug.endsWith(suffix)) { + return slug; + } + String original = slug.substring(0, slug.length() - suffix.length()); + if (original.isEmpty() + || destinationRepository.existsBySlugAndIdNot(original, destination.getId())) { + return slug; + } + return original; + } + + private AdminDestinationRow toRow(AdminDestinationView view) { + return new AdminDestinationRow( + view.getId(), + view.getName(), + view.getSlug(), + view.getImage(), + view.getTourCount(), + view.getDeletedAt()); + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index 6dee4d2..3e52a2b 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -727,3 +727,37 @@ color: #6b7280; font-size: 13px; } + +/* Ảnh thu nhỏ trong bảng danh sách. */ +.row-thumb { + width: 64px; + height: 44px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--admin-border); + display: block; +} + +/* Nút bị vô hiệu (vd: xoá điểm đến còn tour) phải nhìn ra là bấm không được. */ +.btn-link:disabled { + color: #9ca3af; + cursor: not-allowed; + text-decoration: none; +} + +/* Ô số liệu bấm được thì phải trông như bấm được. */ +a.stat-card { + text-decoration: none; + color: inherit; + transition: border-color .15s, box-shadow .15s; +} + +a.stat-card:hover { + border-color: var(--admin-accent); + box-shadow: 0 1px 6px rgba(13, 110, 253, .12); +} + +.stat-note { + color: #9ca3af; + font-size: 12px; +} diff --git a/src/main/resources/templates/admin/destinations/form.html b/src/main/resources/templates/admin/destinations/form.html new file mode 100644 index 0000000..4dca23a --- /dev/null +++ b/src/main/resources/templates/admin/destinations/form.html @@ -0,0 +1,66 @@ + + + + Điểm đến + + +
+ + + +
+ +
+

Lỗi

+
+ +
+ + +

Lỗi

+
+ +
+ + +

+ Dùng làm tham số lọc công khai ?destination=da-nang. + Đổi slug sẽ làm hỏng các link cũ. +

+

Lỗi

+
+ +
+ + + +
+ Ảnh điểm đến hiện tại + +
+ + +

JPG, PNG, WEBP hoặc GIF, tối đa 5 MB.

+

Lỗi

+
+ +
+ + Huỷ +
+
+ +
+ + diff --git a/src/main/resources/templates/admin/destinations/list.html b/src/main/resources/templates/admin/destinations/list.html new file mode 100644 index 0000000..2f6018c --- /dev/null +++ b/src/main/resources/templates/admin/destinations/list.html @@ -0,0 +1,78 @@ + + + + Điểm đến · TripGo Admin + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ẢnhTênSlugSố tourThao tác
+ + — + Đà Nẵngda-nang0 + Sửa + +
+ +
+
Chưa có điểm đến nào
+ + + +
+ + diff --git a/src/main/resources/templates/admin/destinations/trash.html b/src/main/resources/templates/admin/destinations/trash.html new file mode 100644 index 0000000..003847e --- /dev/null +++ b/src/main/resources/templates/admin/destinations/trash.html @@ -0,0 +1,58 @@ + + + + Thùng rác điểm đến · TripGo Admin + + +
+ + + +

+ Điểm đến ở đây đã ẩn khỏi bộ lọc công khai. Chỉ điểm đến không còn tour nào mới xoá được, + nên khôi phục sẽ không làm tour nào bị ảnh hưởng. +

+ + + + + + + + + + + + + + + + + + + + + +
TênSlugXoá lúcThao tác
Đà Nẵngda-nang-deleted-3— +
+ +
+
Thùng rác trống
+ + + +
+ + diff --git a/src/test/java/demo/tripgo/admin/AdminDestinationIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminDestinationIntegrationTest.java new file mode 100644 index 0000000..0cdd16b --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminDestinationIntegrationTest.java @@ -0,0 +1,306 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Tour; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasProperty; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminDestinationIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired DestinationRepository destinations; + @Autowired TourRepository tours; + @Autowired CategoryRepository categories; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + + @BeforeEach + void setUp() { + // Dọn theo đúng thứ tự khoá ngoại: lớp test chạy trước có thể để lại đơn/ngày khởi hành + // trỏ vào tours, xoá tours trước sẽ vi phạm ràng buộc. + bookings.deleteAll(); + departures.deleteAll(); + tours.deleteAll(); + destinations.deleteAll(); + } + + // ---- Danh sách ---- + + @Test + @WithMockUser(roles = "ADMIN") + void listsDestinationsWithTourCount() throws Exception { + Destination daNang = saveDestination("Đà Nẵng", "da-nang"); + saveTour(daNang); + saveTour(daNang); + saveDestination("Sapa", "sapa"); + + mvc.perform(get("/admin/destinations")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/destinations/list")) + .andExpect(model().attribute("total", 2L)) + .andExpect(content().string(containsString("da-nang"))) + .andExpect(content().string(containsString("sapa"))); + } + + // Điểm đến chưa có tour vẫn phải xuất hiện (left join, không bị thành inner join). + @Test + @WithMockUser(roles = "ADMIN") + void destinationWithoutToursStillAppears() throws Exception { + saveDestination("Sapa", "sapa"); + + mvc.perform(get("/admin/destinations")) + .andExpect(model().attribute("total", 1L)) + .andExpect(content().string(containsString("sapa"))); + } + + // Tour đã xoá mềm không được tính vào số tour của điểm đến. + @Test + @WithMockUser(roles = "ADMIN") + void deletedTourIsNotCounted() throws Exception { + Destination daNang = saveDestination("Đà Nẵng", "da-nang"); + saveTour(daNang); + Tour gone = saveTour(daNang); + gone.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(gone); + + mvc.perform(get("/admin/destinations")) + .andExpect(content().string(containsString("da-nang"))); + + assertThat(tours.countByDestinationIdAndDeletedAtIsNull(daNang.getId())).isEqualTo(1); + } + + @Test + @WithMockUser(roles = "ADMIN") + void searchFiltersByName() throws Exception { + saveDestination("Đà Nẵng", "da-nang"); + saveDestination("Sapa", "sapa"); + + mvc.perform(get("/admin/destinations").param("q", "sapa")) + .andExpect(model().attribute("total", 1L)) + .andExpect(content().string(not(containsString("da-nang")))); + } + + // ---- Tạo / sửa ---- + + @Test + @WithMockUser(roles = "ADMIN") + void createsDestinationAndGeneratesSlug() throws Exception { + mvc.perform(post("/admin/destinations").with(csrf()).param("name", "Đà Nẵng")) + .andExpect(redirectedUrl("/admin/destinations")); + + Destination saved = destinations.findAll().getFirst(); + assertThat(saved.getName()).isEqualTo("Đà Nẵng"); + assertThat(saved.getSlug()).isEqualTo("da-nang"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void duplicateSlugGetsSuffix() throws Exception { + saveDestination("Đà Nẵng", "da-nang"); + + mvc.perform(post("/admin/destinations").with(csrf()).param("name", "Đà Nẵng")); + + assertThat(destinations.findAll()).extracting(Destination::getSlug) + .containsExactlyInAnyOrder("da-nang", "da-nang-2"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void emptyNameShowsFieldError() throws Exception { + mvc.perform(post("/admin/destinations").with(csrf()).param("name", "")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/destinations/form")) + .andExpect(model().attributeHasFieldErrors("form", "name")) + .andExpect(content().string(containsString("Tên điểm đến không được để trống"))); + + assertThat(destinations.count()).isZero(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void editFormIsPrefilled() throws Exception { + Destination daNang = saveDestination("Đà Nẵng", "da-nang"); + + mvc.perform(get("/admin/destinations/{id}/edit", daNang.getId())) + .andExpect(status().isOk()) + .andExpect(model().attribute("form", hasProperty("name", is("Đà Nẵng")))) + .andExpect(model().attribute("form", hasProperty("slug", is("da-nang")))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void updateChangesNameAndAppearsInPublicApi() throws Exception { + Destination daNang = saveDestination("Đà Nẵng", "da-nang"); + saveTour(daNang); + + mvc.perform(post("/admin/destinations/{id}", daNang.getId()).with(csrf()) + .param("name", "Đà Nẵng City") + .param("slug", "da-nang")) + .andExpect(redirectedUrl("/admin/destinations")); + + mvc.perform(get("/api/v1/destinations")) + .andExpect(content().string(containsString("Đà Nẵng City"))); + } + + // ---- Xoá: khác tour, điểm đến còn tour thì KHÔNG cho xoá ---- + + @Test + @WithMockUser(roles = "ADMIN") + void cannotDeleteDestinationThatStillHasTours() throws Exception { + Destination daNang = saveDestination("Đà Nẵng", "da-nang"); + saveTour(daNang); + + mvc.perform(post("/admin/destinations/{id}/delete", daNang.getId()).with(csrf())) + .andExpect(flash().attribute("flashType", "error")) + .andExpect(flash().attribute("flashMessage", containsString("còn 1 tour"))); + + assertThat(destinations.findActiveById(daNang.getId())).isPresent(); + } + + // Tour đã xoá mềm không tính là "còn tour" -> vẫn xoá được điểm đến. + @Test + @WithMockUser(roles = "ADMIN") + void canDeleteWhenRemainingToursAreSoftDeleted() throws Exception { + Destination daNang = saveDestination("Đà Nẵng", "da-nang"); + Tour gone = saveTour(daNang); + gone.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(gone); + + mvc.perform(post("/admin/destinations/{id}/delete", daNang.getId()).with(csrf())) + .andExpect(flash().attribute("flashType", "success")); + + assertThat(destinations.findActiveById(daNang.getId())).isEmpty(); + assertThat(destinations.findById(daNang.getId())).isPresent(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void deleteFreesSlugAndRestoreReclaimsIt() throws Exception { + Destination sapa = saveDestination("Sapa", "sapa"); + + mvc.perform(post("/admin/destinations/{id}/delete", sapa.getId()).with(csrf())); + assertThat(destinations.findById(sapa.getId()).orElseThrow().getSlug()) + .isEqualTo("sapa-deleted-" + sapa.getId()); + + mvc.perform(post("/admin/destinations/{id}/restore", sapa.getId()).with(csrf())) + .andExpect(redirectedUrl("/admin/destinations/trash")); + + Destination restored = destinations.findById(sapa.getId()).orElseThrow(); + assertThat(restored.getSlug()).isEqualTo("sapa"); + assertThat(restored.isDeleted()).isFalse(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void deletedDestinationDisappearsFromPublicApi() throws Exception { + Destination sapa = saveDestination("Sapa", "sapa"); + + mvc.perform(get("/api/v1/destinations")) + .andExpect(content().string(containsString("sapa"))); + + mvc.perform(post("/admin/destinations/{id}/delete", sapa.getId()).with(csrf())); + + mvc.perform(get("/api/v1/destinations")) + .andExpect(content().string(not(containsString("\"sapa\"")))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void trashListsOnlyDeleted() throws Exception { + saveDestination("Đà Nẵng", "da-nang"); + Destination sapa = saveDestination("Sapa", "sapa"); + mvc.perform(post("/admin/destinations/{id}/delete", sapa.getId()).with(csrf())); + + mvc.perform(get("/admin/destinations/trash")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Sapa"))) + .andExpect(content().string(not(containsString("da-nang")))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void emptyTrashSaysSo() throws Exception { + mvc.perform(get("/admin/destinations/trash")) + .andExpect(content().string(containsString("Thùng rác trống"))); + } + + // ---- Bảo vệ ---- + + @Test + @WithMockUser(roles = "ADMIN") + void deleteWithoutCsrfIsRejected() throws Exception { + Destination sapa = saveDestination("Sapa", "sapa"); + + mvc.perform(post("/admin/destinations/{id}/delete", sapa.getId())) + .andExpect(status().isForbidden()); + + assertThat(destinations.findActiveById(sapa.getId())).isPresent(); + } + + @Test + @WithMockUser(roles = "USER") + void normalUserIsDenied() throws Exception { + mvc.perform(get("/admin/destinations")) + .andExpect(redirectedUrl("/admin/login?denied")); + } + + private Destination saveDestination(String name, String slug) { + Destination destination = new Destination(); + destination.setName(name); + destination.setSlug(slug); + return destinations.save(destination); + } + + private Tour saveTour(Destination destination) { + Category category = categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + + Tour tour = new Tour(); + tour.setTitle("Tour " + UUID.randomUUID().toString().substring(0, 4)); + tour.setSlug(UUID.randomUUID().toString()); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("1000000")); + tour.setMaxGuests(20); + return tours.save(tour); + } +} From 9995c3ec7189e3fd82e69007ec784e813c2c9dc9 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 15:40:06 +0700 Subject: [PATCH 07/23] Dashboard: noi 4 o so lieu vao du lieu that Tong tour dang ban (khong tinh thung rac), don cho xac nhan, don thang nay, doanh thu thang. "Thang nay" tinh tu 00:00 ngay 1 cua thang hien tai, khong phai 30 ngay gan nhat - do la cach nguoi doc bao cao hieu cum tu nay. Doanh thu bo qua don da huy, va dung coalesce de thang khong co don nao tra 0 thay vi null (null se lam template no khi format so). Ba o dau la link sang danh sach da loc san: nhin thay "12 don cho xac nhan" thi viec tiep theo luon la mo danh sach do ra. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/AdminDashboardController.java | 11 +- .../demo/tripgo/admin/DashboardStats.java | 12 ++ .../tripgo/repository/BookingRepository.java | 13 ++ .../demo/tripgo/service/DashboardService.java | 36 ++++ .../resources/templates/admin/dashboard.html | 27 +-- .../admin/AdminDashboardIntegrationTest.java | 188 ++++++++++++++++++ 6 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 src/main/java/demo/tripgo/admin/DashboardStats.java create mode 100644 src/main/java/demo/tripgo/service/DashboardService.java create mode 100644 src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java diff --git a/src/main/java/demo/tripgo/admin/AdminDashboardController.java b/src/main/java/demo/tripgo/admin/AdminDashboardController.java index 3dd5bba..f25b777 100644 --- a/src/main/java/demo/tripgo/admin/AdminDashboardController.java +++ b/src/main/java/demo/tripgo/admin/AdminDashboardController.java @@ -1,17 +1,24 @@ package demo.tripgo.admin; +import demo.tripgo.service.DashboardService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; -// Khung dashboard của giai đoạn này: bố cục và điều hướng đã chạy, 4 ô số liệu sẽ được nối vào -// dữ liệu thật ở giai đoạn sau (tổng tour, đơn chờ xác nhận, đơn tháng này, doanh thu tháng). @Controller public class AdminDashboardController { + private final DashboardService dashboardService; + + public AdminDashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + @GetMapping("/admin") public String dashboard(Model model) { model.addAttribute("activeMenu", "dashboard"); + model.addAttribute("pageHeading", "Dashboard"); + model.addAttribute("stats", dashboardService.stats()); return "admin/dashboard"; } } diff --git a/src/main/java/demo/tripgo/admin/DashboardStats.java b/src/main/java/demo/tripgo/admin/DashboardStats.java new file mode 100644 index 0000000..cc156c1 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/DashboardStats.java @@ -0,0 +1,12 @@ +package demo.tripgo.admin; + +import java.math.BigDecimal; + +// Bốn ô số liệu trên dashboard. +public record DashboardStats( + long totalTours, + long pendingBookings, + long bookingsThisMonth, + BigDecimal revenueThisMonth +) { +} diff --git a/src/main/java/demo/tripgo/repository/BookingRepository.java b/src/main/java/demo/tripgo/repository/BookingRepository.java index 74194f5..5a51131 100644 --- a/src/main/java/demo/tripgo/repository/BookingRepository.java +++ b/src/main/java/demo/tripgo/repository/BookingRepository.java @@ -6,6 +6,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -35,6 +37,17 @@ public interface BookingRepository extends JpaRepository { long countByStatus(BookingStatus status); + long countByCreatedAtGreaterThanEqual(java.time.LocalDateTime from); + + // Doanh thu tháng: chỉ tính đơn CHƯA huỷ. coalesce để tháng không có đơn nào trả 0 thay vì null. + @Query(""" + select coalesce(sum(b.totalPrice), 0) + from Booking b + where b.status <> demo.tripgo.entity.BookingStatus.CANCELLED + and b.createdAt >= :from + """) + java.math.BigDecimal sumRevenueSince(@Param("from") java.time.LocalDateTime from); + // Số đơn còn hiệu lực của một tour: dùng để cảnh báo admin trước khi xoá. long countByTourIdAndStatusNot(Long tourId, BookingStatus status); diff --git a/src/main/java/demo/tripgo/service/DashboardService.java b/src/main/java/demo/tripgo/service/DashboardService.java new file mode 100644 index 0000000..7c18873 --- /dev/null +++ b/src/main/java/demo/tripgo/service/DashboardService.java @@ -0,0 +1,36 @@ +package demo.tripgo.service; + +import demo.tripgo.admin.DashboardStats; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.TourRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Service +public class DashboardService { + + private final TourRepository tourRepository; + private final BookingRepository bookingRepository; + + public DashboardService(TourRepository tourRepository, BookingRepository bookingRepository) { + this.tourRepository = tourRepository; + this.bookingRepository = bookingRepository; + } + + @Transactional(readOnly = true) + public DashboardStats stats() { + // "Tháng này" tính từ 00:00 ngày 1 của tháng hiện tại, không phải 30 ngày gần nhất — + // đó là cách người đọc báo cáo hiểu cụm từ này. + LocalDateTime startOfMonth = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + + return new DashboardStats( + tourRepository.countByDeletedAtIsNull(), + bookingRepository.countByStatus(BookingStatus.PENDING), + bookingRepository.countByCreatedAtGreaterThanEqual(startOfMonth), + bookingRepository.sumRevenueSince(startOfMonth)); + } +} diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 2e4b69d..d99970d 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -8,23 +8,26 @@

Dashboard

-
-
- Tổng tour - — -
- - + 0 +
Doanh thu tháng - — + 0 đ + Không tính đơn đã huỷ
diff --git a/src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java new file mode 100644 index 0000000..c015ebb --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java @@ -0,0 +1,188 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.ContactInfo; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.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.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminDashboardIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + @Autowired UserRepository users; + + private Destination destination; + private Tour tour; + private Departure departure; + + @BeforeEach + void setUp() { + bookings.deleteAll(); + departures.deleteAll(); + tours.deleteAll(); + destinations.deleteAll(); + users.deleteAll(); + + destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + tour = saveTour(); + departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(0); + departure = departures.save(departure); + } + + @Test + @WithMockUser(roles = "ADMIN") + void emptySystemShowsZeros() throws Exception { + // Xoá ngày khởi hành trước: tour_departures tham chiếu tours bằng khoá ngoại. + departures.deleteAll(); + tours.deleteAll(); + + DashboardStats stats = statsFrom(mvc.perform(get("/admin")).andExpect(status().isOk())); + + assertThat(stats.totalTours()).isZero(); + assertThat(stats.pendingBookings()).isZero(); + assertThat(stats.bookingsThisMonth()).isZero(); + // coalesce: không có đơn nào vẫn phải ra 0, không được null. + assertThat(stats.revenueThisMonth()).isEqualByComparingTo("0"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void countsToursExcludingTrash() throws Exception { + saveTour(); + Tour gone = saveTour(); + gone.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(gone); + + // setUp tạo 1 tour, test thêm 2 (1 bị xoá mềm) -> còn 2 tour đang bán. + assertThat(statsFrom(mvc.perform(get("/admin"))).totalTours()).isEqualTo(2); + } + + @Test + @WithMockUser(roles = "ADMIN") + void countsPendingBookingsOnly() throws Exception { + saveBooking(BookingStatus.PENDING, "1000000"); + saveBooking(BookingStatus.PENDING, "1000000"); + saveBooking(BookingStatus.CONFIRMED, "1000000"); + + DashboardStats stats = statsFrom(mvc.perform(get("/admin"))); + + assertThat(stats.pendingBookings()).isEqualTo(2); + assertThat(stats.bookingsThisMonth()).isEqualTo(3); + } + + // Doanh thu bỏ qua đơn đã huỷ: 2.000.000 + 3.000.000, đơn huỷ 9.000.000 không tính. + @Test + @WithMockUser(roles = "ADMIN") + void revenueExcludesCancelledBookings() throws Exception { + saveBooking(BookingStatus.PENDING, "2000000"); + saveBooking(BookingStatus.CONFIRMED, "3000000"); + saveBooking(BookingStatus.CANCELLED, "9000000"); + + assertThat(statsFrom(mvc.perform(get("/admin"))).revenueThisMonth()) + .isEqualByComparingTo("5000000"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void dashboardLinksToFilteredBookingList() throws Exception { + mvc.perform(get("/admin")) + .andExpect(content().string(containsString("/admin/bookings?status=PENDING"))); + } + + private DashboardStats statsFrom(ResultActions result) throws Exception { + Object stats = result.andReturn().getModelAndView().getModel().get("stats"); + assertThat(stats).isInstanceOf(DashboardStats.class); + return (DashboardStats) stats; + } + + private Tour saveTour() { + Category category = categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + + Tour t = new Tour(); + t.setTitle("Tour " + UUID.randomUUID().toString().substring(0, 4)); + t.setSlug(UUID.randomUUID().toString()); + t.setDestination(destination); + t.setCategory(category); + t.setDurationDays(3); + t.setPrice(new BigDecimal("1000000")); + t.setMaxGuests(20); + return tours.save(t); + } + + private void saveBooking(BookingStatus status, String total) { + User user = new User(); + user.setFullName("Khách"); + user.setEmail(UUID.randomUUID() + "@example.com"); + user.setPassword("x"); + user.setRole(Role.USER); + user = users.save(user); + + ContactInfo contact = new ContactInfo(); + contact.setFullName("Khách"); + contact.setEmail("k@example.com"); + contact.setPhone("0900000000"); + + Booking booking = new Booking(); + booking.setUser(user); + booking.setTour(tour); + booking.setDeparture(departure); + booking.setCode("TG-" + UUID.randomUUID().toString().substring(0, 8)); + booking.setAdults(2); + booking.setChildren(0); + booking.setTotalPrice(new BigDecimal(total)); + booking.setStatus(status); + booking.setContact(contact); + bookings.save(booking); + } +} From 26ed4deb15c1705ff482e3e84dd171cd3d72b962 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 15:40:06 +0700 Subject: [PATCH 08/23] Hien anh tour o danh sach quan tri + script gan anh thay link seed chet Bang danh sach tour truoc gio khong co cot anh nao (wireframe 7.3 khong ve), nen thumbnail dung den may cung khong thay gi. Them cot Anh, hien dau gach khi tour chua co anh. scripts/set-placeholder-images.sql: du lieu mau tro toi https://img/.jpg - ten mien khong ton tai nen toan bo 228 anh trong ung dung deu vo. Script tro chung ve file that trong thu muc uploads. Chi dung nhung dong co link chet nen chay lai nhieu lan khong hong them gi. Luu y khi dung script: nhieu ban ghi cung tro toi MOT file. Sua mot tour roi tai anh moi len se xoa file cu (resolveThumbnail), keo theo cac tour khac mat anh. Chap nhan duoc voi du lieu demo, khong dung cho du lieu that. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/set-placeholder-images.sql | 35 +++++++++++++++++++ .../resources/templates/admin/tours/list.html | 8 ++++- .../admin/AdminTourListIntegrationTest.java | 15 ++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 scripts/set-placeholder-images.sql diff --git a/scripts/set-placeholder-images.sql b/scripts/set-placeholder-images.sql new file mode 100644 index 0000000..e5ce62c --- /dev/null +++ b/scripts/set-placeholder-images.sql @@ -0,0 +1,35 @@ +-- Gán ảnh đã tải lên làm ảnh hiển thị, thay cho các link seed chết (https://img/...). +-- +-- Bối cảnh: dữ liệu mẫu ban đầu trỏ tới https://img/.jpg — tên miền không tồn tại, nên +-- mọi ảnh trong ứng dụng đều vỡ. Script này trỏ chúng về file thật trong thư mục uploads. +-- +-- Chạy: psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/set-placeholder-images.sql +-- +-- Yêu cầu: các file tương ứng phải nằm sẵn trong thư mục UPLOAD_DIR (mặc định ./uploads), +-- vì ứng dụng phục vụ chúng qua ResourceHandler ánh xạ /uploads/** -> thư mục đó. + +BEGIN; + +-- 1) Ảnh đại diện của tour (55 dòng) +UPDATE tours + SET thumbnail_url = '/uploads/dulich.jpg' + WHERE deleted_at IS NULL + AND (thumbnail_url IS NULL OR thumbnail_url LIKE 'https://img/%'); + +-- 2) Ảnh điểm đến (8 dòng) +UPDATE destinations + SET image = '/uploads/anh1.png' + WHERE deleted_at IS NULL + AND (image IS NULL OR image LIKE 'https://img/%'); + +-- 3) Ảnh gallery của tour (165 dòng) +UPDATE tour_images + SET url = '/uploads/anh2.webp' + WHERE url LIKE 'https://img/%'; + +COMMIT; + +-- Kiểm tra lại: không còn link https://img/ nào sót +SELECT 'tours' AS bang, count(*) FILTER (WHERE thumbnail_url LIKE 'https://img/%') AS con_link_hong FROM tours +UNION ALL SELECT 'destinations', count(*) FILTER (WHERE image LIKE 'https://img/%') FROM destinations +UNION ALL SELECT 'tour_images', count(*) FILTER (WHERE url LIKE 'https://img/%') FROM tour_images; diff --git a/src/main/resources/templates/admin/tours/list.html b/src/main/resources/templates/admin/tours/list.html index cba70a6..e9824c8 100644 --- a/src/main/resources/templates/admin/tours/list.html +++ b/src/main/resources/templates/admin/tours/list.html @@ -26,6 +26,7 @@

Tour Giá @@ -36,6 +37,11 @@

Tour 1 + + + — + Tên tour Điểm đến 0 @@ -51,7 +57,7 @@

Tour - Chưa có tour nào diff --git a/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java index 8cd5a7a..108ab48 100644 --- a/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java +++ b/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java @@ -78,6 +78,21 @@ void listsToursWithDestinationAndPrice() throws Exception { .andExpect(content().string(containsString("+ Thêm tour"))); } + // Cột ảnh: có thumbnail thì render , không có thì hiện dấu gạch chứ không để ô trống. + @Test + @WithMockUser(roles = "ADMIN") + void showsThumbnailColumn() throws Exception { + Tour withImage = saveTour("Tour có ảnh"); + withImage.setThumbnailUrl("/uploads/dulich.jpg"); + tours.saveAndFlush(withImage); + saveTour("Tour không ảnh"); + + mvc.perform(get("/admin/tours")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(" Date: Fri, 25 Sep 2026 16:21:17 +0700 Subject: [PATCH 09/23] Ha tang test chay Postgres that bang Testcontainers Bo test dang chay H2 con production chay Postgres, nen loi phuong ngu SQL khong bao gio lo ra luc test. Da co mot bug lot qua dung vi chuyen nay: truy van co tham so null khien Postgres bind thanh bytea roi nem "function lower(bytea) does not exist", trong khi toan bo test H2 van xanh. PostgresTestBase dung container postgres:16-alpine - trung phien ban voi docker-compose.yml de test chay dung thu production dung. Co y KHONG chuyen ca bo test sang Postgres: container nang hon H2 nhieu ma phan lon test chi kiem logic nghiep vu. Chi nhung test dung toi truy van viet tay moi ke thua lop nay. Ghi chu khi nang cap: Testcontainers 2.x doi ten module (postgresql -> testcontainers-postgresql), va spring-boot-dependencies khai testcontainers.version nhung khong import BOM nen phai tu import. pom cung them san spring-boot-starter-oauth2-client cho commit dang nhap Facebook phia sau; khong co client registration nao thi dependency nay khong kich hoat gi. Co-Authored-By: Claude Opus 5 (1M context) --- pom.xml | 38 +++++++++++++++++++ .../java/demo/tripgo/PostgresTestBase.java | 29 ++++++++++++++ .../resources/application-postgres-test.yaml | 17 +++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/test/java/demo/tripgo/PostgresTestBase.java create mode 100644 src/test/resources/application-postgres-test.yaml diff --git a/pom.xml b/pom.xml index a426c6a..991d8c3 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,20 @@ 21 + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + org.springframework.boot @@ -51,6 +65,11 @@ org.springframework.boot spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-oauth2-client + org.postgresql @@ -102,6 +121,25 @@ spring-boot-starter-webmvc-test test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + org.springframework.boot spring-boot-starter-test diff --git a/src/test/java/demo/tripgo/PostgresTestBase.java b/src/test/java/demo/tripgo/PostgresTestBase.java new file mode 100644 index 0000000..5f68599 --- /dev/null +++ b/src/test/java/demo/tripgo/PostgresTestBase.java @@ -0,0 +1,29 @@ +package demo.tripgo; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +// Nền cho các test phải chạy trên Postgres THẬT thay vì H2. +// +// Vì sao cần: H2 (kể cả ở MODE=PostgreSQL) chấp nhận nhiều thứ Postgres từ chối. Một bug đã lọt +// qua đúng vì chuyện này — truy vấn có tham số null khiến Postgres bind thành bytea rồi ném +// "function lower(bytea) does not exist", trong khi toàn bộ test H2 vẫn xanh. +// +// Không chuyển CẢ bộ test sang Postgres: container nặng hơn H2 nhiều, mà phần lớn test chỉ kiểm +// logic nghiệp vụ chứ không phụ thuộc phương ngữ SQL. Chỉ những test đụng tới truy vấn viết tay +// mới kế thừa lớp này. +// +// @Container tĩnh + không withReuse: container khởi động một lần cho cả class, dọn sau khi xong. +@Testcontainers +@ActiveProfiles("postgres-test") +public abstract class PostgresTestBase { + + @Container + @ServiceConnection + static final PostgreSQLContainer POSTGRES = + // Trùng phiên bản với docker-compose.yml để test chạy đúng thứ production dùng. + new PostgreSQLContainer<>("postgres:16-alpine"); +} diff --git a/src/test/resources/application-postgres-test.yaml b/src/test/resources/application-postgres-test.yaml new file mode 100644 index 0000000..013d272 --- /dev/null +++ b/src/test/resources/application-postgres-test.yaml @@ -0,0 +1,17 @@ +# Profile cho test chạy Postgres thật (xem PostgresTestBase). +# Datasource do @ServiceConnection của Testcontainers cấp, nên ở đây không khai url/username. +spring: + jpa: + hibernate: + # Mỗi class test dựng schema mới trên container riêng rồi bỏ đi. + ddl-auto: create-drop + +# Public test fixture only; never use this key outside tests. +jwt: + secret: test-only-jwt-signing-key-not-for-production-0123456789 + expiration: 86400000 + +ratelimit: + auth: + max-requests: 100000 + window-seconds: 60 From 56ceac0e36488c4d108b3ea776284d3a96c60d2f Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 16:21:17 +0700 Subject: [PATCH 10/23] Tim kiem khong dau, va khong xoa file anh con ban ghi khac dung Hai viec di chung mot commit vi cung sua TourRepository, DestinationRepository va hai service admin - tach ra thi commit o giua khong build duoc. 1) Tim kiem khong dau: go "da nang" phai ra "Da Nang". Truoc day ra 0 ket qua. Dung cot chuan hoa (search_text, search_name) thay vi unaccent() cua Postgres: extension do H2 trong test khong co va bat no can quyen superuser. SearchTextBackfill dien cot cho du lieu cu luc khoi dong, idempotent. Chuan hoa CHI bo dau va ha chu thuong, GIU NGUYEN moi ky tu khac. Ban dau dung SlugGenerator (thay ky tu la bang '-') thi "50%" khop luon "50 nights Tour" - pha vo dam bao "% la ky tu literal" ma repo da co test canh. Test cu bat duoc. 2) ImageCleanupService: dem tham chieu o ca ba bang truoc khi xoa file. Form upload sinh ten UUID nen binh thuong moi ban ghi co file rieng va xoa thang la dung; nhung du lieu gan bang script co the cho nhieu ban ghi tro chung mot file - luc do sua mot tour se xoa mat anh cua tat ca nhung tour con lai. PostgresQueryDialectTest chay dung nhung truy van viet tay tren Postgres that, trong do co truy van tung nem loi bytea. Co-Authored-By: Claude Opus 5 (1M context) --- .../tripgo/config/SearchTextBackfill.java | 52 +++++ .../java/demo/tripgo/entity/Destination.java | 11 + src/main/java/demo/tripgo/entity/Tour.java | 14 ++ .../repository/DestinationRepository.java | 8 +- .../repository/TourImageRepository.java | 11 + .../tripgo/repository/TourRepository.java | 6 + .../tripgo/repository/TourSpecifications.java | 27 +-- .../service/DestinationAdminService.java | 24 +- .../tripgo/service/ImageCleanupService.java | 61 +++++ .../java/demo/tripgo/service/SearchText.java | 59 +++++ .../demo/tripgo/service/TourAdminService.java | 11 +- ...minTourUploadItineraryIntegrationTest.java | 37 +++ .../TourControllerIntegrationTest.java | 13 ++ .../repository/PostgresQueryDialectTest.java | 212 ++++++++++++++++++ .../demo/tripgo/service/SearchTextTest.java | 45 ++++ 15 files changed, 549 insertions(+), 42 deletions(-) create mode 100644 src/main/java/demo/tripgo/config/SearchTextBackfill.java create mode 100644 src/main/java/demo/tripgo/repository/TourImageRepository.java create mode 100644 src/main/java/demo/tripgo/service/ImageCleanupService.java create mode 100644 src/main/java/demo/tripgo/service/SearchText.java create mode 100644 src/test/java/demo/tripgo/repository/PostgresQueryDialectTest.java create mode 100644 src/test/java/demo/tripgo/service/SearchTextTest.java diff --git a/src/main/java/demo/tripgo/config/SearchTextBackfill.java b/src/main/java/demo/tripgo/config/SearchTextBackfill.java new file mode 100644 index 0000000..171fa07 --- /dev/null +++ b/src/main/java/demo/tripgo/config/SearchTextBackfill.java @@ -0,0 +1,52 @@ +package demo.tripgo.config; + +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Tour; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +// Điền cột tìm kiếm không dấu cho dữ liệu đã có từ trước khi thêm cột. +// +// Không làm bằng SQL: bỏ dấu tiếng Việt trong SQL thuần cần extension unaccent, mà kể cả có thì +// kết quả cũng phải khớp từng ký tự với hàm Java dùng lúc tìm — lệch một chút là tìm không ra. +// Chạy qua entity nên chắc chắn dùng đúng một hàm chuẩn hoá. +// +// Idempotent: chỉ đụng hàng có searchText/searchName còn null, nên khởi động lại không tốn gì. +@Configuration +public class SearchTextBackfill { + + private static final Logger log = LoggerFactory.getLogger(SearchTextBackfill.class); + + @Bean + public ApplicationRunner backfillSearchText( + TourRepository tourRepository, + DestinationRepository destinationRepository + ) { + return args -> run(tourRepository, destinationRepository); + } + + @Transactional + void run(TourRepository tourRepository, DestinationRepository destinationRepository) { + List tours = tourRepository.findBySearchTextIsNull(); + if (!tours.isEmpty()) { + // @PreUpdate tự gọi refreshSearchText() khi lưu; gọi tường minh cho rõ ý định. + tours.forEach(Tour::refreshSearchText); + tourRepository.saveAll(tours); + log.info("Đã điền search_text cho {} tour", tours.size()); + } + + List destinations = destinationRepository.findBySearchNameIsNull(); + if (!destinations.isEmpty()) { + destinationRepository.saveAll(destinations); + log.info("Đã điền search_name cho {} điểm đến", destinations.size()); + } + } +} diff --git a/src/main/java/demo/tripgo/entity/Destination.java b/src/main/java/demo/tripgo/entity/Destination.java index 4f4b589..e755342 100644 --- a/src/main/java/demo/tripgo/entity/Destination.java +++ b/src/main/java/demo/tripgo/entity/Destination.java @@ -1,5 +1,6 @@ package demo.tripgo.entity; +import demo.tripgo.service.SearchText; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; @@ -40,4 +41,14 @@ public boolean isDeleted() { return deletedAt != null; } + // Dạng không dấu của tên, dùng cho tìm kiếm (xem Tour.searchText). + @Column(name = "search_name", length = 300) + private String searchName; + + @PrePersist + @PreUpdate + void refreshSearchName() { + searchName = SearchText.normalize(name); + } + } diff --git a/src/main/java/demo/tripgo/entity/Tour.java b/src/main/java/demo/tripgo/entity/Tour.java index 5bb3fde..0c54f69 100644 --- a/src/main/java/demo/tripgo/entity/Tour.java +++ b/src/main/java/demo/tripgo/entity/Tour.java @@ -1,5 +1,6 @@ package demo.tripgo.entity; +import demo.tripgo.service.SearchText; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; @@ -122,6 +123,13 @@ public void addItineraryDay(ItineraryDay day) { @Setter private LocalDateTime deletedAt; + // Dạng không dấu của title + description, dùng cho tìm kiếm: gõ "da nang" vẫn ra "Đà Nẵng". + // Tự tính ở @PrePersist/@PreUpdate nên không thể quên cập nhật khi sửa tour. + // Không dùng unaccent() của Postgres vì H2 trong test không có hàm đó, và bật extension cần + // quyền superuser — cột chuẩn hoá chạy được ở mọi loại DB. + @Column(name = "search_text", columnDefinition = "text") + private String searchText; + public boolean isDeleted() { return deletedAt != null; } @@ -132,10 +140,16 @@ protected void onCreate() { LocalDateTime now = LocalDateTime.now(); createdAt = now; updatedAt = now; + refreshSearchText(); } @PreUpdate protected void onUpdate() { updatedAt = LocalDateTime.now(); + refreshSearchText(); + } + + public void refreshSearchText() { + searchText = SearchText.normalize(title, description); } } diff --git a/src/main/java/demo/tripgo/repository/DestinationRepository.java b/src/main/java/demo/tripgo/repository/DestinationRepository.java index 6ef19ef..a7eaa6d 100644 --- a/src/main/java/demo/tripgo/repository/DestinationRepository.java +++ b/src/main/java/demo/tripgo/repository/DestinationRepository.java @@ -32,13 +32,13 @@ public interface DestinationRepository extends JpaRepository from Destination d left join Tour t on t.destination = d and t.deletedAt is null where d.deletedAt is null - and lower(d.name) like lower(:pattern) escape '\\' + and d.searchName like :pattern escape '\\' group by d.id, d.name, d.slug, d.image, d.deletedAt """, countQuery = """ select count(d) from Destination d where d.deletedAt is null - and lower(d.name) like lower(:pattern) escape '\\' + and d.searchName like :pattern escape '\\' """) Page findActiveWithTourCount( @Param("pattern") String pattern, Pageable pageable); @@ -53,6 +53,10 @@ Page findActiveWithTourCount( countQuery = "select count(d) from Destination d where d.deletedAt is not null") Page findDeletedForAdmin(Pageable pageable); + long countByImage(String image); + + List findBySearchNameIsNull(); + // Slug unique tính trên toàn bảng, kể cả hàng đã xoá mềm -> không lọc deletedAt ở đây. boolean existsBySlugAndIdNot(String slug, Long id); diff --git a/src/main/java/demo/tripgo/repository/TourImageRepository.java b/src/main/java/demo/tripgo/repository/TourImageRepository.java new file mode 100644 index 0000000..2f2eca8 --- /dev/null +++ b/src/main/java/demo/tripgo/repository/TourImageRepository.java @@ -0,0 +1,11 @@ +package demo.tripgo.repository; + +import demo.tripgo.entity.TourImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TourImageRepository extends JpaRepository { + + long countByUrl(String url); +} diff --git a/src/main/java/demo/tripgo/repository/TourRepository.java b/src/main/java/demo/tripgo/repository/TourRepository.java index 3a9a902..a2a9681 100644 --- a/src/main/java/demo/tripgo/repository/TourRepository.java +++ b/src/main/java/demo/tripgo/repository/TourRepository.java @@ -43,6 +43,12 @@ public interface TourRepository extends JpaRepository, JpaSpecificat countQuery = "select count(t) from Tour t where t.deletedAt is not null") Page findDeleted(Pageable pageable); + // Dữ liệu có từ trước khi thêm cột tìm kiếm; SearchTextBackfill điền nốt lúc khởi động. + java.util.List findBySearchTextIsNull(); + + // Đếm tour đang trỏ tới một file ảnh (kể cả tour trong thùng rác: khôi phục xong vẫn cần ảnh). + long countByThumbnailUrl(String thumbnailUrl); + // Tổng số tour đang bán (không tính tour trong thùng rác) — số liệu cho dashboard. long countByDeletedAtIsNull(); diff --git a/src/main/java/demo/tripgo/repository/TourSpecifications.java b/src/main/java/demo/tripgo/repository/TourSpecifications.java index f0f0abd..0893558 100644 --- a/src/main/java/demo/tripgo/repository/TourSpecifications.java +++ b/src/main/java/demo/tripgo/repository/TourSpecifications.java @@ -2,6 +2,7 @@ import demo.tripgo.dto.request.TourFilter; import demo.tripgo.entity.Tour; +import demo.tripgo.service.SearchText; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; @@ -9,15 +10,11 @@ import java.util.ArrayList; import java.util.List; -import java.util.Locale; // Dựng điều kiện lọc động cho danh sách tour. Mỗi filter chỉ thêm predicate khi có giá trị, // nên toàn bộ việc lọc chạy ở DB (không tải hết rồi lọc trong bộ nhớ). public final class TourSpecifications { - // Ký tự escape dùng cho các pattern LIKE khi search theo từ khoá. - private static final char LIKE_ESCAPE_CHAR = '\\'; - private TourSpecifications() { } @@ -41,13 +38,13 @@ public static Specification withFilter(TourFilter filter) { predicates.add(cb.isNull(root.get("deletedAt"))); if (hasText(filter.q())) { - // Escape \, %, _ để chúng được hiểu là ký tự literal, không phải wildcard của LIKE. - String escaped = escapeLike(filter.q().trim().toLowerCase(Locale.ROOT)); - String pattern = "%" + escaped + "%"; - predicates.add(cb.or( - cb.like(cb.lower(root.get("title")), pattern, LIKE_ESCAPE_CHAR), - cb.like(cb.lower(root.get("description")), pattern, LIKE_ESCAPE_CHAR) - )); + // Tìm trên cột searchText (dạng không dấu của title + description) để gõ "da nang" + // vẫn ra "Đà Nẵng". Từ khoá chuẩn hoá bằng ĐÚNG hàm đã dùng lúc lưu, và vẫn escape + // % _ để chúng là ký tự thường chứ không phải wildcard. + predicates.add(cb.like( + root.get("searchText"), + SearchText.likePattern(filter.q()), + SearchText.LIKE_ESCAPE_CHAR)); } if (hasText(filter.destination())) { predicates.add(cb.equal(destination.get("slug"), filter.destination().trim())); @@ -75,12 +72,4 @@ public static Specification withFilter(TourFilter filter) { private static boolean hasText(String value) { return value != null && !value.isBlank(); } - - // Escape ký tự đặc biệt của LIKE. Phải xử lý '\' trước để không escape lại chính escape char. - private static String escapeLike(String value) { - return value - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_"); - } } diff --git a/src/main/java/demo/tripgo/service/DestinationAdminService.java b/src/main/java/demo/tripgo/service/DestinationAdminService.java index 5bd8977..b33c51f 100644 --- a/src/main/java/demo/tripgo/service/DestinationAdminService.java +++ b/src/main/java/demo/tripgo/service/DestinationAdminService.java @@ -28,37 +28,27 @@ public class DestinationAdminService { private final DestinationRepository destinationRepository; private final TourRepository tourRepository; private final FileStorageService fileStorageService; + private final ImageCleanupService imageCleanupService; public DestinationAdminService( DestinationRepository destinationRepository, TourRepository tourRepository, - FileStorageService fileStorageService + FileStorageService fileStorageService, + ImageCleanupService imageCleanupService ) { this.destinationRepository = destinationRepository; this.tourRepository = tourRepository; this.fileStorageService = fileStorageService; + this.imageCleanupService = imageCleanupService; } @Transactional(readOnly = true) public Page list(String keyword, int page, int size) { Pageable pageable = PageRequest.of(page - 1, size, Sort.by("name")); - return destinationRepository.findActiveWithTourCount(likePattern(keyword), pageable) + return destinationRepository.findActiveWithTourCount(SearchText.likePattern(keyword), pageable) .map(this::toRow); } - // Không lọc -> '%' khớp mọi tên. Escape \, %, _ để chúng là ký tự thường chứ không phải - // wildcard: gõ "50%" vào ô tìm kiếm không được biến thành "khớp tất cả". - private String likePattern(String keyword) { - if (keyword == null || keyword.isBlank()) { - return "%"; - } - String escaped = keyword.trim() - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_"); - return "%" + escaped + "%"; - } - @Transactional(readOnly = true) public Page listDeleted(int page, int size) { Pageable pageable = PageRequest.of(page - 1, size, @@ -139,11 +129,11 @@ private String resolveImage(DestinationForm form, Destination destination) { String uploaded = fileStorageService.store(form.getImageFile()); if (uploaded != null) { - fileStorageService.delete(current); + imageCleanupService.deleteIfUnused(current); return uploaded; } if (form.isRemoveImage()) { - fileStorageService.delete(current); + imageCleanupService.deleteIfUnused(current); return null; } return current != null ? current : blankToNull(form.getImage()); diff --git a/src/main/java/demo/tripgo/service/ImageCleanupService.java b/src/main/java/demo/tripgo/service/ImageCleanupService.java new file mode 100644 index 0000000..9efc38b --- /dev/null +++ b/src/main/java/demo/tripgo/service/ImageCleanupService.java @@ -0,0 +1,61 @@ +package demo.tripgo.service; + +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourImageRepository; +import demo.tripgo.repository.TourRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// Xoá file ảnh, nhưng chỉ khi không còn bản ghi nào khác dùng nó. +// +// Vì sao cần: form upload sinh tên UUID nên bình thường mỗi bản ghi có file riêng và xoá thẳng là +// đúng. Nhưng dữ liệu gán bằng script (hoặc nhập tay) có thể cho NHIỀU bản ghi trỏ chung một file +// — lúc đó sửa một tour rồi thay ảnh sẽ xoá mất file của tất cả những bản ghi còn lại. +// +// Đếm >= 2 thì giữ file. Tại thời điểm gọi, bản ghi đang sửa VẪN còn trỏ tới URL cũ trong DB, +// nên "chỉ mình nó dùng" tương ứng với count == 1. +@Service +public class ImageCleanupService { + + private static final Logger log = LoggerFactory.getLogger(ImageCleanupService.class); + + private final FileStorageService fileStorageService; + private final TourRepository tourRepository; + private final TourImageRepository tourImageRepository; + private final DestinationRepository destinationRepository; + + public ImageCleanupService( + FileStorageService fileStorageService, + TourRepository tourRepository, + TourImageRepository tourImageRepository, + DestinationRepository destinationRepository + ) { + this.fileStorageService = fileStorageService; + this.tourRepository = tourRepository; + this.tourImageRepository = tourImageRepository; + this.destinationRepository = destinationRepository; + } + + @Transactional(readOnly = true) + public void deleteIfUnused(String url) { + if (url == null || url.isBlank()) { + return; + } + + long references = countReferences(url); + if (references > 1) { + log.debug("Giữ lại {}: còn {} bản ghi đang dùng", url, references); + return; + } + fileStorageService.delete(url); + } + + // Quét cả ba chỗ có thể trỏ tới một file: ảnh đại diện tour, ảnh gallery, ảnh điểm đến. + private long countReferences(String url) { + return tourRepository.countByThumbnailUrl(url) + + tourImageRepository.countByUrl(url) + + destinationRepository.countByImage(url); + } +} diff --git a/src/main/java/demo/tripgo/service/SearchText.java b/src/main/java/demo/tripgo/service/SearchText.java new file mode 100644 index 0000000..d7a3a54 --- /dev/null +++ b/src/main/java/demo/tripgo/service/SearchText.java @@ -0,0 +1,59 @@ +package demo.tripgo.service; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.regex.Pattern; + +// Chuẩn hoá chuỗi cho tìm kiếm: bỏ dấu tiếng Việt và hạ chữ thường, GIỮ NGUYÊN mọi ký tự khác. +// "Đà Nẵng 3N2Đ" -> "da nang 3n2d", "50% Off" -> "50% off". +// +// Cố ý khác SlugGenerator (thay dấu cách và ký tự lạ bằng '-'): nếu thay như vậy thì "50%" và +// "50 " sẽ cùng ra "50", và từ khoá "50%" sẽ khớp luôn "50 nights Tour" — làm mất tính literal +// của % mà repo đã có test canh. +// +// Dùng chung đúng một hàm cho cả lúc LƯU (sinh giá trị cột) lẫn lúc TÌM (sinh pattern). Hai bên +// lệch nhau một chút là tìm không ra, nên cố ý không có phiên bản thứ hai. +public final class SearchText { + + public static final char LIKE_ESCAPE_CHAR = '\\'; + + private static final Pattern DIACRITICS = Pattern.compile("\\p{InCombiningDiacriticalMarks}+"); + private static final Pattern WHITESPACE = Pattern.compile("\\s+"); + + private SearchText() { + } + + public static String normalize(String... parts) { + StringBuilder joined = new StringBuilder(); + for (String part : parts) { + if (part != null && !part.isBlank()) { + if (!joined.isEmpty()) { + joined.append(' '); + } + joined.append(part); + } + } + + // Normalizer tách dấu thành ký tự tổ hợp rồi xoá đi; riêng đ/Đ không có dạng tổ hợp + // nên phải thay thủ công trước. + String ascii = joined.toString().replace('đ', 'd').replace('Đ', 'D'); + ascii = Normalizer.normalize(ascii, Normalizer.Form.NFD); + ascii = DIACRITICS.matcher(ascii).replaceAll(""); + + return WHITESPACE.matcher(ascii.toLowerCase(Locale.ROOT)).replaceAll(" ").trim(); + } + + // Pattern LIKE cho một từ khoá; rỗng -> '%' khớp tất cả. + public static String likePattern(String keyword) { + String normalized = normalize(keyword); + return normalized.isEmpty() ? "%" : "%" + escapeLike(normalized) + "%"; + } + + // Escape ký tự đặc biệt của LIKE. Phải xử lý '\' trước để không escape lại chính escape char. + private static String escapeLike(String value) { + return value + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } +} diff --git a/src/main/java/demo/tripgo/service/TourAdminService.java b/src/main/java/demo/tripgo/service/TourAdminService.java index ca76eb5..ed3242b 100644 --- a/src/main/java/demo/tripgo/service/TourAdminService.java +++ b/src/main/java/demo/tripgo/service/TourAdminService.java @@ -41,19 +41,22 @@ public class TourAdminService { private final DestinationRepository destinationRepository; private final CategoryRepository categoryRepository; private final FileStorageService fileStorageService; + private final ImageCleanupService imageCleanupService; public TourAdminService( TourRepository tourRepository, BookingRepository bookingRepository, DestinationRepository destinationRepository, CategoryRepository categoryRepository, - FileStorageService fileStorageService + FileStorageService fileStorageService, + ImageCleanupService imageCleanupService ) { this.tourRepository = tourRepository; this.bookingRepository = bookingRepository; this.destinationRepository = destinationRepository; this.categoryRepository = categoryRepository; this.fileStorageService = fileStorageService; + this.imageCleanupService = imageCleanupService; } // ---- Tạo / sửa ---- @@ -134,11 +137,11 @@ private String resolveThumbnail(TourForm form, Tour tour) { String uploaded = fileStorageService.store(form.getThumbnailFile()); if (uploaded != null) { - fileStorageService.delete(current); + imageCleanupService.deleteIfUnused(current); return uploaded; } if (form.isRemoveThumbnail()) { - fileStorageService.delete(current); + imageCleanupService.deleteIfUnused(current); return null; } return current != null ? current : blankToNull(form.getThumbnailUrl()); @@ -151,7 +154,7 @@ private void applyGallery(TourForm form, Tour tour) { List removed = tour.getImages().stream() .filter(image -> !keep.contains(image.getUrl())) .toList(); - removed.forEach(image -> fileStorageService.delete(image.getUrl())); + removed.forEach(image -> imageCleanupService.deleteIfUnused(image.getUrl())); tour.getImages().removeAll(removed); if (form.getGalleryFiles() != null) { diff --git a/src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java index 815e195..c3db191 100644 --- a/src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java +++ b/src/test/java/demo/tripgo/admin/AdminTourUploadItineraryIntegrationTest.java @@ -182,6 +182,43 @@ void unkeptGalleryImageIsRemoved() throws Exception { assertThat(fileFor(dropped)).doesNotExist(); } + // Nhiều bản ghi trỏ chung một file (dữ liệu gán bằng script): thay ảnh của MỘT tour không + // được xoá file mà các tour khác vẫn đang dùng. + @Test + @WithMockUser(roles = "ADMIN") + void sharedImageIsKeptWhenOtherRecordsStillUseIt() throws Exception { + mvc.perform(newTour().file(image("thumbnailFile", "image/jpeg"))); + Tour first = tours.findAll().getFirst(); + String shared = first.getThumbnailUrl(); + + // Tour thứ hai dùng chung đúng file đó. + mvc.perform(newTour().param("slug", "tour-hai").param("thumbnailUrl", shared)); + assertThat(tours.count()).isEqualTo(2); + + // Gỡ ảnh của tour thứ nhất -> file phải còn, vì tour thứ hai vẫn dùng. + mvc.perform(editTour(first.getId()) + .param("thumbnailUrl", shared) + .param("removeThumbnail", "true")); + + assertThat(tours.findById(first.getId()).orElseThrow().getThumbnailUrl()).isNull(); + assertThat(fileFor(shared)).exists(); + } + + // Ngược lại: chỉ còn mình nó dùng thì xoá file như cũ, không để rác lại trên đĩa. + @Test + @WithMockUser(roles = "ADMIN") + void soleReferenceStillDeletesTheFile() throws Exception { + mvc.perform(newTour().file(image("thumbnailFile", "image/jpeg"))); + Tour only = tours.findAll().getFirst(); + String url = only.getThumbnailUrl(); + + mvc.perform(editTour(only.getId()) + .param("thumbnailUrl", url) + .param("removeThumbnail", "true")); + + assertThat(fileFor(url)).doesNotExist(); + } + // ---- Lịch trình ---- @Test diff --git a/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java b/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java index 6d5b0ed..35384a2 100644 --- a/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java +++ b/src/test/java/demo/tripgo/controller/TourControllerIntegrationTest.java @@ -334,4 +334,17 @@ private Category category(String slug, String name) { return categories.save(c); }); } + // Tìm không dấu: gõ "da nang" phải ra tour "Đà Nẵng ..." (trước đây ra 0 kết quả). + @Test + void searchIgnoresDiacritics() throws Exception { + saveTour("Đà Nẵng 3N2Đ", daNang, category("beach", "Biển đảo"), 3, "100", 4.0); + saveTour("Sapa san may", daNang, category("beach", "Biển đảo"), 3, "100", 4.0); + + for (String keyword : new String[] {"da nang", "Đà Nẵng", "DA NANG", "nang"}) { + mvc.perform(get("/api/v1/tours").param("q", keyword)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.data[0].title").value("Đà Nẵng 3N2Đ")); + } + } } diff --git a/src/test/java/demo/tripgo/repository/PostgresQueryDialectTest.java b/src/test/java/demo/tripgo/repository/PostgresQueryDialectTest.java new file mode 100644 index 0000000..7306447 --- /dev/null +++ b/src/test/java/demo/tripgo/repository/PostgresQueryDialectTest.java @@ -0,0 +1,212 @@ +package demo.tripgo.repository; + +import demo.tripgo.PostgresTestBase; +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.ContactInfo; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.service.DestinationAdminService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +// Chạy đúng những truy vấn viết tay trên Postgres thật. Mục đích không phải kiểm lại nghiệp vụ +// (các test H2 đã làm) mà là bắt lỗi phương ngữ SQL — thứ H2 không bao giờ phát hiện. +@SpringBootTest +class PostgresQueryDialectTest extends PostgresTestBase { + + @Autowired DestinationRepository destinations; + @Autowired TourRepository tours; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + @Autowired CategoryRepository categories; + @Autowired UserRepository users; + @Autowired DestinationAdminService destinationAdminService; + + private Destination destination; + private Tour tour; + + @BeforeEach + void setUp() { + bookings.deleteAll(); + departures.deleteAll(); + tours.deleteAll(); + destinations.deleteAll(); + users.deleteAll(); + + destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + tour = saveTour(); + } + + // Đây chính là truy vấn từng ném "function lower(bytea) does not exist" trên Postgres + // trong khi H2 chạy bình thường. + @Test + void destinationListQueryRunsOnPostgresWithoutKeyword() { + assertThat(destinationAdminService.list(null, 1, 20).getTotalElements()).isEqualTo(1); + assertThat(destinationAdminService.list(" ", 1, 20).getTotalElements()).isEqualTo(1); + } + + @Test + void destinationListQueryFiltersByKeyword() { + Destination sapa = new Destination(); + sapa.setName("Sapa"); + sapa.setSlug("sapa"); + destinations.save(sapa); + + assertThat(destinationAdminService.list("sapa", 1, 20).getTotalElements()).isEqualTo(1); + assertThat(destinationAdminService.list("khong-co", 1, 20).getTotalElements()).isZero(); + } + + // '%' và '_' phải là ký tự thường, không phải wildcard: không tên nào chứa chúng -> 0 kết quả. + @Test + void likeWildcardsAreTreatedAsLiterals() { + assertThat(destinationAdminService.list("%", 1, 20).getTotalElements()).isZero(); + assertThat(destinationAdminService.list("_", 1, 20).getTotalElements()).isZero(); + } + + // Tìm không dấu: gõ "da nang" phải ra "Đà Nẵng". + @Test + void searchIgnoresDiacritics() { + assertThat(destinationAdminService.list("da nang", 1, 20).getTotalElements()).isEqualTo(1); + assertThat(destinationAdminService.list("Đà Nẵng", 1, 20).getTotalElements()).isEqualTo(1); + assertThat(destinationAdminService.list("DA NANG", 1, 20).getTotalElements()).isEqualTo(1); + assertThat(destinationAdminService.list("nang", 1, 20).getTotalElements()).isEqualTo(1); + assertThat(destinationAdminService.list("sapa", 1, 20).getTotalElements()).isZero(); + } + + @Test + void destinationTourCountUsesLeftJoinCondition() { + saveTour(); + Tour deleted = saveTour(); + deleted.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(deleted); + + Destination empty = new Destination(); + empty.setName("Sapa"); + empty.setSlug("sapa"); + destinations.save(empty); + + var rows = destinationAdminService.list(null, 1, 20).getContent(); + assertThat(rows).hasSize(2); + assertThat(rows).filteredOn(r -> r.slug().equals("da-nang")) + .singleElement().extracting("tourCount").isEqualTo(2L); + // Điểm đến chưa có tour vẫn phải xuất hiện. + assertThat(rows).filteredOn(r -> r.slug().equals("sapa")) + .singleElement().extracting("tourCount").isEqualTo(0L); + } + + // coalesce + so sánh enum bằng tên đầy đủ trong HQL. + @Test + void revenueSumWorksAndIgnoresCancelled() { + LocalDateTime from = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + assertThat(bookings.sumRevenueSince(from)).isEqualByComparingTo("0"); + + saveBooking(BookingStatus.CONFIRMED, "2000000"); + saveBooking(BookingStatus.CANCELLED, "9000000"); + + assertThat(bookings.sumRevenueSince(from)).isEqualByComparingTo("2000000"); + } + + // Các truy vấn xoá mềm viết tay: chạy được và lọc đúng trên Postgres. + @Test + void softDeleteQueriesRunOnPostgres() { + assertThat(tours.findActiveById(tour.getId())).isPresent(); + assertThat(tours.findDetailById(tour.getId())).isPresent(); + assertThat(tours.fetchItinerary(tour.getId())).isPresent(); + assertThat(tours.findIdBySlug(tour.getSlug())).isPresent(); + + tour.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(tour); + + assertThat(tours.findActiveById(tour.getId())).isEmpty(); + assertThat(tours.findDetailById(tour.getId())).isEmpty(); + assertThat(tours.findIdBySlug(tour.getSlug())).isEmpty(); + assertThat(tours.findDeleted(PageRequest.of(0, 10)).getTotalElements()).isEqualTo(1); + assertThat(tours.countByDeletedAtIsNull()).isZero(); + } + + @Test + void adminBookingQueriesRunOnPostgres() { + saveBooking(BookingStatus.PENDING, "1000000"); + + assertThat(bookings.findAllBy(PageRequest.of(0, 10)).getTotalElements()).isEqualTo(1); + assertThat(bookings.findByStatus(BookingStatus.PENDING, PageRequest.of(0, 10)) + .getTotalElements()).isEqualTo(1); + assertThat(bookings.countByStatus(BookingStatus.PENDING)).isEqualTo(1); + } + + @Test + void publicDestinationListRunsOnPostgres() { + assertThat(destinations.findAllWithTourCount()).hasSize(1); + } + + private Tour saveTour() { + Category category = categories.findBySlug("beach").orElseGet(() -> { + Category c = new Category(); + c.setSlug("beach"); + c.setName("Biển đảo"); + return categories.save(c); + }); + + Tour t = new Tour(); + t.setTitle("Tour " + UUID.randomUUID().toString().substring(0, 4)); + t.setSlug(UUID.randomUUID().toString()); + t.setDestination(destination); + t.setCategory(category); + t.setDurationDays(3); + t.setPrice(new BigDecimal("1000000")); + t.setMaxGuests(20); + return tours.save(t); + } + + private void saveBooking(BookingStatus status, String total) { + User user = new User(); + user.setFullName("Khách"); + user.setEmail(UUID.randomUUID() + "@example.com"); + user.setPassword("x"); + user.setRole(Role.USER); + user = users.save(user); + + Departure departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(2); + departure = departures.save(departure); + + ContactInfo contact = new ContactInfo(); + contact.setFullName("Khách"); + contact.setEmail("k@example.com"); + contact.setPhone("0900000000"); + + Booking booking = new Booking(); + booking.setUser(user); + booking.setTour(tour); + booking.setDeparture(departure); + booking.setCode("TG-" + UUID.randomUUID().toString().substring(0, 8)); + booking.setAdults(2); + booking.setChildren(0); + booking.setTotalPrice(new BigDecimal(total)); + booking.setStatus(status); + booking.setContact(contact); + bookings.save(booking); + } +} diff --git a/src/test/java/demo/tripgo/service/SearchTextTest.java b/src/test/java/demo/tripgo/service/SearchTextTest.java new file mode 100644 index 0000000..95fa360 --- /dev/null +++ b/src/test/java/demo/tripgo/service/SearchTextTest.java @@ -0,0 +1,45 @@ +package demo.tripgo.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SearchTextTest { + + @Test + void stripsVietnameseDiacritics() { + assertThat(SearchText.normalize("Đà Nẵng 3N2Đ")).isEqualTo("da nang 3n2d"); + assertThat(SearchText.normalize("Huế")).isEqualTo("hue"); + assertThat(SearchText.normalize("Lễ hội hoa Đà Lạt")).isEqualTo("le hoi hoa da lat"); + } + + @Test + void joinsSeveralParts() { + assertThat(SearchText.normalize("Đà Nẵng", "Biển đẹp")).isEqualTo("da nang bien dep"); + assertThat(SearchText.normalize("Đà Nẵng", null)).isEqualTo("da nang"); + } + + // Từ khoá và giá trị lưu phải đi qua cùng một hàm, nếu không sẽ tìm không ra. + @Test + void keywordAndStoredValueNormalizeTheSameWay() { + String stored = SearchText.normalize("Đà Nẵng 3N2Đ", "Tour biển"); + assertThat(stored).contains(SearchText.normalize("da nang")); + assertThat(stored).contains(SearchText.normalize("ĐÀ NẴNG")); + } + + @Test + void blankKeywordMatchesEverything() { + assertThat(SearchText.likePattern(null)).isEqualTo("%"); + assertThat(SearchText.likePattern(" ")).isEqualTo("%"); + } + + // % và _ được GIỮ LẠI rồi escape, để chúng là ký tự thường chứ không phải wildcard: + // gõ "50%" không được khớp luôn "50 nights". + @Test + void likeWildcardsAreEscapedNotStripped() { + assertThat(SearchText.likePattern("%")).isEqualTo("%\\%%"); + assertThat(SearchText.likePattern("_")).isEqualTo("%\\_%"); + assertThat(SearchText.likePattern("50%")).isEqualTo("%50\\%%"); + assertThat(SearchText.likePattern("Đà Nẵng")).isEqualTo("%da nang%"); + } +} From d8bad7af2449396bf624bb54dd8b4628ace1d2a7 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 16:21:35 +0700 Subject: [PATCH 11/23] Dang nhap Facebook bang OAuth2 Login, doi sang JWT cua TripGo Bo trong FACEBOOK_CLIENT_ID thi ca cum khong duoc tao va ung dung chay binh thuong voi dang nhap email/mat khau. Chain rieng cho /oauth2/** va /login/oauth2/**: OAuth2 buoc phai giu authorization request + tham so state giua hai request (di va callback), viec ma chain STATELESS cua API khong lam duoc. Dat sau chain /admin/** de hai securityMatcher khong tranh nhau. Xong bat tay thi doi ngay sang JWT cua TripGo va huy session - client dung chung mot loai token cho moi endpoint. Co OAUTH2_SUCCESS_REDIRECT_URI thi redirect ve do kem ?token=, bo trong thi tra JSON { token, user } ngay tai callback de thu duoc bang trinh duyet khi chua co frontend. User.password thanh nullable: tai khoan Facebook khong bao gio dat mat khau. AuthService.login chan trong hop nay voi loi nhan ro rang thay vi so khop mat khau voi null. Email trung tai khoan LOCAL thi gan Facebook vao chinh tai khoan do, giu nguyen wishlist va don hang. Neu email da thuoc mot tai khoan Facebook KHAC thi dung lai, khong cuop tai khoan cua nguoi ta. Hai loi do test bat duoc: SocialLoginUserService co hai constructor nen Spring khong chon duoc (thieu @Autowired), va provider chua dang ky tra 500 thay vi 404 (boc resolver de nuot IllegalArgumentException). Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 11 ++ README.md | 23 +++ docker-compose.yml | 4 + .../demo/tripgo/config/SocialLoginConfig.java | 131 ++++++++++++++ .../tripgo/config/SocialLoginProperties.java | 29 +++ .../java/demo/tripgo/entity/AuthProvider.java | 26 +++ src/main/java/demo/tripgo/entity/User.java | 19 +- .../tripgo/repository/UserRepository.java | 4 + .../security/OAuth2LoginFailureHandler.java | 31 ++++ .../security/OAuth2LoginSuccessHandler.java | 86 +++++++++ .../security/SocialLoginUserService.java | 137 ++++++++++++++ .../tripgo/security/SocialUserAttributes.java | 45 +++++ .../java/demo/tripgo/service/AuthService.java | 8 + src/main/resources/application.yaml | 11 ++ .../SocialLoginDisabledIntegrationTest.java | 40 ++++ .../config/SocialLoginIntegrationTest.java | 103 +++++++++++ .../security/SocialLoginUserServiceTest.java | 171 ++++++++++++++++++ 17 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 src/main/java/demo/tripgo/config/SocialLoginConfig.java create mode 100644 src/main/java/demo/tripgo/config/SocialLoginProperties.java create mode 100644 src/main/java/demo/tripgo/entity/AuthProvider.java create mode 100644 src/main/java/demo/tripgo/security/OAuth2LoginFailureHandler.java create mode 100644 src/main/java/demo/tripgo/security/OAuth2LoginSuccessHandler.java create mode 100644 src/main/java/demo/tripgo/security/SocialLoginUserService.java create mode 100644 src/main/java/demo/tripgo/security/SocialUserAttributes.java create mode 100644 src/test/java/demo/tripgo/config/SocialLoginDisabledIntegrationTest.java create mode 100644 src/test/java/demo/tripgo/config/SocialLoginIntegrationTest.java create mode 100644 src/test/java/demo/tripgo/security/SocialLoginUserServiceTest.java diff --git a/.env.example b/.env.example index efc4499..b8c8ab4 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,14 @@ DB_PORT=5432 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=. +# Bỏ trống -> callback trả thẳng JSON { token, user }. +OAUTH2_SUCCESS_REDIRECT_URI= diff --git a/README.md b/README.md index 48b4fae..f8ee7f9 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,29 @@ 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` + +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ũ. + +--- + ## 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. diff --git a/docker-compose.yml b/docker-compose.yml index f505b40..cc16edd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,10 @@ 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:-} RATE_LIMIT_AUTH_MAX: ${RATE_LIMIT_AUTH_MAX:-20} RATE_LIMIT_AUTH_WINDOW: ${RATE_LIMIT_AUTH_WINDOW:-60} ports: diff --git a/src/main/java/demo/tripgo/config/SocialLoginConfig.java b/src/main/java/demo/tripgo/config/SocialLoginConfig.java new file mode 100644 index 0000000..96dae4c --- /dev/null +++ b/src/main/java/demo/tripgo/config/SocialLoginConfig.java @@ -0,0 +1,131 @@ +package demo.tripgo.config; + +import demo.tripgo.security.OAuth2LoginFailureHandler; +import demo.tripgo.security.OAuth2LoginSuccessHandler; +import demo.tripgo.security.SocialLoginUserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.SecurityFilterChain; + +import java.util.List; + +// Cả cụm đăng nhập Facebook chỉ được tạo khi có FACEBOOK_CLIENT_ID + FACEBOOK_CLIENT_SECRET. +// Thiếu cấu hình thì ứng dụng vẫn chạy bình thường với đăng nhập email/mật khẩu — tiện cho test +// và cho môi trường dev chưa xin được app Facebook. +// (@ConfigurationProperties không đánh giá SpEL nên phải kiểm bằng @ConditionalOnExpression.) +@Configuration +@EnableConfigurationProperties(SocialLoginProperties.class) +@ConditionalOnExpression("!'${social.login.facebook.client-id:}'.isBlank()") +public class SocialLoginConfig { + + private static final Logger log = LoggerFactory.getLogger(SocialLoginConfig.class); + + @Bean + public ClientRegistrationRepository clientRegistrationRepository(SocialLoginProperties properties) { + if (!properties.facebook().isConfigured()) { + throw new IllegalStateException( + "Đăng nhập Facebook cần CẢ client-id và client-secret. Kiểm tra FACEBOOK_CLIENT_SECRET."); + } + log.info("Đã bật đăng nhập Facebook"); + return new InMemoryClientRegistrationRepository(List.of(facebook(properties.facebook()))); + } + + // Chain riêng cho lúc bắt tay với Facebook. Tách khỏi chain API vì OAuth2 buộc phải giữ + // authorization request + tham số state giữa hai request (đi và callback) — việc mà chain + // STATELESS của API không làm được. + // + // Thứ tự đứng SAU chain /admin/** (HIGHEST_PRECEDENCE) để hai securityMatcher không tranh nhau. + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public SecurityFilterChain oauth2LoginFilterChain( + HttpSecurity http, + ClientRegistrationRepository clientRegistrationRepository, + SocialLoginUserService socialLoginUserService, + OAuth2LoginSuccessHandler successHandler, + OAuth2LoginFailureHandler failureHandler + ) throws Exception { + http + .securityMatcher("/oauth2/**", "/login/oauth2/**") + // Tham số state (và PKCE) đã chống CSRF cho chính luồng OAuth2; không có form nào ở đây. + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(endpoint -> + endpoint.authorizationRequestResolver(pkceResolver(clientRegistrationRepository))) + .userInfoEndpoint(userInfo -> userInfo.userService(socialLoginUserService)) + .successHandler(successHandler) + .failureHandler(failureHandler)); + + return http.build(); + } + + // Endpoint không ghi phiên bản: Facebook tự định tuyến, không cũ đi theo thời gian. + private ClientRegistration facebook(SocialLoginProperties.Credentials credentials) { + return CommonOAuth2Provider.FACEBOOK.getBuilder("facebook") + .clientId(credentials.clientId()) + .clientSecret(credentials.clientSecret()) + .clientName("Facebook") + .scope(credentials.scopes().toArray(String[]::new)) + .authorizationUri("https://www.facebook.com/dialog/oauth") + .tokenUri("https://graph.facebook.com/oauth/access_token") + .userInfoUri("https://graph.facebook.com/me?fields=id,name,email") + .userNameAttributeName("id") + .build(); + } + + // PKCE: kể cả khi mã authorization bị lộ trên đường redirect, thiếu code_verifier vẫn không + // đổi được token. + // + // Bọc thêm một lớp nuốt IllegalArgumentException: gõ nhầm /oauth2/authorization/google (provider + // chưa đăng ký) sẽ ném lỗi và thành trang 500. Trả null thì request đi tiếp và kết thúc bằng + // 404 — đúng nghĩa "không có đường dẫn này" và không làm log đầy stack trace vô ích. + private OAuth2AuthorizationRequestResolver pkceResolver(ClientRegistrationRepository repository) { + DefaultOAuth2AuthorizationRequestResolver delegate = + new DefaultOAuth2AuthorizationRequestResolver(repository, "/oauth2/authorization"); + delegate.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce()); + + return new OAuth2AuthorizationRequestResolver() { + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + return resolveQuietly(() -> delegate.resolve(request)); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + return resolveQuietly(() -> delegate.resolve(request, clientRegistrationId)); + } + + private OAuth2AuthorizationRequest resolveQuietly( + java.util.function.Supplier supplier + ) { + try { + return supplier.get(); + } catch (IllegalArgumentException exception) { + log.debug("Bỏ qua yêu cầu uỷ quyền không hợp lệ: {}", exception.getMessage()); + return null; + } + } + }; + } +} diff --git a/src/main/java/demo/tripgo/config/SocialLoginProperties.java b/src/main/java/demo/tripgo/config/SocialLoginProperties.java new file mode 100644 index 0000000..c14dcc7 --- /dev/null +++ b/src/main/java/demo/tripgo/config/SocialLoginProperties.java @@ -0,0 +1,29 @@ +package demo.tripgo.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import java.util.List; + +// Cấu hình đăng nhập mạng xã hội, đọc từ nhóm social.login trong application.yaml. +@ConfigurationProperties(prefix = "social.login") +public record SocialLoginProperties( + @DefaultValue Credentials facebook, + + // Có giá trị -> sau khi Facebook xác thực xong, redirect về URL này kèm ?token=. + // Bỏ trống (mặc định) -> trả thẳng JSON { token, user } giống POST /auth/login, để thử được + // bằng trình duyệt khi chưa có frontend. + @DefaultValue("") String successRedirectUri +) { + public record Credentials( + @DefaultValue("") String clientId, + @DefaultValue("") String clientSecret, + + // App Facebook mới chỉ có sẵn public_profile; xin email phải qua App Review. + @DefaultValue({"public_profile", "email"}) List scopes + ) { + public boolean isConfigured() { + return !clientId.isBlank() && !clientSecret.isBlank(); + } + } +} diff --git a/src/main/java/demo/tripgo/entity/AuthProvider.java b/src/main/java/demo/tripgo/entity/AuthProvider.java new file mode 100644 index 0000000..f1c0e37 --- /dev/null +++ b/src/main/java/demo/tripgo/entity/AuthProvider.java @@ -0,0 +1,26 @@ +package demo.tripgo.entity; + +import java.util.Locale; + +// Nguồn gốc tài khoản: LOCAL là đăng ký bằng email/mật khẩu, còn lại là đăng nhập qua OAuth2. +// Dùng enum thay cờ boolean để thêm Google/X sau này chỉ cần thêm hằng số ở đây. +public enum AuthProvider { + LOCAL, + FACEBOOK; + + public String getSlug() { + return name().toLowerCase(Locale.ROOT); + } + + // registrationId là id khai trong ClientRegistration ("facebook"), Spring Security truyền vào. + public static AuthProvider fromRegistrationId(String registrationId) { + if (registrationId == null) { + throw new IllegalArgumentException("registrationId không được null"); + } + return switch (registrationId.toLowerCase(Locale.ROOT)) { + case "facebook" -> FACEBOOK; + default -> throw new IllegalArgumentException( + "Chưa hỗ trợ đăng nhập bằng '" + registrationId + "'"); + }; + } +} diff --git a/src/main/java/demo/tripgo/entity/User.java b/src/main/java/demo/tripgo/entity/User.java index abb1f24..ea34813 100644 --- a/src/main/java/demo/tripgo/entity/User.java +++ b/src/main/java/demo/tripgo/entity/User.java @@ -10,7 +10,9 @@ @Getter @Entity -@Table(name = "users") +// (provider, provider_id) unique: một tài khoản Facebook chỉ gắn được vào đúng một user. +// Postgres coi các NULL là khác nhau nên mọi user LOCAL (provider_id = null) không đụng ràng buộc. +@Table(name = "users", uniqueConstraints = @UniqueConstraint(columnNames = {"provider", "provider_id"})) public class User { @Id @@ -25,10 +27,23 @@ public class User { @Setter private String email; - @Column(nullable = false) + // Null với tài khoản đăng nhập bằng Facebook: họ không bao giờ đặt mật khẩu ở TripGo. + // AuthService.login chặn sẵn trường hợp này để không so khớp mật khẩu với null. + @Column @Setter private String password; + // Mặc định LOCAL để user đăng ký bằng email vẫn đúng ngay cả khi mapper không set. + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Setter + private AuthProvider provider = AuthProvider.LOCAL; + + // Id do Facebook cấp (trường "id" của Graph API). Null với tài khoản LOCAL. + @Column(name = "provider_id") + @Setter + private String providerId; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) @Setter diff --git a/src/main/java/demo/tripgo/repository/UserRepository.java b/src/main/java/demo/tripgo/repository/UserRepository.java index 32f2573..8ff9ed9 100644 --- a/src/main/java/demo/tripgo/repository/UserRepository.java +++ b/src/main/java/demo/tripgo/repository/UserRepository.java @@ -1,5 +1,6 @@ package demo.tripgo.repository; +import demo.tripgo.entity.AuthProvider; import demo.tripgo.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -13,6 +14,9 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + // Khoá nhận dạng của tài khoản social: id Facebook không đổi kể cả khi user đổi tên/email. + Optional findByProviderAndProviderId(AuthProvider provider, String providerId); + // Principal lấy từ JWT là entity đã detached -> phải nạp lại kèm wishlist trong transaction // hiện tại. left join fetch để user chưa lưu tour nào vẫn trả về (wishlist rỗng). @Query("select u from User u left join fetch u.wishlist where u.id = :userId") diff --git a/src/main/java/demo/tripgo/security/OAuth2LoginFailureHandler.java b/src/main/java/demo/tripgo/security/OAuth2LoginFailureHandler.java new file mode 100644 index 0000000..bf3c5b1 --- /dev/null +++ b/src/main/java/demo/tripgo/security/OAuth2LoginFailureHandler.java @@ -0,0 +1,31 @@ +package demo.tripgo.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +// Mặc định Spring redirect về /login?error — TripGo không có trang đó, nên trả JSON đúng contract +// { error: { code, message } } như mọi lỗi khác của API. +@Component +public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { + + private final SecurityErrorResponder securityErrorResponder; + + public OAuth2LoginFailureHandler(SecurityErrorResponder securityErrorResponder) { + this.securityErrorResponder = securityErrorResponder; + } + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + securityErrorResponder.write(response, 401, + "Đăng nhập Facebook thất bại: " + exception.getMessage()); + } +} diff --git a/src/main/java/demo/tripgo/security/OAuth2LoginSuccessHandler.java b/src/main/java/demo/tripgo/security/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..e557608 --- /dev/null +++ b/src/main/java/demo/tripgo/security/OAuth2LoginSuccessHandler.java @@ -0,0 +1,86 @@ +package demo.tripgo.security; + +import demo.tripgo.dto.response.LoginResponse; +import demo.tripgo.entity.User; +import demo.tripgo.mapper.UserMapper; +import demo.tripgo.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +// Điểm nối giữa OAuth2 (có phiên) và phần còn lại của API (stateless): Facebook xác thực xong thì +// đổi ngay sang JWT của TripGo, để client dùng chung một loại token cho mọi endpoint. +@Component +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final UserRepository userRepository; + private final JwtService jwtService; + private final UserMapper userMapper; + private final ObjectMapper objectMapper; + private final String successRedirectUri; + + public OAuth2LoginSuccessHandler( + UserRepository userRepository, + JwtService jwtService, + UserMapper userMapper, + ObjectMapper objectMapper, + @Value("${social.login.success-redirect-uri:}") String successRedirectUri + ) { + this.userRepository = userRepository; + this.jwtService = jwtService; + this.userMapper = userMapper; + this.objectMapper = objectMapper; + this.successRedirectUri = successRedirectUri; + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + User user = loadUser(authentication); + String token = jwtService.generateToken(user); + + // Bỏ phiên ngay khi đã có JWT: phiên chỉ tồn tại để giữ state trong lúc bắt tay với Facebook. + if (request.getSession(false) != null) { + request.getSession(false).invalidate(); + } + + if (!successRedirectUri.isBlank()) { + response.sendRedirect(UriComponentsBuilder.fromUriString(successRedirectUri) + .queryParam("token", token) + .build() + .toUriString()); + return; + } + + // Không cấu hình frontend -> trả đúng body của POST /auth/login để thử thẳng bằng trình duyệt. + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), + new LoginResponse(token, userMapper.toUserResponse(user))); + } + + private User loadUser(Authentication authentication) { + if (!(authentication.getPrincipal() instanceof OAuth2User principal)) { + throw new IllegalStateException("Principal sau khi đăng nhập OAuth2 phải là OAuth2User"); + } + Object userId = principal.getAttributes().get(SocialLoginUserService.USER_ID_ATTRIBUTE); + if (!(userId instanceof Number id)) { + throw new IllegalStateException("Thiếu userId trong thuộc tính của phiên OAuth2"); + } + return userRepository.findById(id.longValue()) + .orElseThrow(() -> new IllegalStateException( + "Không tìm thấy user vừa đăng nhập bằng Facebook")); + } +} diff --git a/src/main/java/demo/tripgo/security/SocialLoginUserService.java b/src/main/java/demo/tripgo/security/SocialLoginUserService.java new file mode 100644 index 0000000..abe6249 --- /dev/null +++ b/src/main/java/demo/tripgo/security/SocialLoginUserService.java @@ -0,0 +1,137 @@ +package demo.tripgo.security; + +import demo.tripgo.entity.AuthProvider; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +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.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +// Chạy sau khi Spring Security đã đổi được access token: lấy thông tin user từ Facebook rồi +// tìm-hoặc-tạo bản ghi tương ứng trong bảng users. OAuth2LoginSuccessHandler dùng userId ở đây +// để phát JWT của TripGo. +@Service +public class SocialLoginUserService implements OAuth2UserService { + + public static final String PRINCIPAL_ATTRIBUTE = "principal"; + public static final String USER_ID_ATTRIBUTE = "userId"; + + private final OAuth2UserService delegate; + private final UserRepository userRepository; + + // @Autowired tường minh: lớp có hai constructor nên Spring không tự chọn được cái nào. + @Autowired + public SocialLoginUserService(UserRepository userRepository) { + this(userRepository, new DefaultOAuth2UserService()); + } + + // Cho phép thay delegate để unit test không cần gọi thật ra Graph API. + SocialLoginUserService( + UserRepository userRepository, + OAuth2UserService delegate + ) { + this.userRepository = userRepository; + this.delegate = delegate; + } + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User raw = delegate.loadUser(userRequest); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + SocialUserAttributes social; + try { + social = SocialUserAttributes.of(registrationId, raw.getAttributes()); + } catch (IllegalArgumentException exception) { + throw new OAuth2AuthenticationException( + new OAuth2Error("unsupported_provider", exception.getMessage(), null), exception); + } + if (social.providerId() == null) { + throw new OAuth2AuthenticationException(new OAuth2Error( + "invalid_user_info", "Facebook không trả về id người dùng", null)); + } + + User user = upsert(social); + + Map attributes = new LinkedHashMap<>(); + attributes.put(PRINCIPAL_ATTRIBUTE, social.principalName()); + attributes.put(USER_ID_ATTRIBUTE, user.getId()); + attributes.put("provider", social.provider().getSlug()); + attributes.put("fullName", user.getFullName()); + attributes.put("email", user.getEmail()); + + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())), + attributes, + PRINCIPAL_ATTRIBUTE); + } + + private User upsert(SocialUserAttributes social) { + User user = userRepository + .findByProviderAndProviderId(social.provider(), social.providerId()) + .orElseGet(() -> linkOrCreate(social)); + + // Facebook là nguồn sự thật cho tên: user đổi tên bên đó thì lần đăng nhập sau cập nhật theo. + if (social.fullName() != null) { + user.setFullName(social.fullName()); + } + return userRepository.save(user); + } + + // Đã có tài khoản LOCAL trùng email thì gắn Facebook vào chính tài khoản đó, vì cột email là + // unique — tạo mới sẽ vi phạm ràng buộc, mà user cũng muốn dùng lại đơn hàng/wishlist cũ. + private User linkOrCreate(SocialUserAttributes social) { + String email = social.email() == null ? null : social.email().toLowerCase(Locale.ROOT); + + Optional byEmail = email == null + ? Optional.empty() + : userRepository.findByEmail(email); + + User user = byEmail + .map(existing -> claimLocalAccount(existing, email)) + .orElseGet(() -> newUser(social, email)); + user.setProvider(social.provider()); + user.setProviderId(social.providerId()); + return user; + } + + // Chỉ gắn vào tài khoản LOCAL. Nếu email đã thuộc một tài khoản Facebook khác (user đổi email + // bên Facebook rồi đăng nhập bằng tài khoản mới) thì dừng, không cướp tài khoản của người khác. + private User claimLocalAccount(User existing, String email) { + if (existing.getProvider() != AuthProvider.LOCAL) { + throw new OAuth2AuthenticationException(new OAuth2Error( + "email_already_linked", + "Email " + email + " đã gắn với một tài khoản Facebook khác", null)); + } + return existing; + } + + private User newUser(SocialUserAttributes social, String email) { + User user = new User(); + // Email là NOT NULL + unique: user không cấp quyền email thì tự sinh một địa chỉ giữ chỗ. + user.setEmail(email != null + ? email + : social.principalName().replace(':', '.') + "@facebook.local"); + user.setFullName(social.fullName() != null ? social.fullName() : "Người dùng Facebook"); + user.setRole(Role.USER); + user.setStatus(UserStatus.ACTIVE); + return user; + } +} diff --git a/src/main/java/demo/tripgo/security/SocialUserAttributes.java b/src/main/java/demo/tripgo/security/SocialUserAttributes.java new file mode 100644 index 0000000..8ebfff0 --- /dev/null +++ b/src/main/java/demo/tripgo/security/SocialUserAttributes.java @@ -0,0 +1,45 @@ +package demo.tripgo.security; + +import demo.tripgo.entity.AuthProvider; + +import java.util.Map; + +// Chuẩn hoá dữ liệu user thô của từng nhà cung cấp về một kiểu chung, để SocialLoginUserService +// không phải biết Facebook trả JSON hình dạng ra sao. +public record SocialUserAttributes( + AuthProvider provider, + String providerId, + String fullName, + String email +) { + + public static SocialUserAttributes of(String registrationId, Map attributes) { + AuthProvider provider = AuthProvider.fromRegistrationId(registrationId); + return switch (provider) { + case FACEBOOK -> fromFacebook(attributes); + case LOCAL -> throw new IllegalArgumentException("LOCAL không phải nhà cung cấp OAuth2"); + }; + } + + // Graph API trả { id, name, email }; email vắng mặt nếu user không cấp quyền. + static SocialUserAttributes fromFacebook(Map attributes) { + return new SocialUserAttributes( + AuthProvider.FACEBOOK, + text(attributes.get("id")), + text(attributes.get("name")), + text(attributes.get("email"))); + } + + // Tên principal của phiên OAuth2, dạng "facebook:123...". + public String principalName() { + return provider.getSlug() + ":" + providerId; + } + + private static String text(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } +} diff --git a/src/main/java/demo/tripgo/service/AuthService.java b/src/main/java/demo/tripgo/service/AuthService.java index 5f4e6a3..fd9559e 100644 --- a/src/main/java/demo/tripgo/service/AuthService.java +++ b/src/main/java/demo/tripgo/service/AuthService.java @@ -72,6 +72,14 @@ public LoginResponse login(LoginRequest request) { ) ); + // Tài khoản tạo qua Facebook không có mật khẩu: nói rõ để user khỏi thử lại vô ích, + // thay vì trả "sai mật khẩu" cho một mật khẩu chưa từng tồn tại. + if (user.getPassword() == null) { + throw new InvalidCredentialsException( + "Tài khoản này đăng nhập bằng Facebook, vui lòng dùng đăng nhập Facebook" + ); + } + if (!passwordEncoder.matches( request.password(), user.getPassword() diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f049e0a..c0b4941 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,6 +37,17 @@ jwt: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:*} +# Đăng nhập Facebook. Bỏ trống client-id -> tính năng không bật, app vẫn chạy bình thường. +# Lấy giá trị ở https://developers.facebook.com/apps -> App settings -> Basic. +social: + login: + facebook: + client-id: ${FACEBOOK_CLIENT_ID:} + client-secret: ${FACEBOOK_CLIENT_SECRET:} + # Có giá trị -> callback redirect về frontend kèm ?token=. + # Bỏ trống -> trả JSON { token, user } ngay tại callback (tiện thử bằng trình duyệt). + success-redirect-uri: ${OAUTH2_SUCCESS_REDIRECT_URI:} + # Ảnh admin tải lên. Lưu ngoài classpath để không mất khi đóng gói lại (xem StorageProperties). storage: upload-dir: ${UPLOAD_DIR:./uploads} diff --git a/src/test/java/demo/tripgo/config/SocialLoginDisabledIntegrationTest.java b/src/test/java/demo/tripgo/config/SocialLoginDisabledIntegrationTest.java new file mode 100644 index 0000000..b70c07c --- /dev/null +++ b/src/test/java/demo/tripgo/config/SocialLoginDisabledIntegrationTest.java @@ -0,0 +1,40 @@ +package demo.tripgo.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.ApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +// Không khai FACEBOOK_CLIENT_ID (mặc định của profile test) thì cả cụm social login không được tạo, +// và phần còn lại của ứng dụng vẫn chạy bình thường. +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SocialLoginDisabledIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired ApplicationContext context; + + @Test + void socialLoginBeansAreNotCreated() { + assertThat(context.getBeanNamesForType(ClientRegistrationRepository.class)).isEmpty(); + assertThat(context.containsBean("oauth2LoginFilterChain")).isFalse(); + } + + @Test + void emailPasswordRegistrationStillWorks() throws Exception { + mvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Nhi\",\"email\":\"khong-social@example.com\",\"password\":\"MatKhau123\"}")) + .andExpect(status().isCreated()); + } +} diff --git a/src/test/java/demo/tripgo/config/SocialLoginIntegrationTest.java b/src/test/java/demo/tripgo/config/SocialLoginIntegrationTest.java new file mode 100644 index 0000000..c62e30e --- /dev/null +++ b/src/test/java/demo/tripgo/config/SocialLoginIntegrationTest.java @@ -0,0 +1,103 @@ +package demo.tripgo.config; + +import demo.tripgo.entity.AuthProvider; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.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.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +// Bật đăng nhập Facebook bằng credential giả: đủ để kiểm phần TripGo tự làm (dựng URL uỷ quyền, +// chặn đăng nhập mật khẩu cho tài khoản social). Phần đổi code lấy token là việc của Facebook, +// không test được ở đây và cũng không phải code của mình. +@SpringBootTest(properties = { + "social.login.facebook.client-id=test-client-id", + "social.login.facebook.client-secret=test-client-secret" +}) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SocialLoginIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired UserRepository users; + + @BeforeEach + void setUp() { + users.deleteAll(); + } + + // Vào /oauth2/authorization/facebook -> chuyển hướng sang trang xin quyền của Facebook. + @Test + void authorizationEndpointRedirectsToFacebook() throws Exception { + MvcResult result = mvc.perform(get("/oauth2/authorization/facebook")) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String location = result.getResponse().getRedirectedUrl(); + assertThat(location).startsWith("https://www.facebook.com/dialog/oauth"); + assertThat(location).contains("client_id=test-client-id"); + // state chống CSRF cho chính luồng OAuth2; code_challenge là PKCE. + assertThat(location).contains("state="); + assertThat(location).contains("code_challenge="); + } + + // Provider lạ -> không dựng được authorization request. + @Test + void unknownProviderIsRejected() throws Exception { + mvc.perform(get("/oauth2/authorization/google")) + .andExpect(status().is4xxClientError()); + } + + // Tài khoản tạo qua Facebook không có mật khẩu: đăng nhập bằng form phải bị chặn với lời nhắn + // rõ ràng, KHÔNG được ném lỗi do so khớp mật khẩu với null. + @Test + void passwordLoginIsRejectedForFacebookAccount() throws Exception { + User user = new User(); + user.setEmail("fb@example.com"); + user.setFullName("Người dùng Facebook"); + user.setRole(Role.USER); + user.setStatus(UserStatus.ACTIVE); + user.setProvider(AuthProvider.FACEBOOK); + user.setProviderId("fb-100"); + users.save(user); + + mvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"fb@example.com\",\"password\":\"bat-ky-mat-khau-nao\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error.message").value( + "Tài khoản này đăng nhập bằng Facebook, vui lòng dùng đăng nhập Facebook")); + } + + // Chain OAuth2 chỉ nhận /oauth2/** và /login/oauth2/**; API thường vẫn stateless + 401 JSON. + @Test + void apiChainStaysStatelessAndReturnsJsonError() throws Exception { + MvcResult result = mvc.perform(get("/api/v1/wishlist")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error.message").value("Vui lòng đăng nhập để tiếp tục")) + .andReturn(); + + assertThat(result.getRequest().getSession(false)).isNull(); + } + + // Khu quản trị có chain riêng và đứng trước; bật Facebook không được làm nó đổi hành vi. + @Test + void adminChainIsUnaffected() throws Exception { + mvc.perform(get("/admin")) + .andExpect(status().is3xxRedirection()); + } +} diff --git a/src/test/java/demo/tripgo/security/SocialLoginUserServiceTest.java b/src/test/java/demo/tripgo/security/SocialLoginUserServiceTest.java new file mode 100644 index 0000000..e2a5474 --- /dev/null +++ b/src/test/java/demo/tripgo/security/SocialLoginUserServiceTest.java @@ -0,0 +1,171 @@ +package demo.tripgo.security; + +import demo.tripgo.entity.AuthProvider; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.User; +import demo.tripgo.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +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.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +// Kiểm phần logic thuần của đăng nhập Facebook (tìm-hoặc-tạo user) mà không gọi ra Graph API: +// delegate được thay bằng một OAuth2UserService trả sẵn attributes đúng như Facebook trả về. +class SocialLoginUserServiceTest { + + private UserRepository users; + + @BeforeEach + void setUp() { + users = mock(UserRepository.class); + when(users.findByProviderAndProviderId(any(), anyString())).thenReturn(Optional.empty()); + when(users.findByEmail(anyString())).thenReturn(Optional.empty()); + when(users.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + if (user.getId() == null) { + ReflectionTestUtils.setField(user, "id", 100L); + } + return user; + }); + } + + @Test + void createsNewUserFromFacebookAttributes() { + OAuth2User principal = + load(Map.of("id", "fb-1", "name", "Nhi Truong", "email", "nhi@example.com")); + + assertThat(principal.getName()).isEqualTo("facebook:fb-1"); + assertThat(principal.getAttribute(SocialLoginUserService.USER_ID_ATTRIBUTE)) + .isEqualTo(100L); + + User saved = captureSaved(); + assertThat(saved.getProvider()).isEqualTo(AuthProvider.FACEBOOK); + assertThat(saved.getProviderId()).isEqualTo("fb-1"); + assertThat(saved.getEmail()).isEqualTo("nhi@example.com"); + assertThat(saved.getFullName()).isEqualTo("Nhi Truong"); + // Tài khoản Facebook không có mật khẩu -> AuthService.login phải chặn, không so khớp null. + assertThat(saved.getPassword()).isNull(); + } + + // Không xin được quyền email thì vẫn phải đăng nhập được: cột email là NOT NULL + unique. + @Test + void createsUserWithPlaceholderEmailWhenFacebookHidesIt() { + load(Map.of("id", "fb-2", "name", "Không email")); + + assertThat(captureSaved().getEmail()).isEqualTo("facebook.fb-2@facebook.local"); + } + + // Đã đăng ký bằng email trước đó -> gắn Facebook vào đúng tài khoản cũ (giữ wishlist, đơn hàng). + // Email Facebook trả về viết hoa, vẫn phải khớp với bản ghi lưu chữ thường. + @Test + void linksFacebookToExistingLocalAccountWithSameEmail() { + User existing = localUser(7L, "nhi@example.com"); + when(users.findByEmail("nhi@example.com")).thenReturn(Optional.of(existing)); + + load(Map.of("id", "fb-3", "name", "Nhi", "email", "Nhi@Example.com")); + + User saved = captureSaved(); + assertThat(saved.getId()).isEqualTo(7L); + assertThat(saved.getProvider()).isEqualTo(AuthProvider.FACEBOOK); + assertThat(saved.getProviderId()).isEqualTo("fb-3"); + } + + // Email đã thuộc một tài khoản Facebook khác -> dừng, không cướp tài khoản của người ta. + @Test + void refusesWhenEmailBelongsToAnotherFacebookAccount() { + User other = localUser(8L, "nhi@example.com"); + other.setProvider(AuthProvider.FACEBOOK); + other.setProviderId("fb-khac"); + when(users.findByEmail("nhi@example.com")).thenReturn(Optional.of(other)); + + assertThatThrownBy(() -> load(Map.of("id", "fb-4", "name", "Nhi", "email", "nhi@example.com"))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("đã gắn với một tài khoản Facebook khác"); + + verify(users, never()).save(any(User.class)); + } + + // Đăng nhập lần hai: tìm theo providerId, cập nhật tên mới chứ không tạo thêm user. + @Test + void secondLoginUpdatesNameInsteadOfCreatingAnotherUser() { + User existing = localUser(9L, "nhi@example.com"); + existing.setProvider(AuthProvider.FACEBOOK); + existing.setProviderId("fb-5"); + when(users.findByProviderAndProviderId(AuthProvider.FACEBOOK, "fb-5")) + .thenReturn(Optional.of(existing)); + + load(Map.of("id", "fb-5", "name", "Tên mới", "email", "nhi@example.com")); + + User saved = captureSaved(); + assertThat(saved.getId()).isEqualTo(9L); + assertThat(saved.getFullName()).isEqualTo("Tên mới"); + verify(users, never()).findByEmail(anyString()); + } + + @Test + void rejectsProviderWithoutUserId() { + assertThatThrownBy(() -> load(Map.of("name", "Thiếu id"))) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("không trả về id người dùng"); + } + + private User captureSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(users).save(captor.capture()); + return captor.getValue(); + } + + private OAuth2User load(Map attributes) { + OAuth2UserService delegate = request -> + new DefaultOAuth2User(List.of(), attributes, attributes.containsKey("id") ? "id" : "name"); + return new SocialLoginUserService(users, delegate).loadUser(userRequest()); + } + + private User localUser(Long id, String email) { + User user = new User(); + user.setEmail(email); + user.setFullName("Tên cũ"); + user.setRole(Role.USER); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("facebook") + .clientId("id") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("http://localhost/login/oauth2/code/facebook") + .authorizationUri("https://www.facebook.com/dialog/oauth") + .tokenUri("https://graph.facebook.com/oauth/access_token") + .userInfoUri("https://graph.facebook.com/me") + .userNameAttributeName("id") + .build(); + + OAuth2AccessToken token = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "token", Instant.now(), Instant.now().plusSeconds(60)); + return new OAuth2UserRequest(registration, token); + } +} From b7d673919d617081068e0d9260a07405cfafe2dc Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 16:43:38 +0700 Subject: [PATCH 12/23] Sua dang nhap Facebook lam hong DB da co du lieu Trieu chung: sau khi them cot provider, khong dang nhap duoc admin nua ma log khong bao gi ro rang. Nguyen nhan: ddl-auto=update chi THEM duoc thu moi. No khong them duoc cot NOT NULL vao bang da co dong (Postgres tu choi vi dong cu mang NULL), va khong go duoc NOT NULL khoi cot password. Hibernate gap loi DDL chi ghi WARN roi chay tiep, nen ung dung van khoi dong nhung cot provider khong ton tai -> moi truy van tren users hong. Test khong bat duoc vi H2 chay ddl-auto=create-drop, luon dung schema moi tinh. Day la lop loi chi lo ra tren DB da co du lieu that. Sua hai phan: - Cot provider cho phep NULL, hang cu duoc hieu la LOCAL qua User.isLocalAccount(). UserProviderBackfill dien not luc khoi dong, idempotent. - scripts/migrate-social-login.sql go NOT NULL khoi password. Cai nay BAT BUOC chay tay: khong co cach nao de Hibernate tu noi long rang buoc. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 20 ++++++++++ scripts/migrate-social-login.sql | 31 ++++++++++++++ .../tripgo/config/UserProviderBackfill.java | 40 +++++++++++++++++++ src/main/java/demo/tripgo/entity/User.java | 12 +++++- .../tripgo/repository/UserRepository.java | 3 ++ .../security/SocialLoginUserService.java | 3 +- 6 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 scripts/migrate-social-login.sql create mode 100644 src/main/java/demo/tripgo/config/UserProviderBackfill.java diff --git a/README.md b/README.md index f8ee7f9..08ba187 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,13 @@ Bỏ trống `FACEBOOK_CLIENT_ID` thì tính năng không được bật 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`. @@ -116,6 +123,19 @@ 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 | +| `/admin` | Xuất doanh thu tháng | Doanh thu gộp theo tour, bỏ qua đơn đã huỷ | + +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. + +--- + ## 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. diff --git a/scripts/migrate-social-login.sql b/scripts/migrate-social-login.sql new file mode 100644 index 0000000..84164de --- /dev/null +++ b/scripts/migrate-social-login.sql @@ -0,0 +1,31 @@ +-- Chuẩn bị bảng users cho đăng nhập Facebook. +-- +-- BẮT BUỘC chạy trên database đã có dữ liệu. Lý do: ddl-auto=update của Hibernate chỉ THÊM được +-- thứ mới, nó không: +-- - thêm được cột NOT NULL vào bảng đã có dòng (Postgres từ chối vì dòng cũ mang NULL), +-- - gỡ được ràng buộc NOT NULL khỏi cột password. +-- Hibernate gặp lỗi DDL chỉ ghi WARN rồi chạy tiếp, nên ứng dụng vẫn khởi động nhưng mọi truy vấn +-- trên users sẽ hỏng — triệu chứng là không đăng nhập được mà log không báo gì rõ ràng. +-- +-- Chạy: psql -h localhost -p 5432 -U postgres -d tripgo -f scripts/migrate-social-login.sql + +BEGIN; + +-- 1) Tài khoản Facebook không có mật khẩu. +ALTER TABLE users ALTER COLUMN password DROP NOT NULL; + +-- 2) Nguồn gốc tài khoản. Thêm cột cho phép NULL trước, điền dữ liệu cũ, rồi mới siết NOT NULL. +ALTER TABLE users ADD COLUMN IF NOT EXISTS provider VARCHAR(20); +ALTER TABLE users ADD COLUMN IF NOT EXISTS provider_id VARCHAR(255); + +UPDATE users SET provider = 'LOCAL' WHERE provider IS NULL; + +-- 3) Một tài khoản Facebook chỉ gắn được vào đúng một user. Postgres coi các NULL là khác nhau +-- nên mọi user LOCAL (provider_id = NULL) không đụng ràng buộc này. +CREATE UNIQUE INDEX IF NOT EXISTS uk_users_provider + ON users (provider, provider_id); + +COMMIT; + +-- Kiểm tra lại +SELECT provider, count(*) FROM users GROUP BY provider; diff --git a/src/main/java/demo/tripgo/config/UserProviderBackfill.java b/src/main/java/demo/tripgo/config/UserProviderBackfill.java new file mode 100644 index 0000000..1a2a1c3 --- /dev/null +++ b/src/main/java/demo/tripgo/config/UserProviderBackfill.java @@ -0,0 +1,40 @@ +package demo.tripgo.config; + +import demo.tripgo.entity.AuthProvider; +import demo.tripgo.entity.User; +import demo.tripgo.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +// Điền provider = LOCAL cho tài khoản có từ trước khi thêm cột. +// +// scripts/migrate-social-login.sql cũng làm việc này, nhưng chạy script là thao tác thủ công dễ +// quên; bean này bảo đảm dữ liệu luôn nhất quán kể cả khi ai đó bỏ qua bước đó. +// Idempotent: chỉ đụng hàng đang NULL. +@Configuration +public class UserProviderBackfill { + + private static final Logger log = LoggerFactory.getLogger(UserProviderBackfill.class); + + @Bean + public ApplicationRunner backfillUserProvider(UserRepository userRepository) { + return args -> run(userRepository); + } + + @Transactional + void run(UserRepository userRepository) { + List users = userRepository.findByProviderIsNull(); + if (users.isEmpty()) { + return; + } + users.forEach(user -> user.setProvider(AuthProvider.LOCAL)); + userRepository.saveAll(users); + log.info("Đã điền provider = LOCAL cho {} tài khoản cũ", users.size()); + } +} diff --git a/src/main/java/demo/tripgo/entity/User.java b/src/main/java/demo/tripgo/entity/User.java index ea34813..74533a9 100644 --- a/src/main/java/demo/tripgo/entity/User.java +++ b/src/main/java/demo/tripgo/entity/User.java @@ -33,9 +33,12 @@ public class User { @Setter private String password; - // Mặc định LOCAL để user đăng ký bằng email vẫn đúng ngay cả khi mapper không set. + // Cột để NULL được, không phải nullable = false: ddl-auto=update KHÔNG thêm được cột NOT NULL + // vào bảng đã có dữ liệu (Postgres từ chối, Hibernate chỉ log WARN rồi chạy tiếp — hậu quả là + // cột không tồn tại và mọi truy vấn users đều hỏng). + // Hàng cũ mang NULL và được hiểu là LOCAL; hàng mới luôn có giá trị nhờ default bên dưới. @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) + @Column(length = 20) @Setter private AuthProvider provider = AuthProvider.LOCAL; @@ -71,6 +74,11 @@ public class User { @Column(nullable = false) private LocalDateTime updatedAt; + // NULL = dữ liệu có từ trước khi thêm cột, coi như tài khoản email/mật khẩu. + public boolean isLocalAccount() { + return provider == null || provider == AuthProvider.LOCAL; + } + @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); diff --git a/src/main/java/demo/tripgo/repository/UserRepository.java b/src/main/java/demo/tripgo/repository/UserRepository.java index 8ff9ed9..c23a6ae 100644 --- a/src/main/java/demo/tripgo/repository/UserRepository.java +++ b/src/main/java/demo/tripgo/repository/UserRepository.java @@ -14,6 +14,9 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + // Tài khoản có từ trước khi thêm cột provider; UserProviderBackfill điền nốt lúc khởi động. + java.util.List findByProviderIsNull(); + // Khoá nhận dạng của tài khoản social: id Facebook không đổi kể cả khi user đổi tên/email. Optional findByProviderAndProviderId(AuthProvider provider, String providerId); diff --git a/src/main/java/demo/tripgo/security/SocialLoginUserService.java b/src/main/java/demo/tripgo/security/SocialLoginUserService.java index abe6249..1385f3a 100644 --- a/src/main/java/demo/tripgo/security/SocialLoginUserService.java +++ b/src/main/java/demo/tripgo/security/SocialLoginUserService.java @@ -1,6 +1,5 @@ package demo.tripgo.security; -import demo.tripgo.entity.AuthProvider; import demo.tripgo.entity.Role; import demo.tripgo.entity.User; import demo.tripgo.entity.UserStatus; @@ -115,7 +114,7 @@ private User linkOrCreate(SocialUserAttributes social) { // Chỉ gắn vào tài khoản LOCAL. Nếu email đã thuộc một tài khoản Facebook khác (user đổi email // bên Facebook rồi đăng nhập bằng tài khoản mới) thì dừng, không cướp tài khoản của người khác. private User claimLocalAccount(User existing, String email) { - if (existing.getProvider() != AuthProvider.LOCAL) { + if (!existing.isLocalAccount()) { throw new OAuth2AuthenticationException(new OAuth2Error( "email_already_linked", "Email " + email + " đã gắn với một tài khoản Facebook khác", null)); From 335813f640a7a5e063499ce7f3a3131940af8f47 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Fri, 25 Sep 2026 16:43:39 +0700 Subject: [PATCH 13/23] Xuat Excel cho don dat, tour va doanh thu thang Tang excel dung chung (demo.tripgo.excel): gan @ExcelColumn len field, ExcelMapper doc bang reflection roi tu sinh file. Them loai du lieu moi chi can tao mot lop va gan annotation, khong phai viet them dong code doc/ghi nao. Khop cot theo TIEU DE chu khong theo vi tri, nen nguoi dung dao cot hay chen them cot ghi chu van nhap duoc. Xuat theo dung bo loc dang xem chu khong phai xuat tat ca: loc xong roi moi muon gui file cho ai do la thao tac thuong gap hon nhieu. Mot truong hop test bat duoc: dong ma nguoi dung go roi xoa de lai o CHUOI RONG chu khong phai o BLANK, nen isBlank() phai tinh ca hai - file that day nhung dong "ma" kieu nay o cuoi bang. Ba lop test cu phai them buoc don don/ngay khoi hanh truoc khi xoa tours: moi lop test tu viet lai thu tu don theo khoa ngoai, day la lan thu ba vuong. Nen gom thanh mot helper dung chung. Co-Authored-By: Claude Opus 5 (1M context) --- pom.xml | 6 + .../tripgo/admin/AdminBookingController.java | 18 +- .../admin/AdminDashboardController.java | 15 +- .../tripgo/admin/AdminTourController.java | 13 +- .../java/demo/tripgo/admin/ExcelDownload.java | 40 +++ .../tripgo/admin/excel/BookingExportRow.java | 38 +++ .../tripgo/admin/excel/RevenueExportRow.java | 25 ++ .../tripgo/admin/excel/TourExportRow.java | 43 +++ .../demo/tripgo/excel/ExcelCellConverter.java | 175 +++++++++++ .../demo/tripgo/excel/ExcelCellException.java | 9 + .../java/demo/tripgo/excel/ExcelColumn.java | 23 ++ .../java/demo/tripgo/excel/ExcelField.java | 27 ++ .../java/demo/tripgo/excel/ExcelMapper.java | 287 ++++++++++++++++++ .../tripgo/excel/ExcelParseException.java | 13 + .../demo/tripgo/excel/ExcelReadResult.java | 16 + src/main/java/demo/tripgo/excel/ExcelRow.java | 5 + .../java/demo/tripgo/excel/ExcelRowError.java | 4 + .../tripgo/repository/BookingRepository.java | 15 + .../tripgo/repository/RevenueByTourView.java | 14 + .../tripgo/service/ExcelExportService.java | 113 +++++++ .../templates/admin/bookings/list.html | 3 + .../resources/templates/admin/dashboard.html | 5 +- .../resources/templates/admin/tours/list.html | 1 + .../AdminExcelExportIntegrationTest.java | 268 ++++++++++++++++ .../admin/AdminTourFormIntegrationTest.java | 8 + .../admin/AdminTourListIntegrationTest.java | 8 + .../demo/tripgo/excel/ExcelMapperTest.java | 240 +++++++++++++++ 27 files changed, 1428 insertions(+), 4 deletions(-) create mode 100644 src/main/java/demo/tripgo/admin/ExcelDownload.java create mode 100644 src/main/java/demo/tripgo/admin/excel/BookingExportRow.java create mode 100644 src/main/java/demo/tripgo/admin/excel/RevenueExportRow.java create mode 100644 src/main/java/demo/tripgo/admin/excel/TourExportRow.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelCellConverter.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelCellException.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelColumn.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelField.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelMapper.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelParseException.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelReadResult.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelRow.java create mode 100644 src/main/java/demo/tripgo/excel/ExcelRowError.java create mode 100644 src/main/java/demo/tripgo/repository/RevenueByTourView.java create mode 100644 src/main/java/demo/tripgo/service/ExcelExportService.java create mode 100644 src/test/java/demo/tripgo/admin/AdminExcelExportIntegrationTest.java create mode 100644 src/test/java/demo/tripgo/excel/ExcelMapperTest.java diff --git a/pom.xml b/pom.xml index 991d8c3..8a7d963 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,12 @@ + + + org.apache.poi + poi-ooxml + 5.5.1 + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/java/demo/tripgo/admin/AdminBookingController.java b/src/main/java/demo/tripgo/admin/AdminBookingController.java index a07594a..943a1b0 100644 --- a/src/main/java/demo/tripgo/admin/AdminBookingController.java +++ b/src/main/java/demo/tripgo/admin/AdminBookingController.java @@ -5,6 +5,9 @@ import demo.tripgo.exception.BookingAlreadyCancelledException; import demo.tripgo.exception.InvalidBookingRequestException; import demo.tripgo.service.BookingAdminService; +import demo.tripgo.service.ExcelExportService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; import org.springframework.data.domain.Page; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -22,9 +25,14 @@ public class AdminBookingController { private static final int PAGE_SIZE = 20; private final BookingAdminService bookingAdminService; + private final ExcelExportService excelExportService; - public AdminBookingController(BookingAdminService bookingAdminService) { + public AdminBookingController( + BookingAdminService bookingAdminService, + ExcelExportService excelExportService + ) { this.bookingAdminService = bookingAdminService; + this.excelExportService = excelExportService; } @GetMapping @@ -48,6 +56,14 @@ public String list( return "admin/bookings/list"; } + // Xuất đúng bộ lọc đang xem, nên URL nhận cùng tham số status với trang danh sách. + @GetMapping("/export") + public ResponseEntity export(@RequestParam(required = false) String status) { + BookingStatus filter = parseStatus(status); + String suffix = filter == null ? "tat-ca" : filter.name().toLowerCase(java.util.Locale.ROOT); + return ExcelDownload.of(excelExportService.exportBookings(filter), "don-dat-" + suffix); + } + @PostMapping("/{id}/confirm") public String confirm(@PathVariable Long id, @RequestParam(required = false) String status, RedirectAttributes redirect) { diff --git a/src/main/java/demo/tripgo/admin/AdminDashboardController.java b/src/main/java/demo/tripgo/admin/AdminDashboardController.java index f25b777..6ce8bc9 100644 --- a/src/main/java/demo/tripgo/admin/AdminDashboardController.java +++ b/src/main/java/demo/tripgo/admin/AdminDashboardController.java @@ -1,6 +1,9 @@ package demo.tripgo.admin; import demo.tripgo.service.DashboardService; +import demo.tripgo.service.ExcelExportService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -9,9 +12,14 @@ public class AdminDashboardController { private final DashboardService dashboardService; + private final ExcelExportService excelExportService; - public AdminDashboardController(DashboardService dashboardService) { + public AdminDashboardController( + DashboardService dashboardService, + ExcelExportService excelExportService + ) { this.dashboardService = dashboardService; + this.excelExportService = excelExportService; } @GetMapping("/admin") @@ -21,4 +29,9 @@ public String dashboard(Model model) { model.addAttribute("stats", dashboardService.stats()); return "admin/dashboard"; } + + @GetMapping("/admin/reports/revenue/export") + public ResponseEntity exportRevenue() { + return ExcelDownload.of(excelExportService.exportMonthlyRevenue(), "doanh-thu-thang"); + } } diff --git a/src/main/java/demo/tripgo/admin/AdminTourController.java b/src/main/java/demo/tripgo/admin/AdminTourController.java index 3f8850a..9561566 100644 --- a/src/main/java/demo/tripgo/admin/AdminTourController.java +++ b/src/main/java/demo/tripgo/admin/AdminTourController.java @@ -7,7 +7,10 @@ import demo.tripgo.repository.DestinationRepository; import demo.tripgo.dto.response.PageResponse; import demo.tripgo.dto.response.TourSummaryResponse; +import demo.tripgo.service.ExcelExportService; import demo.tripgo.service.TourAdminService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; import demo.tripgo.service.TourService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -36,13 +39,16 @@ public class AdminTourController { private final TourAdminService tourAdminService; private final DestinationRepository destinationRepository; private final CategoryRepository categoryRepository; + private final ExcelExportService excelExportService; public AdminTourController( TourService tourService, TourAdminService tourAdminService, DestinationRepository destinationRepository, - CategoryRepository categoryRepository + CategoryRepository categoryRepository, + ExcelExportService excelExportService ) { + this.excelExportService = excelExportService; this.tourService = tourService; this.tourAdminService = tourAdminService; this.destinationRepository = destinationRepository; @@ -71,6 +77,11 @@ public String list( return "admin/tours/list"; } + @GetMapping("/export") + public ResponseEntity export(@RequestParam(required = false) String q) { + return ExcelDownload.of(excelExportService.exportTours(q), "tour"); + } + // ---- Tạo / sửa ---- @GetMapping("/new") diff --git a/src/main/java/demo/tripgo/admin/ExcelDownload.java b/src/main/java/demo/tripgo/admin/ExcelDownload.java new file mode 100644 index 0000000..420a5a6 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/ExcelDownload.java @@ -0,0 +1,40 @@ +package demo.tripgo.admin; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +// Dựng response tải file. Gom vào một chỗ vì phần header dễ sai và lặp ở nhiều controller. +final class ExcelDownload { + + private static final MediaType XLSX = + MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + private static final DateTimeFormatter FILE_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private ExcelDownload() { + } + + static ResponseEntity of(byte[] content, String baseName) { + String fileName = baseName + "-" + LocalDate.now().format(FILE_DATE) + ".xlsx"; + + // ContentDisposition.builder tự sinh cả filename* (RFC 5987) nên tên file có dấu tiếng + // Việt không bị trình duyệt hiển thị thành ký tự lạ. + ContentDisposition disposition = ContentDisposition.attachment() + .filename(fileName, StandardCharsets.UTF_8) + .build(); + + return ResponseEntity.ok() + .contentType(XLSX) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentLength(content.length) + .body(new ByteArrayResource(content)); + } +} diff --git a/src/main/java/demo/tripgo/admin/excel/BookingExportRow.java b/src/main/java/demo/tripgo/admin/excel/BookingExportRow.java new file mode 100644 index 0000000..2b5e22e --- /dev/null +++ b/src/main/java/demo/tripgo/admin/excel/BookingExportRow.java @@ -0,0 +1,38 @@ +package demo.tripgo.admin.excel; + +import demo.tripgo.excel.ExcelColumn; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +// Một dòng trong file Excel đơn đặt. Chỉ cần gắn @ExcelColumn, ExcelMapper lo phần còn lại. +@Getter +@Setter +public class BookingExportRow { + + @ExcelColumn(header = "Mã đơn", order = 1) + private String code; + + @ExcelColumn(header = "Khách", order = 2) + private String customerName; + + @ExcelColumn(header = "Email", order = 3) + private String customerEmail; + + @ExcelColumn(header = "Tour", order = 4) + private String tourTitle; + + @ExcelColumn(header = "Ngày đi", order = 5) + private LocalDate departureDate; + + @ExcelColumn(header = "Số khách", order = 6) + private Integer guests; + + @ExcelColumn(header = "Tổng tiền", order = 7) + private BigDecimal totalPrice; + + @ExcelColumn(header = "Trạng thái", order = 8) + private String status; +} diff --git a/src/main/java/demo/tripgo/admin/excel/RevenueExportRow.java b/src/main/java/demo/tripgo/admin/excel/RevenueExportRow.java new file mode 100644 index 0000000..d44bbc5 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/excel/RevenueExportRow.java @@ -0,0 +1,25 @@ +package demo.tripgo.admin.excel; + +import demo.tripgo.excel.ExcelColumn; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +// Báo cáo doanh thu tháng, gộp theo tour. +@Getter +@Setter +public class RevenueExportRow { + + @ExcelColumn(header = "Tour", order = 1) + private String tourTitle; + + @ExcelColumn(header = "Số đơn", order = 2) + private Long bookingCount; + + @ExcelColumn(header = "Số khách", order = 3) + private Long guestCount; + + @ExcelColumn(header = "Doanh thu", order = 4) + private BigDecimal revenue; +} diff --git a/src/main/java/demo/tripgo/admin/excel/TourExportRow.java b/src/main/java/demo/tripgo/admin/excel/TourExportRow.java new file mode 100644 index 0000000..48bcac9 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/excel/TourExportRow.java @@ -0,0 +1,43 @@ +package demo.tripgo.admin.excel; + +import demo.tripgo.excel.ExcelColumn; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +// Cột trùng khớp với TourImportRow để file xuất ra sửa xong nhập lại được luôn. +@Getter +@Setter +public class TourExportRow { + + @ExcelColumn(header = "ID", order = 1) + private Long id; + + @ExcelColumn(header = "Tên tour", order = 2) + private String title; + + @ExcelColumn(header = "Slug", order = 3) + private String slug; + + @ExcelColumn(header = "Điểm đến", order = 4) + private String destination; + + @ExcelColumn(header = "Loại hình", order = 5) + private String category; + + @ExcelColumn(header = "Giá", order = 6) + private BigDecimal price; + + @ExcelColumn(header = "Giá khuyến mãi", order = 7) + private BigDecimal discountPrice; + + @ExcelColumn(header = "Số ngày", order = 8) + private Integer durationDays; + + @ExcelColumn(header = "Đánh giá", order = 9) + private Double rating; + + @ExcelColumn(header = "Lượt đánh giá", order = 10) + private Integer reviewCount; +} diff --git a/src/main/java/demo/tripgo/excel/ExcelCellConverter.java b/src/main/java/demo/tripgo/excel/ExcelCellConverter.java new file mode 100644 index 0000000..9d81bbf --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelCellConverter.java @@ -0,0 +1,175 @@ +package demo.tripgo.excel; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DateUtil; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +// Đổi giá trị một ô Excel sang kiểu Java của field. Người dùng gõ tay đủ kiểu định dạng nên chỗ +// này nhận nhiều dạng ngày, và mọi lỗi đều kèm tên cột + giá trị thật để họ biết sửa ở đâu. +final class ExcelCellConverter { + + private static final List DATE_TIME_FORMATS = List.of( + DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), + DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"), + DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm") + ); + + private static final List DATE_FORMATS = List.of( + DateTimeFormatter.ISO_LOCAL_DATE, + DateTimeFormatter.ofPattern("dd/MM/yyyy") + ); + + private ExcelCellConverter() { + } + + static Object convert(Cell cell, Class targetType, String header) { + String raw = asText(cell); + if (raw == null || raw.isBlank()) { + return null; + } + String value = raw.trim(); + + if (targetType == String.class) { + return value; + } + if (targetType == Integer.class || targetType == int.class) { + return parseInteger(value, header); + } + if (targetType == Long.class || targetType == long.class) { + return parseLong(value, header); + } + // Tiền: dùng BigDecimal, và bỏ dấu phân cách nghìn vì người dùng hay gõ "4.500.000". + if (targetType == BigDecimal.class) { + return parseDecimal(value, header); + } + if (targetType == Boolean.class || targetType == boolean.class) { + return parseBoolean(value, header); + } + if (targetType == LocalDateTime.class) { + return parseDateTime(cell, value, header); + } + if (targetType == LocalDate.class) { + return parseDateTime(cell, value, header).toLocalDate(); + } + if (targetType.isEnum()) { + return parseEnum(value, targetType, header); + } + throw new IllegalStateException( + "ExcelMapper chưa hỗ trợ kiểu " + targetType.getSimpleName() + " (cột '" + header + "')"); + } + + private static String asText(Cell cell) { + if (cell == null) { + return null; + } + CellType type = cell.getCellType() == CellType.FORMULA + ? cell.getCachedFormulaResultType() + : cell.getCellType(); + + return switch (type) { + case STRING -> cell.getStringCellValue(); + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); + case NUMERIC -> { + if (DateUtil.isCellDateFormatted(cell)) { + yield cell.getLocalDateTimeCellValue().toString(); + } + double number = cell.getNumericCellValue(); + // Excel lưu mọi số dạng double; số nguyên phải in lại không có ".0" để + // "4500000" không thành "4500000.0" rồi hỏng khi parse Integer. + yield number == Math.floor(number) && !Double.isInfinite(number) + ? String.valueOf((long) number) + : String.valueOf(number); + } + default -> null; + }; + } + + private static Integer parseInteger(String value, String header) { + try { + return Integer.valueOf(value); + } catch (NumberFormatException exception) { + throw new ExcelCellException( + "cột '" + header + "' phải là số nguyên, nhận được '" + value + "'"); + } + } + + private static Long parseLong(String value, String header) { + try { + return Long.valueOf(value); + } catch (NumberFormatException exception) { + throw new ExcelCellException( + "cột '" + header + "' phải là số nguyên, nhận được '" + value + "'"); + } + } + + private static BigDecimal parseDecimal(String value, String header) { + // "4.500.000" và "4,500,000" đều là cách viết một triệu rưỡi ở hai nơi khác nhau; bỏ hết + // dấu phân cách rồi parse, vì Excel đã trả số thuần khi ô là kiểu số. + String cleaned = value.replace(".", "").replace(",", "").replace(" ", ""); + try { + return new BigDecimal(cleaned); + } catch (NumberFormatException exception) { + throw new ExcelCellException( + "cột '" + header + "' phải là số tiền, nhận được '" + value + "'"); + } + } + + private static Boolean parseBoolean(String value, String header) { + String normalized = value.toLowerCase(Locale.ROOT); + if (List.of("true", "1", "yes", "x", "có").contains(normalized)) { + return Boolean.TRUE; + } + if (List.of("false", "0", "no", "không").contains(normalized)) { + return Boolean.FALSE; + } + throw new ExcelCellException( + "cột '" + header + "' phải là true hoặc false, nhận được '" + value + "'"); + } + + private static LocalDateTime parseDateTime(Cell cell, String value, String header) { + if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { + return cell.getLocalDateTimeCellValue(); + } + for (DateTimeFormatter formatter : DATE_TIME_FORMATS) { + try { + return LocalDateTime.parse(value, formatter); + } catch (DateTimeParseException ignored) { + // Thử định dạng tiếp theo. + } + } + for (DateTimeFormatter formatter : DATE_FORMATS) { + try { + return LocalDate.parse(value, formatter).atStartOfDay(); + } catch (DateTimeParseException ignored) { + // Thử định dạng tiếp theo. + } + } + throw new ExcelCellException("cột '" + header + + "' phải là ngày (vd 2026-01-31 hoặc 31/01/2026), nhận được '" + value + "'"); + } + + private static Object parseEnum(String value, Class enumType, String header) { + for (Object constant : enumType.getEnumConstants()) { + if (((Enum) constant).name().equalsIgnoreCase(value)) { + return constant; + } + } + String allowed = Arrays.stream(enumType.getEnumConstants()) + .map(constant -> ((Enum) constant).name().toLowerCase(Locale.ROOT)) + .toList() + .toString(); + throw new ExcelCellException("cột '" + header + "' có giá trị '" + value + + "' không hợp lệ. Cho phép: " + allowed); + } +} diff --git a/src/main/java/demo/tripgo/excel/ExcelCellException.java b/src/main/java/demo/tripgo/excel/ExcelCellException.java new file mode 100644 index 0000000..ac8bbeb --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelCellException.java @@ -0,0 +1,9 @@ +package demo.tripgo.excel; + +// Lỗi ở một ô cụ thể. Bắt ở mức dòng để một ô sai chỉ làm hỏng dòng đó, không hỏng cả file. +public class ExcelCellException extends RuntimeException { + + public ExcelCellException(String message) { + super(message); + } +} diff --git a/src/main/java/demo/tripgo/excel/ExcelColumn.java b/src/main/java/demo/tripgo/excel/ExcelColumn.java new file mode 100644 index 0000000..03e2a06 --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelColumn.java @@ -0,0 +1,23 @@ +package demo.tripgo.excel; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +// Đánh dấu một field sẽ thành một cột Excel. ExcelMapper đọc annotation này bằng reflection nên +// thêm loại dữ liệu mới chỉ cần tạo một lớp và gắn annotation, không phải viết thêm code đọc/ghi. +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface ExcelColumn { + + // Tiêu đề cột. Lúc đọc file, cột được tìm theo tiêu đề (không phân biệt hoa thường) chứ không + // theo vị trí — người dùng đảo cột hay chèn thêm cột lạ vẫn nhập được. + String header(); + + // Thứ tự cột khi ghi file. + int order(); + + // Bắt buộc: thiếu cột này thì cả file bị từ chối, thiếu giá trị thì dòng đó báo lỗi. + boolean required() default false; +} diff --git a/src/main/java/demo/tripgo/excel/ExcelField.java b/src/main/java/demo/tripgo/excel/ExcelField.java new file mode 100644 index 0000000..c54e36d --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelField.java @@ -0,0 +1,27 @@ +package demo.tripgo.excel; + +import java.lang.reflect.Field; + +// Một field đã gắn @ExcelColumn, kèm sẵn metadata để khỏi đọc annotation lại mỗi lần dùng. +public record ExcelField(Field field, String header, int order, boolean required) { + + public Object read(Object target) { + try { + return field.get(target); + } catch (IllegalAccessException exception) { + throw new IllegalStateException("Không đọc được field " + field.getName(), exception); + } + } + + public void write(Object target, Object value) { + try { + field.set(target, value); + } catch (IllegalAccessException exception) { + throw new IllegalStateException("Không ghi được field " + field.getName(), exception); + } + } + + public Class type() { + return field.getType(); + } +} diff --git a/src/main/java/demo/tripgo/excel/ExcelMapper.java b/src/main/java/demo/tripgo/excel/ExcelMapper.java new file mode 100644 index 0000000..cb1e158 --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelMapper.java @@ -0,0 +1,287 @@ +package demo.tripgo.excel; + +import org.apache.poi.EmptyFileException; +import org.apache.poi.EncryptedDocumentException; +import org.apache.poi.UnsupportedFileFormatException; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +// Đọc/ghi Excel cho BẤT KỲ lớp nào có field gắn @ExcelColumn, nhờ reflection. Thêm một loại dữ +// liệu mới (đơn đặt, tour, điểm đến...) chỉ cần tạo lớp và gắn annotation — không phải viết thêm +// một dòng code đọc/ghi nào. +@Component +public class ExcelMapper { + + // Chặn file quá lớn làm hết bộ nhớ: POI đọc cả workbook vào RAM. + public static final int MAX_DATA_ROWS = 5_000; + + private static final String DATE_PATTERN = "dd/mm/yyyy"; + + // Quét annotation khá tốn; mỗi lớp chỉ quét một lần rồi dùng lại. + private final Map, List> cache = new ConcurrentHashMap<>(); + + public List describe(Class type) { + return cache.computeIfAbsent(type, ExcelMapper::scan); + } + + private static List scan(Class type) { + List fields = new ArrayList<>(); + // Duyệt cả lớp cha để lớp con kế thừa được cột chung. + for (Class current = type; current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + ExcelColumn column = field.getAnnotation(ExcelColumn.class); + if (column == null) { + continue; + } + field.setAccessible(true); + fields.add(new ExcelField(field, column.header(), column.order(), column.required())); + } + } + if (fields.isEmpty()) { + throw new IllegalStateException( + type.getSimpleName() + " không có field nào gắn @ExcelColumn"); + } + fields.sort(Comparator.comparingInt(ExcelField::order)); + return List.copyOf(fields); + } + + // ---- Đọc ---- + + public ExcelReadResult read(InputStream input, Class type) { + List fields = describe(type); + + try (Workbook workbook = WorkbookFactory.create(input)) { + if (workbook.getNumberOfSheets() == 0) { + throw new ExcelParseException("File Excel không có sheet nào"); + } + Sheet sheet = workbook.getSheetAt(0); + Row headerRow = sheet.getRow(sheet.getFirstRowNum()); + if (headerRow == null) { + throw new ExcelParseException("File Excel trống, thiếu dòng tiêu đề"); + } + + Map layout = matchColumns(fields, readHeader(headerRow)); + + List> rows = new ArrayList<>(); + List errors = new ArrayList<>(); + + for (int index = headerRow.getRowNum() + 1; index <= sheet.getLastRowNum(); index++) { + Row row = sheet.getRow(index); + if (isBlank(row)) { + continue; + } + if (rows.size() + errors.size() >= MAX_DATA_ROWS) { + throw new ExcelParseException( + "File vượt quá " + MAX_DATA_ROWS + " dòng dữ liệu, hãy tách nhỏ file"); + } + // Số dòng hiển thị = chỉ số + 1 để khớp với thanh số dòng trong Excel. + int displayRow = index + 1; + try { + rows.add(new ExcelRow<>(displayRow, bind(row, type, layout))); + } catch (ExcelCellException exception) { + errors.add(new ExcelRowError(displayRow, exception.getMessage())); + } + } + return new ExcelReadResult<>(rows, errors); + + } catch (EmptyFileException exception) { + throw new ExcelParseException("File tải lên rỗng", exception); + } catch (EncryptedDocumentException exception) { + throw new ExcelParseException("File Excel đang được đặt mật khẩu", exception); + } catch (UnsupportedFileFormatException | IOException exception) { + throw new ExcelParseException( + "File tải lên không phải file Excel hợp lệ hoặc đã bị hỏng", exception); + } + } + + private Map readHeader(Row headerRow) { + Map indexes = new HashMap<>(); + for (Cell cell : headerRow) { + String text = cell.getCellType() == CellType.STRING ? cell.getStringCellValue() : null; + if (text != null && !text.isBlank()) { + indexes.putIfAbsent(normalize(text), cell.getColumnIndex()); + } + } + return indexes; + } + + // Khớp cột theo TIÊU ĐỀ chứ không theo vị trí: người dùng đảo cột hay chèn thêm cột ghi chú + // vẫn nhập được, và thiếu cột bắt buộc thì báo rõ thiếu cột nào. + private Map matchColumns( + List fields, Map columnIndexes + ) { + Map layout = new LinkedHashMap<>(); + List missingRequired = new ArrayList<>(); + + for (ExcelField field : fields) { + Integer index = columnIndexes.get(normalize(field.header())); + if (index == null) { + if (field.required()) { + missingRequired.add(field.header()); + } + continue; + } + layout.put(field, index); + } + if (!missingRequired.isEmpty()) { + throw new ExcelParseException( + "File thiếu cột bắt buộc: " + String.join(", ", missingRequired)); + } + return layout; + } + + private T bind(Row row, Class type, Map layout) { + T target = instantiate(type); + for (Map.Entry entry : layout.entrySet()) { + ExcelField field = entry.getKey(); + Cell cell = row.getCell(entry.getValue(), Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); + Object value = ExcelCellConverter.convert(cell, field.type(), field.header()); + if (value == null) { + if (field.required()) { + throw new ExcelCellException( + "thiếu giá trị ở cột bắt buộc '" + field.header() + "'"); + } + continue; + } + field.write(target, value); + } + return target; + } + + private T instantiate(Class type) { + try { + var constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException(type.getSimpleName() + + " cần có constructor rỗng để ExcelMapper tạo đối tượng khi đọc file", exception); + } + } + + // Dòng rỗng bị bỏ qua thay vì báo lỗi "thiếu giá trị". Phải tính cả ô CHUỖI RỖNG: người dùng + // gõ rồi xoá để lại ô kiểu STRING với giá trị "", không phải ô BLANK — file thật đầy dòng + // kiểu này ở cuối bảng. + private boolean isBlank(Row row) { + if (row == null) { + return true; + } + for (Cell cell : row) { + if (cell.getCellType() == CellType.BLANK) { + continue; + } + if (cell.getCellType() == CellType.STRING && cell.getStringCellValue().isBlank()) { + continue; + } + return false; + } + return true; + } + + // ---- Ghi ---- + + public byte[] write(List rows, Class type, String sheetName) { + List fields = describe(type); + + try (XSSFWorkbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + + Sheet sheet = workbook.createSheet(sheetName); + CellStyle headerStyle = headerStyle(workbook); + CellStyle dateStyle = dateStyle(workbook); + + Row headerRow = sheet.createRow(0); + for (int column = 0; column < fields.size(); column++) { + Cell cell = headerRow.createCell(column); + cell.setCellValue(fields.get(column).header()); + cell.setCellStyle(headerStyle); + } + + for (int index = 0; index < rows.size(); index++) { + Row row = sheet.createRow(index + 1); + T item = rows.get(index); + for (int column = 0; column < fields.size(); column++) { + fill(row.createCell(column), fields.get(column).read(item), dateStyle); + } + } + + for (int column = 0; column < fields.size(); column++) { + sheet.autoSizeColumn(column); + } + // Khoá dòng tiêu đề để cuộn danh sách dài vẫn thấy tên cột. + sheet.createFreezePane(0, 1); + + workbook.write(output); + return output.toByteArray(); + + } catch (IOException exception) { + throw new ExcelParseException( + "Không ghi được file Excel: " + exception.getMessage(), exception); + } + } + + private void fill(Cell cell, Object value, CellStyle dateStyle) { + switch (value) { + case null -> cell.setBlank(); + // BigDecimal xuống double có thể mất chính xác với số rất lớn, nhưng tiền VND trong + // phạm vi này thì an toàn, và để dạng số thì Excel mới tính tổng được. + case BigDecimal decimal -> cell.setCellValue(decimal.doubleValue()); + case Number number -> cell.setCellValue(number.doubleValue()); + case Boolean bool -> cell.setCellValue(bool); + case LocalDateTime dateTime -> { + cell.setCellValue(dateTime); + cell.setCellStyle(dateStyle); + } + case LocalDate date -> { + cell.setCellValue(date); + cell.setCellStyle(dateStyle); + } + case Enum enumValue -> cell.setCellValue(enumValue.name().toLowerCase(Locale.ROOT)); + default -> cell.setCellValue(String.valueOf(value)); + } + } + + private CellStyle headerStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private CellStyle dateStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setDataFormat( + workbook.getCreationHelper().createDataFormat().getFormat(DATE_PATTERN)); + return style; + } + + private static String normalize(String header) { + return header.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/demo/tripgo/excel/ExcelParseException.java b/src/main/java/demo/tripgo/excel/ExcelParseException.java new file mode 100644 index 0000000..74fe372 --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelParseException.java @@ -0,0 +1,13 @@ +package demo.tripgo.excel; + +// Lỗi ở mức cả file: không phải file Excel, thiếu cột bắt buộc, quá nhiều dòng... +public class ExcelParseException extends RuntimeException { + + public ExcelParseException(String message) { + super(message); + } + + public ExcelParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/demo/tripgo/excel/ExcelReadResult.java b/src/main/java/demo/tripgo/excel/ExcelReadResult.java new file mode 100644 index 0000000..f7f1c9d --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelReadResult.java @@ -0,0 +1,16 @@ +package demo.tripgo.excel; + +import java.util.List; + +// Kết quả đọc file: dòng đọc được và dòng lỗi nằm cạnh nhau. Nhờ vậy tầng trên nhập được phần +// hợp lệ và vẫn báo chính xác dòng nào sai — thay vì từ chối cả file vì một ô gõ nhầm. +public record ExcelReadResult(List> rows, List errors) { + + public int totalRows() { + return rows.size() + errors.size(); + } + + public List values() { + return rows.stream().map(ExcelRow::value).toList(); + } +} diff --git a/src/main/java/demo/tripgo/excel/ExcelRow.java b/src/main/java/demo/tripgo/excel/ExcelRow.java new file mode 100644 index 0000000..0f49d94 --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelRow.java @@ -0,0 +1,5 @@ +package demo.tripgo.excel; + +// Giữ kèm số dòng trong file để báo lỗi chỉ đúng chỗ cho người dùng sửa. +public record ExcelRow(int rowNumber, T value) { +} diff --git a/src/main/java/demo/tripgo/excel/ExcelRowError.java b/src/main/java/demo/tripgo/excel/ExcelRowError.java new file mode 100644 index 0000000..7386c27 --- /dev/null +++ b/src/main/java/demo/tripgo/excel/ExcelRowError.java @@ -0,0 +1,4 @@ +package demo.tripgo.excel; + +public record ExcelRowError(int rowNumber, String message) { +} diff --git a/src/main/java/demo/tripgo/repository/BookingRepository.java b/src/main/java/demo/tripgo/repository/BookingRepository.java index 5a51131..129a8dc 100644 --- a/src/main/java/demo/tripgo/repository/BookingRepository.java +++ b/src/main/java/demo/tripgo/repository/BookingRepository.java @@ -48,6 +48,21 @@ select coalesce(sum(b.totalPrice), 0) """) java.math.BigDecimal sumRevenueSince(@Param("from") java.time.LocalDateTime from); + // Doanh thu tháng gộp theo tour, dùng cho file báo cáo. Gộp ở DB thay vì tải hết đơn về rồi + // cộng trong bộ nhớ. + @Query(""" + select b.tour.title as tourTitle, + count(b.id) as bookingCount, + sum(b.adults + b.children) as guestCount, + sum(b.totalPrice) as revenue + from Booking b + where b.status <> demo.tripgo.entity.BookingStatus.CANCELLED + and b.createdAt >= :from + group by b.tour.id, b.tour.title + order by sum(b.totalPrice) desc + """) + java.util.List revenueByTourSince(@Param("from") java.time.LocalDateTime from); + // Số đơn còn hiệu lực của một tour: dùng để cảnh báo admin trước khi xoá. long countByTourIdAndStatusNot(Long tourId, BookingStatus status); diff --git a/src/main/java/demo/tripgo/repository/RevenueByTourView.java b/src/main/java/demo/tripgo/repository/RevenueByTourView.java new file mode 100644 index 0000000..8de763f --- /dev/null +++ b/src/main/java/demo/tripgo/repository/RevenueByTourView.java @@ -0,0 +1,14 @@ +package demo.tripgo.repository; + +import java.math.BigDecimal; + +// Projection cho báo cáo doanh thu theo tour. +public interface RevenueByTourView { + String getTourTitle(); + + long getBookingCount(); + + long getGuestCount(); + + BigDecimal getRevenue(); +} diff --git a/src/main/java/demo/tripgo/service/ExcelExportService.java b/src/main/java/demo/tripgo/service/ExcelExportService.java new file mode 100644 index 0000000..6ff1b7d --- /dev/null +++ b/src/main/java/demo/tripgo/service/ExcelExportService.java @@ -0,0 +1,113 @@ +package demo.tripgo.service; + +import demo.tripgo.admin.AdminBookingRow; +import demo.tripgo.admin.excel.BookingExportRow; +import demo.tripgo.admin.excel.RevenueExportRow; +import demo.tripgo.admin.excel.TourExportRow; +import demo.tripgo.dto.request.TourListRequest; +import demo.tripgo.dto.response.TourSummaryResponse; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.excel.ExcelMapper; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.RevenueByTourView; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +// Dựng file Excel cho ba màn quản trị. Mỗi hàm chỉ lấy dữ liệu rồi map sang lớp *ExportRow; +// phần ghi workbook do ExcelMapper lo. +@Service +public class ExcelExportService { + + // Xuất tối đa bấy nhiêu dòng: file lớn hơn thì vừa chậm vừa dễ hết bộ nhớ, mà admin cũng nên + // lọc bớt trước khi xuất. + private static final int EXPORT_LIMIT = 5_000; + + private final ExcelMapper excelMapper; + private final BookingAdminService bookingAdminService; + private final TourService tourService; + private final BookingRepository bookingRepository; + + public ExcelExportService( + ExcelMapper excelMapper, + BookingAdminService bookingAdminService, + TourService tourService, + BookingRepository bookingRepository + ) { + this.excelMapper = excelMapper; + this.bookingAdminService = bookingAdminService; + this.tourService = tourService; + this.bookingRepository = bookingRepository; + } + + // Xuất theo ĐÚNG bộ lọc admin đang xem, không phải xuất tất cả: lọc xong rồi mới muốn gửi + // file cho ai đó là thao tác thường gặp hơn nhiều. + @Transactional(readOnly = true) + public byte[] exportBookings(BookingStatus status) { + List rows = bookingAdminService.list(status, 1, EXPORT_LIMIT) + .getContent().stream() + .map(this::toBookingRow) + .toList(); + return excelMapper.write(rows, BookingExportRow.class, "Đơn đặt"); + } + + @Transactional(readOnly = true) + public byte[] exportTours(String keyword) { + List rows = tourService.listTours(new TourListRequest( + keyword, null, null, null, null, null, null, "newest", 1, EXPORT_LIMIT)) + .data().stream() + .map(this::toTourRow) + .toList(); + return excelMapper.write(rows, TourExportRow.class, "Tour"); + } + + @Transactional(readOnly = true) + public byte[] exportMonthlyRevenue() { + LocalDateTime startOfMonth = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + List rows = bookingRepository.revenueByTourSince(startOfMonth).stream() + .map(this::toRevenueRow) + .toList(); + return excelMapper.write(rows, RevenueExportRow.class, "Doanh thu tháng"); + } + + private BookingExportRow toBookingRow(AdminBookingRow booking) { + BookingExportRow row = new BookingExportRow(); + row.setCode(booking.code()); + row.setCustomerName(booking.customerName()); + row.setCustomerEmail(booking.customerEmail()); + row.setTourTitle(booking.tourTitle()); + row.setDepartureDate(booking.departureDate()); + row.setGuests(booking.guests()); + row.setTotalPrice(booking.totalPrice()); + // Nhãn tiếng Việt thay vì tên enum: file này để người đọc, không phải để máy đọc. + row.setStatus(booking.status().getLabel()); + return row; + } + + private TourExportRow toTourRow(TourSummaryResponse tour) { + TourExportRow row = new TourExportRow(); + row.setId(tour.id()); + row.setTitle(tour.title()); + row.setSlug(tour.slug()); + row.setDestination(tour.destination()); + row.setCategory(tour.category()); + row.setPrice(tour.price()); + row.setDiscountPrice(tour.discountPrice()); + row.setDurationDays(tour.durationDays()); + row.setRating(tour.rating()); + row.setReviewCount(tour.reviewCount()); + return row; + } + + private RevenueExportRow toRevenueRow(RevenueByTourView view) { + RevenueExportRow row = new RevenueExportRow(); + row.setTourTitle(view.getTourTitle()); + row.setBookingCount(view.getBookingCount()); + row.setGuestCount(view.getGuestCount()); + row.setRevenue(view.getRevenue()); + return row; + } +} diff --git a/src/main/resources/templates/admin/bookings/list.html b/src/main/resources/templates/admin/bookings/list.html index 269322a..091469d 100644 --- a/src/main/resources/templates/admin/bookings/list.html +++ b/src/main/resources/templates/admin/bookings/list.html @@ -9,6 +9,9 @@ diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index d99970d..d612c33 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -6,7 +6,10 @@
-

Dashboard

+
diff --git a/src/main/resources/templates/admin/tours/list.html b/src/main/resources/templates/admin/tours/list.html index e9824c8..ec9f07a 100644 --- a/src/main/resources/templates/admin/tours/list.html +++ b/src/main/resources/templates/admin/tours/list.html @@ -10,6 +10,7 @@
+ +
+ + + diff --git a/src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java index 81caa31..775d3c4 100644 --- a/src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java +++ b/src/test/java/demo/tripgo/admin/AdminSecurityIntegrationTest.java @@ -139,6 +139,21 @@ void logoutClearsSession() throws Exception { .andExpect(redirectedUrl("/admin/login?logout")); } + // Endpoint WebSocket nằm trong /admin nên phải được chain quản trị bảo vệ: đặt ở ngoài thì + // ai cũng nghe được thông tin đơn hàng của khách. + @Test + void websocketEndpointRequiresAdmin() throws Exception { + mvc.perform(get("/admin/ws")) + .andExpect(redirectedUrl("/admin/login")); + } + + @Test + @WithMockUser(username = USER_EMAIL, roles = "USER") + void websocketEndpointDeniesNormalUser() throws Exception { + mvc.perform(get("/admin/ws")) + .andExpect(redirectedUrl("/admin/login?denied")); + } + // ---- Khu quản trị không được ảnh hưởng tới API ---- @Test diff --git a/src/test/java/demo/tripgo/event/BookingNotificationIntegrationTest.java b/src/test/java/demo/tripgo/event/BookingNotificationIntegrationTest.java new file mode 100644 index 0000000..bc35315 --- /dev/null +++ b/src/test/java/demo/tripgo/event/BookingNotificationIntegrationTest.java @@ -0,0 +1,168 @@ +package demo.tripgo.event; + +import demo.tripgo.TestDataCleaner; +import demo.tripgo.config.WebSocketConfig; +import demo.tripgo.dto.request.CreateBookingRequest; +import demo.tripgo.dto.request.ContactRequest; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.repository.UserRepository; +import demo.tripgo.service.BookingAdminService; +import demo.tripgo.service.BookingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +// Kiểm việc PHÁT sự kiện và đẩy xuống kênh realtime. Phần bắt tay WebSocket thật là của Spring, +// ở đây thay SimpMessagingTemplate bằng mock để bám vào đúng phần TripGo tự viết. +@SpringBootTest +@ActiveProfiles("test") +class BookingNotificationIntegrationTest { + + @Autowired TestDataCleaner cleaner; + @Autowired BookingService bookingService; + @Autowired BookingAdminService bookingAdminService; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired DepartureRepository departures; + @Autowired UserRepository users; + + // @MockBean đã bị gỡ ở Spring Boot 4; bản thay thế là @MockitoBean. + @MockitoBean SimpMessagingTemplate messagingTemplate; + + private Tour tour; + private User customer; + + @BeforeEach + void setUp() { + cleaner.clean(); + + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = new Category(); + category.setSlug("beach"); + category.setName("Biển đảo"); + category = categories.save(category); + + tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("1000000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + Departure departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(0); + departures.save(departure); + + customer = new User(); + customer.setFullName("Khách"); + customer.setEmail(UUID.randomUUID() + "@example.com"); + customer.setPassword("x"); + customer.setRole(Role.USER); + customer = users.save(customer); + } + + @Test + void publishesEventWhenBookingCreated() { + bookingService.createBooking(customer, bookingRequest()); + + BookingEvent event = captureEvent(); + assertThat(event.kind()).isEqualTo(BookingEvent.Kind.CREATED); + assertThat(event.tourTitle()).isEqualTo("Đà Nẵng 3N2Đ"); + assertThat(event.status()).isEqualTo(BookingStatus.PENDING); + assertThat(event.code()).startsWith("TG-"); + assertThat(event.message()).contains("Đơn mới"); + } + + @Test + void publishesEventWhenAdminConfirms() { + Long id = bookingService.createBooking(customer, bookingRequest()).id(); + + bookingAdminService.confirm(id); + + assertThat(captureLastEvent().kind()).isEqualTo(BookingEvent.Kind.CONFIRMED); + } + + @Test + void publishesEventWhenBookingCancelled() { + Long id = bookingService.createBooking(customer, bookingRequest()).id(); + + bookingAdminService.cancel(id); + + BookingEvent event = captureLastEvent(); + assertThat(event.kind()).isEqualTo(BookingEvent.Kind.CANCELLED); + assertThat(event.status()).isEqualTo(BookingStatus.CANCELLED); + } + + // Transaction rollback thì KHÔNG được gửi thông báo: nếu không, admin thấy báo một đơn + // không hề tồn tại. + @Test + void sendsNothingWhenTransactionRollsBack() { + try { + bookingService.createBooking(customer, new CreateBookingRequest( + 999999L, LocalDate.now().plusDays(30), 2, 0, contact())); + } catch (RuntimeException expected) { + // Tour không tồn tại -> rollback. + } + + verify(messagingTemplate, never()).convertAndSend(eq(WebSocketConfig.TOPIC_BOOKINGS), any(Object.class)); + } + + private BookingEvent captureEvent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(BookingEvent.class); + verify(messagingTemplate, timeout(2000)) + .convertAndSend(eq(WebSocketConfig.TOPIC_BOOKINGS), captor.capture()); + return captor.getValue(); + } + + private BookingEvent captureLastEvent() { + ArgumentCaptor captor = ArgumentCaptor.forClass(BookingEvent.class); + verify(messagingTemplate, timeout(2000).atLeastOnce()) + .convertAndSend(eq(WebSocketConfig.TOPIC_BOOKINGS), captor.capture()); + return captor.getAllValues().getLast(); + } + + private CreateBookingRequest bookingRequest() { + return new CreateBookingRequest(tour.getId(), LocalDate.now().plusDays(30), 2, 0, contact()); + } + + private ContactRequest contact() { + return new ContactRequest("Khách", "k@example.com", "0900000000", null); + } +} From e0515704b8881f053b152890d8b4e84297a37aa4 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Mon, 28 Sep 2026 10:31:27 +0700 Subject: [PATCH 18/23] Hang doi gui mail xac nhan dat tour bang JMS Mail di qua hang doi tripgo.booking.mail thay vi gui ngay trong request cua khach. Listener nem loi thi message quay lai hang doi de thu lai - do la ly do chinh dung hang doi: may chu mail chap chon thi viec khong mat. MAC DINH TAT (JMS_ENABLED=false). Co ActiveMQ tren classpath ma khong co broker thi listener thu ket noi lai lien tuc va lam log day loi, trong khi moi truong dev va test khong can dung broker chi de chay ung dung. Khi tat, DirectBookingMailDispatcher gui thang tren pool nen. Hai ban cai cung mot interface nen phan con lai cua ung dung khong can biet co broker hay khong, va viec gui mail khong lang le bien mat khi ai do quen bat JMS. Gui dang chuoi JSON tu serialize thay vi dung message converter co san: goi tin di qua hang doi co the duoc doc lai boi phien ban ung dung khac, nen dinh dang phai la thu minh kiem soat duoc. BookingNotifier tach hai try rieng: bao realtime hong thi mail van phai di. Test chay ca hai nhanh, trong do nhanh JMS dung broker nhung vm:// nen goi tin di qua hang doi that ma khong can dung ActiveMQ ben ngoai. Them service mq vao docker-compose (chi can khi JMS_ENABLED=true). Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 9 ++ README.md | 15 ++++ docker-compose.yml | 20 +++++ pom.xml | 13 +++ .../java/demo/tripgo/config/JmsConfig.java | 84 ++++++++++++++++++ .../demo/tripgo/event/BookingNotifier.java | 17 +++- .../tripgo/mail/BookingMailDispatcher.java | 12 +++ .../demo/tripgo/mail/BookingMailService.java | 29 +++++++ .../mail/DirectBookingMailDispatcher.java | 29 +++++++ src/main/resources/application.yaml | 17 ++++ .../tripgo/mail/BookingMailDispatchTest.java | 86 +++++++++++++++++++ 11 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/main/java/demo/tripgo/config/JmsConfig.java create mode 100644 src/main/java/demo/tripgo/mail/BookingMailDispatcher.java create mode 100644 src/main/java/demo/tripgo/mail/BookingMailService.java create mode 100644 src/main/java/demo/tripgo/mail/DirectBookingMailDispatcher.java create mode 100644 src/test/java/demo/tripgo/mail/BookingMailDispatchTest.java diff --git a/.env.example b/.env.example index 75221ba..ecb5666 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,12 @@ OAUTH2_SUCCESS_REDIRECT_URI= 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 + +# --- 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 diff --git a/README.md b/README.md index 5456abf..27ba5ca 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,21 @@ tài khoản đó** để giữ nguyên wishlist và đơn hàng cũ. 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. +## 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, diff --git a/docker-compose.yml b/docker-compose.yml index cc16edd..c9efd5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: @@ -35,6 +50,11 @@ services: 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: diff --git a/pom.xml b/pom.xml index 1ac11c8..883b05a 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,19 @@ + + + org.springframework.boot + spring-boot-starter-activemq + + + + org.apache.activemq + activemq-broker + test + + org.springframework.boot diff --git a/src/main/java/demo/tripgo/config/JmsConfig.java b/src/main/java/demo/tripgo/config/JmsConfig.java new file mode 100644 index 0000000..e8aebfc --- /dev/null +++ b/src/main/java/demo/tripgo/config/JmsConfig.java @@ -0,0 +1,84 @@ +package demo.tripgo.config; + +import demo.tripgo.event.BookingEvent; +import demo.tripgo.mail.BookingMailDispatcher; +import demo.tripgo.mail.BookingMailService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jms.annotation.EnableJms; +import org.springframework.jms.annotation.JmsListener; +import org.springframework.jms.core.JmsTemplate; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +// Gửi mail qua hàng đợi thay vì gọi thẳng. +// +// Cả cụm chỉ được tạo khi tasks.jms.enabled=true. Mặc định TẮT: có dependency ActiveMQ trên +// classpath mà không có broker thì listener sẽ thử kết nối lại liên tục và làm log đầy lỗi — +// môi trường dev và test không cần dựng broker chỉ để chạy ứng dụng. +// +// Khi tắt, DirectBookingMailDispatcher làm thay nên việc gửi mail không biến mất. +@Configuration +@EnableJms +@ConditionalOnProperty(prefix = "tasks.jms", name = "enabled", havingValue = "true") +public class JmsConfig { + + public static final String BOOKING_MAIL_QUEUE = "tripgo.booking.mail"; + + @Bean + public BookingMailDispatcher jmsBookingMailDispatcher( + JmsTemplate jmsTemplate, ObjectMapper objectMapper + ) { + return new JmsBookingMailDispatcher(jmsTemplate, objectMapper); + } + + // Gửi dạng chuỗi JSON tự serialize thay vì dùng message converter có sẵn: gói tin đi qua hàng + // đợi có thể được đọc lại bởi phiên bản ứng dụng khác, nên định dạng phải là thứ mình kiểm + // soát được chứ không phụ thuộc vào cách một thư viện tuần tự hoá đối tượng Java. + static class JmsBookingMailDispatcher implements BookingMailDispatcher { + + private static final Logger log = LoggerFactory.getLogger(JmsBookingMailDispatcher.class); + + private final JmsTemplate jmsTemplate; + private final ObjectMapper objectMapper; + + JmsBookingMailDispatcher(JmsTemplate jmsTemplate, ObjectMapper objectMapper) { + this.jmsTemplate = jmsTemplate; + this.objectMapper = objectMapper; + } + + @Override + public void dispatch(BookingEvent event) { + try { + jmsTemplate.convertAndSend(BOOKING_MAIL_QUEUE, objectMapper.writeValueAsString(event)); + } catch (RuntimeException exception) { + // Broker chết không được làm hỏng việc đặt tour: đơn đã lưu xong rồi. + log.warn("Không đẩy được mail của đơn {} vào hàng đợi: {}", + event.code(), exception.getMessage()); + } + } + } + + // Người tiêu thụ hàng đợi. Ném lỗi thì message quay lại hàng đợi để thử lại — đó là lý do + // chính dùng hàng đợi thay vì gọi thẳng: máy chủ mail chập chờn thì việc không mất. + @Component + @ConditionalOnProperty(prefix = "tasks.jms", name = "enabled", havingValue = "true") + static class BookingMailListener { + + private final BookingMailService mailService; + private final ObjectMapper objectMapper; + + BookingMailListener(BookingMailService mailService, ObjectMapper objectMapper) { + this.mailService = mailService; + this.objectMapper = objectMapper; + } + + @JmsListener(destination = BOOKING_MAIL_QUEUE) + public void onMessage(String payload) { + mailService.send(objectMapper.readValue(payload, BookingEvent.class)); + } + } +} diff --git a/src/main/java/demo/tripgo/event/BookingNotifier.java b/src/main/java/demo/tripgo/event/BookingNotifier.java index 0b12bb8..97e40c1 100644 --- a/src/main/java/demo/tripgo/event/BookingNotifier.java +++ b/src/main/java/demo/tripgo/event/BookingNotifier.java @@ -1,6 +1,7 @@ package demo.tripgo.event; import demo.tripgo.config.WebSocketConfig; +import demo.tripgo.mail.BookingMailDispatcher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -18,19 +19,31 @@ public class BookingNotifier { private static final Logger log = LoggerFactory.getLogger(BookingNotifier.class); private final SimpMessagingTemplate messagingTemplate; + private final BookingMailDispatcher mailDispatcher; - public BookingNotifier(SimpMessagingTemplate messagingTemplate) { + public BookingNotifier( + SimpMessagingTemplate messagingTemplate, + BookingMailDispatcher mailDispatcher + ) { this.messagingTemplate = messagingTemplate; + this.mailDispatcher = mailDispatcher; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onBookingEvent(BookingEvent event) { + // Hai việc độc lập nhau, tách try riêng: báo realtime hỏng thì mail vẫn phải đi. try { messagingTemplate.convertAndSend(WebSocketConfig.TOPIC_BOOKINGS, event); } catch (RuntimeException exception) { // Thông báo hỏng không được làm hỏng nghiệp vụ: đơn đã lưu xong rồi. Chỉ ghi log. - log.warn("Không gửi được thông báo cho đơn {}: {}", + log.warn("Không gửi được thông báo realtime cho đơn {}: {}", event.code(), exception.getMessage()); } + + try { + mailDispatcher.dispatch(event); + } catch (RuntimeException exception) { + log.warn("Không xếp được mail của đơn {}: {}", event.code(), exception.getMessage()); + } } } diff --git a/src/main/java/demo/tripgo/mail/BookingMailDispatcher.java b/src/main/java/demo/tripgo/mail/BookingMailDispatcher.java new file mode 100644 index 0000000..6ce81bd --- /dev/null +++ b/src/main/java/demo/tripgo/mail/BookingMailDispatcher.java @@ -0,0 +1,12 @@ +package demo.tripgo.mail; + +import demo.tripgo.event.BookingEvent; + +// Đưa một việc gửi mail đi xử lý. Có hai bản cài: qua hàng đợi JMS, hoặc chạy nền trực tiếp. +// +// Tách interface để phần còn lại của ứng dụng không cần biết có broker hay không — bật/tắt JMS +// chỉ là đổi cấu hình, hành vi nhìn từ ngoài vẫn y hệt. +public interface BookingMailDispatcher { + + void dispatch(BookingEvent event); +} diff --git a/src/main/java/demo/tripgo/mail/BookingMailService.java b/src/main/java/demo/tripgo/mail/BookingMailService.java new file mode 100644 index 0000000..8422f9c --- /dev/null +++ b/src/main/java/demo/tripgo/mail/BookingMailService.java @@ -0,0 +1,29 @@ +package demo.tripgo.mail; + +import demo.tripgo.event.BookingEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +// Việc thật: soạn và gửi mail cho khách. +// +// Hiện chỉ ghi log — TripGo chưa nối máy chủ mail. Tách thành service riêng để khi nối thật +// (JavaMailSender, SendGrid...) chỉ phải sửa đúng chỗ này, còn phần hàng đợi giữ nguyên. +@Service +public class BookingMailService { + + private static final Logger log = LoggerFactory.getLogger(BookingMailService.class); + + public void send(BookingEvent event) { + log.info("[MAIL] gửi tới {} — {} (đơn {}, tour {})", + event.customerName(), subjectFor(event), event.code(), event.tourTitle()); + } + + private String subjectFor(BookingEvent event) { + return switch (event.kind()) { + case CREATED -> "TripGo đã nhận đơn đặt tour của bạn"; + case CONFIRMED -> "Đơn đặt tour của bạn đã được xác nhận"; + case CANCELLED -> "Đơn đặt tour của bạn đã bị huỷ"; + }; + } +} diff --git a/src/main/java/demo/tripgo/mail/DirectBookingMailDispatcher.java b/src/main/java/demo/tripgo/mail/DirectBookingMailDispatcher.java new file mode 100644 index 0000000..84ba547 --- /dev/null +++ b/src/main/java/demo/tripgo/mail/DirectBookingMailDispatcher.java @@ -0,0 +1,29 @@ +package demo.tripgo.mail; + +import demo.tripgo.config.AsyncConfig; +import demo.tripgo.event.BookingEvent; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +// Bản dự phòng khi KHÔNG bật JMS: gửi thẳng, nhưng vẫn chạy trên pool nền nên request của khách +// không phải chờ gửi mail xong. +// +// Nhờ có bản này mà môi trường dev và test không cần dựng broker, và việc gửi mail không lặng lẽ +// biến mất khi ai đó quên bật JMS. +@Component +@ConditionalOnMissingBean(name = "jmsBookingMailDispatcher") +public class DirectBookingMailDispatcher implements BookingMailDispatcher { + + private final BookingMailService mailService; + + public DirectBookingMailDispatcher(BookingMailService mailService) { + this.mailService = mailService; + } + + @Override + @Async(AsyncConfig.TASK_EXECUTOR) + public void dispatch(BookingEvent event) { + mailService.send(event); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2b033dd..b475e08 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,17 @@ spring: + # Hàng đợi gửi mail (xem JmsConfig). Chỉ dùng khi tasks.jms.enabled=true. + activemq: + broker-url: ${MQ_URL:tcp://localhost:61616} + user: ${MQ_USER:admin} + password: ${MQ_PASSWORD:admin} + jms: + # Hàng đợi point-to-point: mỗi message chỉ một người tiêu thụ xử lý. + pub-sub-domain: false + listener: + session: + # Listener ném lỗi thì message quay lại hàng đợi để thử lại. + transacted: true + application: name: tripgo @@ -39,6 +52,10 @@ cors: # Job dọn dẹp định kỳ và pool luồng chạy nền (xem MaintenanceScheduler, AsyncConfig). tasks: + # MẶC ĐỊNH TẮT: 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. Tắt thì DirectBookingMailDispatcher làm thay, việc gửi mail không mất. + jms: + enabled: ${JMS_ENABLED:false} maintenance: enabled: ${TASKS_ENABLED:true} # Đơn chờ xác nhận quá số giờ này sẽ bị tự huỷ và hoàn chỗ. diff --git a/src/test/java/demo/tripgo/mail/BookingMailDispatchTest.java b/src/test/java/demo/tripgo/mail/BookingMailDispatchTest.java new file mode 100644 index 0000000..59830bd --- /dev/null +++ b/src/test/java/demo/tripgo/mail/BookingMailDispatchTest.java @@ -0,0 +1,86 @@ +package demo.tripgo.mail; + +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.event.BookingEvent; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +// Hai nhánh của việc gửi mail: qua hàng đợi và gửi thẳng. Cả hai phải cho kết quả như nhau nhìn +// từ bên ngoài — đó là lý do tách interface BookingMailDispatcher. +class BookingMailDispatchTest { + + private static BookingEvent event() { + return new BookingEvent(BookingEvent.Kind.CREATED, 1L, "TG-2026-000001", "Khách", + "Đà Nẵng 3N2Đ", new BigDecimal("1000000"), BookingStatus.PENDING, LocalDateTime.now()); + } + + // ---- Không bật JMS (mặc định) ---- + + @Nested + @SpringBootTest + @ActiveProfiles("test") + class WithoutJms { + + @Autowired ApplicationContext context; + @Autowired BookingMailDispatcher dispatcher; + @MockitoBean BookingMailService mailService; + + @Test + void usesDirectDispatcherAndStillSendsMail() { + assertThat(dispatcher).isInstanceOf(DirectBookingMailDispatcher.class); + // Không có broker thì cũng không được tạo bean JMS nào. + assertThat(context.containsBean("jmsBookingMailDispatcher")).isFalse(); + assertThat(context.getBeanNamesForType(org.springframework.jms.core.JmsTemplate.class)) + .as("JmsTemplate vẫn được autoconfigure tạo, nhưng không ai dùng tới") + .isNotNull(); + + dispatcher.dispatch(event()); + + // Chạy trên pool nền nên phải chờ một nhịp. + verify(mailService, timeout(2000)).send(any(BookingEvent.class)); + } + } + + // ---- Bật JMS, broker nhúng vm:// ---- + + @Nested + @SpringBootTest(properties = { + "tasks.jms.enabled=true", + // vm:// dựng broker ngay trong tiến trình test, không cần ActiveMQ bên ngoài. + "spring.activemq.broker-url=vm://tripgo-test?broker.persistent=false&broker.useJmx=false", + "spring.activemq.user=", + "spring.activemq.password=" + }) + @ActiveProfiles("test") + class WithJms { + + @Autowired BookingMailDispatcher dispatcher; + @MockitoBean BookingMailService mailService; + + // Gói tin đi qua hàng đợi thật rồi mới tới người tiêu thụ. + @Test + void messageTravelsThroughQueueToListener() { + dispatcher.dispatch(event()); + + verify(mailService, timeout(5000)).send(any(BookingEvent.class)); + } + + @Test + void usesJmsDispatcherNotTheDirectOne() { + assertThat(dispatcher).isNotInstanceOf(DirectBookingMailDispatcher.class); + } + } +} From bfac43400bca1c3b8e43128066f3ee4a2b821d33 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Mon, 28 Sep 2026 10:36:35 +0700 Subject: [PATCH 19/23] SOAP cho he thong doi tac tra cuu cho trong (Spring-WS) getTourAvailability(tourSlug, fromDate?) tra ve danh sach ngay khoi hanh kem so cho con trong. WSDL o /ws/tourAvailability.wsdl. Contract-first that su: src/main/resources/soap/tripgo.xsd la nguon su that duy nhat. Spring-WS sinh WSDL tu do, jaxb2-maven-plugin sinh lop Java tu do - WSDL cong bo cho doi tac va lop xu ly request khong the lech nhau. Endpoint mo cong khai vi client SOAP khong co phien dang nhap de mang theo. Mo duoc la vi no CHI DOC va chi tra so lieu cho trong, khong co du lieu khach hang. Tour da xoa mem khong lo ra (di qua findIdBySlug da loc san). Slug sai tra SOAP Fault dang CLIENT chu khong phai SERVER: doi tac biet la phai sua request chu khong phai thu lai. SOAP chay tren servlet RIENG (MessageDispatcherServlet), khong di qua DispatcherServlet cua MVC, nen phai dang ky tuong minh. Test gui SOAP request that qua MockWebServiceClient, di qua dung lop marshal/unmarshal ma client doi tac se dung. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 15 ++ pom.xml | 39 +++++ .../demo/tripgo/config/SecurityConfig.java | 4 + .../demo/tripgo/config/WebServiceConfig.java | 51 +++++++ .../tripgo/soap/TourAvailabilityEndpoint.java | 97 +++++++++++++ src/main/resources/soap/tripgo.xsd | 45 ++++++ .../tripgo/soap/TourAvailabilitySoapTest.java | 133 ++++++++++++++++++ 7 files changed, 384 insertions(+) create mode 100644 src/main/java/demo/tripgo/config/WebServiceConfig.java create mode 100644 src/main/java/demo/tripgo/soap/TourAvailabilityEndpoint.java create mode 100644 src/main/resources/soap/tripgo.xsd create mode 100644 src/test/java/demo/tripgo/soap/TourAvailabilitySoapTest.java diff --git a/README.md b/README.md index 27ba5ca..9a2b0f4 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,21 @@ tài khoản đó** để giữ nguyên wishlist và đơn hàng cũ. 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. diff --git a/pom.xml b/pom.xml index 883b05a..4222c98 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,22 @@ + + + org.springframework.boot + spring-boot-starter-web-services + + + wsdl4j + wsdl4j + + + + org.springframework.ws + spring-ws-test + test + + @@ -186,6 +202,29 @@ + + + org.codehaus.mojo + jaxb2-maven-plugin + 3.2.0 + + + xjc + + xjc + + + + + + src/main/resources/soap/tripgo.xsd + + ${project.build.directory}/generated-sources/jaxb + false + + org.springframework.boot spring-boot-maven-plugin diff --git a/src/main/java/demo/tripgo/config/SecurityConfig.java b/src/main/java/demo/tripgo/config/SecurityConfig.java index 299d232..24f874c 100644 --- a/src/main/java/demo/tripgo/config/SecurityConfig.java +++ b/src/main/java/demo/tripgo/config/SecurityConfig.java @@ -124,6 +124,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { // Ảnh tour do admin tải lên: trang bán hàng công khai phải xem được. .requestMatchers(HttpMethod.GET, "/uploads/**").permitAll() + // SOAP cho hệ thống đối tác: client SOAP không có phiên đăng nhập để mang theo. + // Mở được vì endpoint chỉ đọc và chỉ trả số liệu chỗ trống, không có dữ liệu khách. + .requestMatchers(WebServiceConfig.PATH + "/**").permitAll() + // Cho phép người chưa đăng nhập gọi API đăng ký và đăng nhập. // Matcher phải gồm cả tiền tố /api/v1: không còn context-path nên servlet // container không tách phần này ra nữa (xem ApiPathConfig). diff --git a/src/main/java/demo/tripgo/config/WebServiceConfig.java b/src/main/java/demo/tripgo/config/WebServiceConfig.java new file mode 100644 index 0000000..bb4f1b1 --- /dev/null +++ b/src/main/java/demo/tripgo/config/WebServiceConfig.java @@ -0,0 +1,51 @@ +package demo.tripgo.config; + +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.ws.config.annotation.EnableWs; +import org.springframework.ws.transport.http.MessageDispatcherServlet; +import org.springframework.ws.wsdl.wsdl11.DefaultWsdl11Definition; +import org.springframework.xml.xsd.SimpleXsdSchema; +import org.springframework.xml.xsd.XsdSchema; + +// Dịch vụ SOAP cho hệ thống đối tác. +// +// SOAP chạy trên servlet RIÊNG (MessageDispatcherServlet) chứ không đi qua DispatcherServlet của +// MVC, nên phải đăng ký tường minh ở đây. +@Configuration +@EnableWs +public class WebServiceConfig { + + public static final String PATH = "/ws"; + + @Bean + public ServletRegistrationBean messageDispatcherServlet( + ApplicationContext applicationContext + ) { + MessageDispatcherServlet servlet = new MessageDispatcherServlet(); + servlet.setApplicationContext(applicationContext); + // Cho phép servlet tự phục vụ file WSDL/XSD theo đường dẫn. + servlet.setTransformWsdlLocations(true); + return new ServletRegistrationBean<>(servlet, PATH + "/*"); + } + + // WSDL sinh từ XSD chứ không viết tay: đổi hợp đồng chỉ cần sửa XSD, WSDL công bố cho đối tác + // tự khớp theo. + @Bean(name = "tourAvailability") + public DefaultWsdl11Definition tourAvailabilityWsdl(XsdSchema tripgoSchema) { + DefaultWsdl11Definition definition = new DefaultWsdl11Definition(); + definition.setPortTypeName("TourAvailabilityPort"); + definition.setLocationUri(PATH); + definition.setTargetNamespace("http://tripgo.demo/soap"); + definition.setSchema(tripgoSchema); + return definition; + } + + @Bean + public XsdSchema tripgoSchema() { + return new SimpleXsdSchema(new ClassPathResource("soap/tripgo.xsd")); + } +} diff --git a/src/main/java/demo/tripgo/soap/TourAvailabilityEndpoint.java b/src/main/java/demo/tripgo/soap/TourAvailabilityEndpoint.java new file mode 100644 index 0000000..583b687 --- /dev/null +++ b/src/main/java/demo/tripgo/soap/TourAvailabilityEndpoint.java @@ -0,0 +1,97 @@ +package demo.tripgo.soap; + +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Tour; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.TourRepository; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.ws.server.endpoint.annotation.Endpoint; +import org.springframework.ws.server.endpoint.annotation.PayloadRoot; +import org.springframework.ws.server.endpoint.annotation.RequestPayload; +import org.springframework.ws.server.endpoint.annotation.ResponsePayload; +import org.springframework.ws.soap.server.endpoint.annotation.FaultCode; +import org.springframework.ws.soap.server.endpoint.annotation.SoapFault; + +import java.time.LocalDate; +import java.util.List; + +// Tra cứu chỗ trống cho hệ thống đối tác. +// +// Chỉ đọc và chỉ trả số liệu chỗ trống — không có thông tin khách hàng. Đó là lý do endpoint này +// mở công khai được: client SOAP không có phiên đăng nhập để mang theo. +@Endpoint +public class TourAvailabilityEndpoint { + + private static final String NAMESPACE = "http://tripgo.demo/soap"; + + private final TourRepository tourRepository; + private final DepartureRepository departureRepository; + + public TourAvailabilityEndpoint( + TourRepository tourRepository, + DepartureRepository departureRepository + ) { + this.tourRepository = tourRepository; + this.departureRepository = departureRepository; + } + + @PayloadRoot(namespace = NAMESPACE, localPart = "getTourAvailabilityRequest") + @ResponsePayload + @Transactional(readOnly = true) + public GetTourAvailabilityResponse getAvailability( + @RequestPayload GetTourAvailabilityRequest request + ) { + // findIdBySlug đã lọc tour xoá mềm, nên tour trong thùng rác không lộ ra ngoài. + Long tourId = tourRepository.findIdBySlug(nullSafe(request.getTourSlug())) + .orElseThrow(() -> new TourNotFoundException( + "Không tìm thấy tour với slug '" + request.getTourSlug() + "'")); + Tour tour = tourRepository.findActiveById(tourId).orElseThrow(); + + LocalDate from = request.getFromDate() == null + ? LocalDate.now() + : LocalDate.of(request.getFromDate().getYear(), + request.getFromDate().getMonth(), request.getFromDate().getDay()); + + List departures = departureRepository + .findByTourIdAndDepartureDateGreaterThanEqualOrderByDepartureDateAsc(tourId, from); + + GetTourAvailabilityResponse response = new GetTourAvailabilityResponse(); + response.setTourSlug(tour.getSlug()); + response.setTourTitle(tour.getTitle()); + departures.stream().map(TourAvailabilityEndpoint::toPayload) + .forEach(response.getDeparture()::add); + return response; + } + + private static demo.tripgo.soap.Departure toPayload(Departure departure) { + demo.tripgo.soap.Departure payload = new demo.tripgo.soap.Departure(); + payload.setDate(toXml(departure.getDepartureDate())); + payload.setTotalSeats(departure.getTotalSeats()); + payload.setRemainingSeats(departure.getRemainingSeats()); + return payload; + } + + private static javax.xml.datatype.XMLGregorianCalendar toXml(LocalDate date) { + try { + return javax.xml.datatype.DatatypeFactory.newInstance() + .newXMLGregorianCalendarDate( + date.getYear(), date.getMonthValue(), date.getDayOfMonth(), + javax.xml.datatype.DatatypeConstants.FIELD_UNDEFINED); + } catch (javax.xml.datatype.DatatypeConfigurationException exception) { + throw new IllegalStateException("Không dựng được kiểu ngày của XML", exception); + } + } + + private static String nullSafe(String value) { + return value == null ? "" : value.trim(); + } + + // Slug không tồn tại là lỗi của bên gọi, nên 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. + @SoapFault(faultCode = FaultCode.CLIENT) + static class TourNotFoundException extends RuntimeException { + TourNotFoundException(String message) { + super(message); + } + } +} diff --git a/src/main/resources/soap/tripgo.xsd b/src/main/resources/soap/tripgo.xsd new file mode 100644 index 0000000..315b1f0 --- /dev/null +++ b/src/main/resources/soap/tripgo.xsd @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/demo/tripgo/soap/TourAvailabilitySoapTest.java b/src/test/java/demo/tripgo/soap/TourAvailabilitySoapTest.java new file mode 100644 index 0000000..08418c7 --- /dev/null +++ b/src/test/java/demo/tripgo/soap/TourAvailabilitySoapTest.java @@ -0,0 +1,133 @@ +package demo.tripgo.soap; + +import demo.tripgo.TestDataCleaner; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Tour; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.ws.test.server.MockWebServiceClient; +import org.springframework.xml.transform.StringSource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.springframework.ws.test.server.RequestCreators.withPayload; +import static org.springframework.ws.test.server.ResponseMatchers.clientOrSenderFault; +import static org.springframework.ws.test.server.ResponseMatchers.xpath; + +// Gửi SOAP request thật vào endpoint qua MockWebServiceClient — đi qua đúng lớp unmarshal/marshal +// mà client đối tác sẽ dùng, chứ không gọi thẳng method Java. +@SpringBootTest +@ActiveProfiles("test") +class TourAvailabilitySoapTest { + + private static final String NS = "http://tripgo.demo/soap"; + + @Autowired TestDataCleaner cleaner; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired DepartureRepository departures; + @Autowired org.springframework.context.ApplicationContext context; + + private MockWebServiceClient client; + private Tour tour; + + @BeforeEach + void setUp() { + cleaner.clean(); + client = MockWebServiceClient.createClient(context); + + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = new Category(); + category.setSlug("beach"); + category.setName("Biển đảo"); + category = categories.save(category); + + tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("1000000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + saveDeparture(LocalDate.now().plusDays(10), 20, 5); + saveDeparture(LocalDate.now().plusDays(40), 15, 15); + // Ngày đã qua: không được trả về khi không truyền fromDate. + saveDeparture(LocalDate.now().minusDays(5), 10, 0); + } + + @Test + void returnsUpcomingDeparturesWithRemainingSeats() { + client.sendRequest(withPayload(request("da-nang-3n2d", null))) + .andExpect(xpath("//tns:tourTitle", ns()).evaluatesTo("Đà Nẵng 3N2Đ")) + // Chỉ 2 ngày sắp tới, ngày đã qua bị loại. + .andExpect(xpath("count(//tns:departure)", ns()).evaluatesTo(2D)) + .andExpect(xpath("(//tns:departure)[1]/tns:remainingSeats", ns()).evaluatesTo(15D)) + // Chuyến đã đầy vẫn trả về, với 0 chỗ trống. + .andExpect(xpath("(//tns:departure)[2]/tns:remainingSeats", ns()).evaluatesTo(0D)); + } + + @Test + void filtersByFromDate() { + client.sendRequest(withPayload(request("da-nang-3n2d", LocalDate.now().plusDays(20)))) + .andExpect(xpath("count(//tns:departure)", ns()).evaluatesTo(1D)); + } + + // Slug sai là lỗi của bên gọi -> SOAP Fault dạng CLIENT, để đối tác biết phải sửa request. + @Test + void unknownSlugReturnsClientFault() { + client.sendRequest(withPayload(request("khong-co-that", null))) + .andExpect(clientOrSenderFault()); + } + + // Tour đã xoá mềm không được lộ ra cho đối tác. + @Test + void softDeletedTourIsNotVisible() { + tour.setDeletedAt(LocalDateTime.now()); + tours.saveAndFlush(tour); + + client.sendRequest(withPayload(request("da-nang-3n2d", null))) + .andExpect(clientOrSenderFault()); + } + + private StringSource request(String slug, LocalDate fromDate) { + String from = fromDate == null ? "" : "%s".formatted(fromDate); + return new StringSource(""" + + %s + %s + + """.formatted(NS, slug, from)); + } + + private java.util.Map ns() { + return java.util.Map.of("tns", NS); + } + + private void saveDeparture(LocalDate date, int total, int booked) { + Departure departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(date); + departure.setTotalSeats(total); + departure.setBookedSeats(booked); + departures.save(departure); + } +} From 78eabca8248cfafceb54a267c68b442baa7fb169 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Tue, 29 Sep 2026 10:38:11 +0700 Subject: [PATCH 20/23] Sua thong bao realtime khong bao gio hien len duoc Class .toast trung ten voi component cua Bootstrap, ma Bootstrap co quy tac .toast:not(.show){display:none}. The thong bao cua minh khong bao gio duoc gan class .show nen bi an sach - khong loi, khong canh bao, chi la khong thay gi. Doi sang tien to tg-notice-*. Vi sao khong ai phat hien: luc lam WebSocket minh kiem bang client STOMP viet bang Python, thay goi tin ve dung la cho la xong. Phan hien len man hinh chua bao gio duoc mo bang trinh duyet that. Lan nay chup man hinh trang quan tri moi lo ra. Bai hoc: test xanh + goi tin toi noi van chua co nghia la nguoi dung THAY duoc. Da ghi chu ngay trong admin.css de sau nay khong ai doi ten lai. Co-Authored-By: Claude Opus 5 (1M context) --- BAI-HOC-DEMO.md | 644 ++++++++++++++++++ src/main/resources/static/css/admin.css | 19 +- .../static/js/admin-notifications.js | 3 +- .../resources/templates/admin/layout.html | 2 +- 4 files changed, 658 insertions(+), 10 deletions(-) create mode 100644 BAI-HOC-DEMO.md diff --git a/BAI-HOC-DEMO.md b/BAI-HOC-DEMO.md new file mode 100644 index 0000000..1a1e1a5 --- /dev/null +++ b/BAI-HOC-DEMO.md @@ -0,0 +1,644 @@ +# TripGo — Bản đồ bài học mini project và kịch bản demo + +Tài liệu này trả lời hai câu hỏi: + +1. Mỗi chức năng của đề bài mini project (ứng dụng theo dõi tương tác Facebook/X) được áp dụng + vào **chỗ nào** trong TripGo? +2. Demo thế nào và **giải thích ra sao** để trainer thấy mình hiểu chứ không chỉ chạy được. + +> **Nói trước hai khác biệt so với đề bài**, để không ai hiểu nhầm là đã làm nhưng thực ra chưa: +> +> - **Spring Boot 4.1.1**, không phải 3.x. TripGo có sẵn từ trước và đã dùng bản này. Hệ quả thật: +> `@MockBean` đã bị gỡ (dùng `@MockitoBean`), `spring-boot-starter-web` đổi tên thành +> `spring-boot-starter-webmvc`, Jackson lên phiên bản 3 (`tools.jackson.databind`). +> - **Chỉ có đăng nhập Facebook, chưa có X (Twitter)**. Phần khung đã sẵn sàng cho nhà cung cấp +> thứ hai (xem mục 1), thêm X chỉ là thêm một `ClientRegistration`. + +--- + +## Bảng đối chiếu nhanh + +| # | Bài học trong đề | Áp dụng vào TripGo | Trạng thái | +|---|---|---|---| +| 1 | Social Login (FB, TW) | Đăng nhập Facebook cho khách, đổi sang JWT của TripGo | ✅ (chưa có X) | +| 2 | Import Excel danh sách bài viết | Nhập **tour** hàng loạt từ Excel | ✅ | +| 3 | Background job crawl & update metrics | Job nền: tự huỷ đơn quá hạn, tính lại đánh giá | ⚠️ khác bản chất | +| 4 | Hiển thị biểu đồ (Chart.js) | 2 biểu đồ trên dashboard quản trị | ✅ | +| 5 | Thông báo realtime qua WebSocket | Báo admin khi có đơn mới / đổi trạng thái | ✅ | +| 6 | Xuất kết quả ra Excel | Xuất đơn đặt, tour, doanh thu tháng | ✅ | +| 7 | Bảo mật với CSRF | Bật cho khu quản trị, tắt cho API stateless | ✅ | +| 8 | Multithreading & JMS | `ThreadPoolTaskExecutor` + hàng đợi gửi mail ActiveMQ | ✅ | +| 9 | Reflection cho export/import động | `@ExcelColumn` + `ExcelMapper` | ✅ | + +**Mục 3 khác bản chất** — nói thẳng chỗ này với trainer: đề bài crawl dữ liệu từ **API bên ngoài** +(Facebook/X), còn TripGo không có hệ thống ngoài nào để crawl. Nên phần áp dụng là **cơ chế job nền** +(`@Scheduled` + `@Async` + pool riêng + tắt được + một việc hỏng không đổ cả mẻ), còn nội dung công +việc là dọn dẹp dữ liệu nội bộ. Chi tiết ở mục 3. + +--- + +## Bảng đối chiếu yêu cầu kỹ thuật + +| Hạng mục | Yêu cầu | TripGo dùng gì | Ở đâu | +|---|---|---|---| +| Framework | Spring Boot 3.x | Spring Boot **4.1.1** | `pom.xml` | +| Security | Spring Security + OAuth2 + CSRF | 3 `SecurityFilterChain` tách biệt | `SecurityConfig`, `AdminSecurityConfig`, `SocialLoginConfig` | +| Database | MySQL / PostgreSQL | PostgreSQL 16 | `docker-compose.yml` | +| Messaging | JMS (ActiveMQ / RabbitMQ) | ActiveMQ Classic | `JmsConfig` | +| Realtime | WebSocket + STOMP | STOMP trên WebSocket thuần | `WebSocketConfig` | +| Scheduler | `@Scheduled` + `@Async` | Cả hai | `MaintenanceScheduler` | +| Multithread | `ThreadPoolTaskExecutor` | Pool riêng, có chính sách từ chối | `AsyncConfig` | +| File Handling | Apache POI | POI 5.5.1, tầng generic | `excel/ExcelMapper` | +| WebService | SOAP (Spring-WS) | Contract-first từ XSD | `WebServiceConfig`, `soap/` | +| Chart | Chart.js hoặc ECharts | Chart.js 4.4.4 | `static/js/admin-charts.js` | +| Testing | JUnit5 + Mockito + MockMvc | **286 test**, cả Testcontainers | `src/test/` | +| API Docs | Swagger / OpenAPI | springdoc-openapi | `/swagger-ui.html` | +| Logging | SLF4J + Logback | Mặc định của Spring Boot | toàn bộ service | +| Frontend | Thymeleaf hoặc React | Thymeleaf + Bootstrap 5 | `templates/admin/` | + +--- + +# 1. Social Login + +## Áp dụng vào đâu + +Khách đăng nhập TripGo bằng Facebook thay vì phải đăng ký email/mật khẩu. + +| File | Việc | +|---|---| +| `config/SocialLoginConfig` | Khai `ClientRegistration` cho Facebook + chain bảo mật riêng | +| `security/SocialLoginUserService` | Sau khi Facebook xác thực xong: tìm-hoặc-tạo user trong bảng `users` | +| `security/OAuth2LoginSuccessHandler` | **Đổi sang JWT của TripGo** | +| `security/SocialUserAttributes` | Chuẩn hoá dữ liệu thô của Facebook | +| `entity/AuthProvider` | `LOCAL` / `FACEBOOK` — thêm Google/X chỉ là thêm hằng số | + +## Demo + +```bash +# .env phải có FACEBOOK_CLIENT_ID và FACEBOOK_CLIENT_SECRET +set -a; source .env; set +a && ./mvnw spring-boot:run +``` + +Mở trình duyệt: `http://localhost:8080/oauth2/authorization/facebook` + +Sau khi đồng ý trên Facebook, callback trả thẳng JSON: + +```json +{ "token": "", "user": { "id": 42, "email": "...", "name": "..." } } +``` + +Copy `token` đó rồi gọi một API cần đăng nhập để chứng minh nó dùng được thật: + +```bash +curl -H "Authorization: Bearer " http://localhost:8080/api/v1/auth/me +``` + +## Giải thích gì + +**Vấn đề cốt lõi**: OAuth2 cần **session** để giữ tham số `state` giữa hai request (lúc chuyển sang +Facebook và lúc Facebook gọi về). Nhưng API của TripGo là **stateless JWT**. Hai thứ này xung khắc. + +**Cách giải quyết**: một `SecurityFilterChain` riêng chỉ nhận `/oauth2/**` và `/login/oauth2/**`, +được phép có session; xong bắt tay thì `OAuth2LoginSuccessHandler` phát JWT và **huỷ session ngay**. +Từ đó trở đi client chỉ dùng JWT như mọi endpoint khác. + +**Ba chi tiết đáng nói:** + +1. **Email trùng thì gắn vào tài khoản cũ.** Khách từng đăng ký bằng `a@x.com`, giờ đăng nhập + Facebook cũng `a@x.com` → gắn Facebook vào chính tài khoản đó, giữ nguyên wishlist và đơn hàng. + Nếu email đã thuộc một tài khoản Facebook **khác** thì dừng lại — không cướp tài khoản người ta. +2. **Không có email vẫn đăng nhập được.** App Facebook mới chưa được duyệt quyền `email`. Cột + `email` là `NOT NULL + unique`, nên hệ thống tự sinh địa chỉ giữ chỗ `facebook.@facebook.local`. +3. **PKCE** được bật (`code_challenge_method=S256`): kể cả khi mã authorization bị lộ trên đường + redirect, thiếu `code_verifier` vẫn không đổi được token. + +## Trainer hay hỏi + +> *"Tài khoản Facebook không có mật khẩu thì `POST /auth/login` xử lý sao?"* + +`AuthService.login` kiểm tra `user.getPassword() == null` **trước** khi so khớp, và trả lời rõ +*"Tài khoản này đăng nhập bằng Facebook"*. Không kiểm thì `passwordEncoder.matches(raw, null)` sẽ +nổ hoặc trả sai lệch. + +> *"Thêm Twitter/X thì phải sửa gì?"* + +Thêm một `ClientRegistration` trong `SocialLoginConfig`, thêm hằng số vào `AuthProvider`, và thêm +một nhánh `case X ->` trong `SocialUserAttributes.of()`. Không đụng tới phần còn lại. + +--- + +# 2. Import Excel + +## Áp dụng vào đâu + +Đề bài nhập danh sách **bài viết**; TripGo nhập danh sách **tour** — cùng một bài toán. + +| File | Việc | +|---|---| +| `admin/AdminTourImportController` | Trang `/admin/tours/import`, nút tải file mẫu | +| `service/TourImportService` | Điều phối: đọc file → lưu từng dòng → tổng kết | +| `service/TourRowImporter` | Lưu **một** dòng, trong transaction riêng | +| `admin/excel/TourImportRow` | Khai các cột bằng `@ExcelColumn` | + +## Demo + +1. Vào `/admin/tours` → **Nhập Excel** +2. Bấm **Tải file mẫu** → mở ra, thêm vài dòng, **cố tình làm sai 2–3 dòng** + (sai slug điểm đến, giá KM cao hơn giá gốc, để trống cột Giá) +3. Tải lên → màn hình hiện: + +``` +Tổng dòng: 5 Đã nhập: 2 Bỏ qua: 3 + dòng 4: không có điểm đến với slug 'khong-ton-tai' + dòng 5: giá khuyến mãi phải nhỏ hơn giá gốc + dòng 6: thiếu giá trị ở cột bắt buộc 'Giá' +``` + +4. **Tải lên lại đúng file đó** → tất cả bị bỏ qua vì trùng slug, không tạo bản sao + +## Giải thích gì + +**Quyết định 1 — nhập được dòng nào hay dòng đó.** Từ chối cả file chỉ vì một ô gõ nhầm sẽ biến +việc nhập 200 tour thành vòng lặp sửa-thử vô tận. Số dòng báo lỗi **khớp đúng thanh số dòng trong +Excel** để người dùng tìm được chỗ sửa. + +**Quyết định 2 — mỗi dòng một transaction** (`REQUIRES_NEW` trong `TourRowImporter`). Để cả file +trong một transaction thì một dòng vi phạm ràng buộc DB sẽ **rollback những dòng đã lưu thành công**. + +**Quyết định 3 — trùng slug thì bỏ qua, không ghi đè.** Chạy lại cùng một file không tạo bản sao, +và không lặng lẽ đè lên dữ liệu admin đã sửa tay. + +## Trainer hay hỏi + +> *"Vì sao `TourRowImporter` là một bean riêng chứ không phải method private?"* + +**Đây là câu đáng giá nhất của mục này.** `@Transactional` hoạt động qua proxy. Gọi một method +`@Transactional` từ **chính lớp đó** (self-invocation) sẽ **không mở transaction nào cả** — không +báo lỗi, không có triệu chứng, chỉ âm thầm sai cho tới lúc dữ liệu hỏng. Tách sang bean khác thì +lời gọi đi qua proxy và annotation mới có tác dụng. + +> *"Người dùng đảo thứ tự cột thì sao?"* + +Cột được khớp theo **tên tiêu đề**, không theo vị trí. Thừa cột cũng không sao. + +--- + +# 3. Background job + +## Áp dụng vào đâu — và khác đề bài chỗ nào + +Đề bài: mỗi giờ gọi API Facebook/X lấy số like/share về lưu, để vẽ được biểu đồ theo thời gian. + +TripGo **không có hệ thống ngoài nào để crawl**. Nên phần áp dụng là **cơ chế**, còn nội dung công +việc là dọn dẹp nội bộ: + +| Job | Nhịp | Việc | +|---|---|---| +| Tự huỷ đơn quá hạn | 1 giờ | Đơn `PENDING` quá 72h → huỷ và **hoàn chỗ** cho khách khác | +| Tính lại đánh giá | 6 giờ | So `rating_avg`/`review_count` với bảng `reviews`, sửa tour bị lệch | + +| File | Việc | +|---|---| +| `config/MaintenanceScheduler` | Lịch chạy (`@Scheduled` + `@Async`) | +| `service/MaintenanceService` | Việc thật — tách ra để test gọi thẳng, không phải chờ tới giờ | +| `config/AsyncConfig` | `ThreadPoolTaskExecutor` riêng | + +## Demo + +Job chạy mỗi giờ nên **không ngồi chờ được**. Hai cách demo: + +**Cách 1 — rút ngắn chu kỳ** (thuyết phục nhất): + +```bash +BOOKING_EXPIRE_HOURS=0 \ + ./mvnw spring-boot:run -Dspring-boot.run.arguments=--tasks.maintenance.initial-delay=PT5S,--tasks.maintenance.cancel-expired-delay=PT10S +``` + +Đặt một đơn qua API, chờ ~15 giây, xem log: + +``` +Đã tự huỷ 1 đơn chờ xác nhận quá 0 giờ +``` + +Rồi mở `/admin/bookings` thấy đơn chuyển sang **Đã huỷ**, và số chỗ trống của ngày khởi hành đã tăng lại. + +**Cách 2 — chạy test**, nhanh và chắc chắn: + +```bash +./mvnw -Dtest=MaintenanceServiceIntegrationTest test +``` + +6 test, trong đó có `runningTwiceIsIdempotent` (chạy hai lần không trừ chỗ hai lần). + +## Giải thích gì + +**`fixedDelay` chứ không `fixedRate`.** `fixedRate` đếm 1 giờ từ lúc **bắt đầu**; job chạy 70 phút +với chu kỳ 60 phút sẽ tự chồng lên chính nó. `fixedDelay` đếm từ lúc **kết thúc**. + +**Pool riêng, không dùng executor mặc định.** Mặc định của Spring Boot là `SimpleAsyncTaskExecutor` +— nó tạo **một luồng mới cho mỗi lần gọi, không giới hạn**. Một job chạy lâu gặp lúc nhiều việc dồn +lại sẽ sinh luồng mất kiểm soát. Pool của TripGo giới hạn 2–4 luồng, hàng đợi 50, và khi đầy thì +`CallerRunsPolicy` (chạy ngay trên luồng gọi) — **chậm còn hơn mất việc**. + +**Một đơn hỏng không kéo đổ cả mẻ.** Vòng lặp bắt lỗi từng đơn và ghi log, thay vì gom cả mẻ vào +một transaction. + +**Job tắt trong profile test.** Nó chạy xen vào giữa test sẽ sửa dữ liệu ngay dưới chân test và +sinh lỗi chập chờn rất khó lần. + +## Trainer hay hỏi + +> *"Sao không crawl gì cả?"* + +Trả lời thẳng: TripGo không có hệ thống ngoài để crawl, nên mình áp dụng đúng **cơ chế** job nền +vào bài toán có thật của dự án. Nếu cần crawl thật, chỗ cắm vào là `MaintenanceService` — thêm một +method và một `@Scheduled`, phần hạ tầng (pool, tắt/bật, chống lỗi) dùng lại nguyên vẹn. + +--- + +# 4. Biểu đồ (Chart.js) + +## Áp dụng vào đâu + +Hai biểu đồ trên dashboard `/admin`: + +- **Doanh thu 6 tháng gần nhất** (cột dọc) +- **5 tour doanh thu cao nhất tháng này** (cột ngang) + +| File | Việc | +|---|---| +| `service/DashboardService.charts()` | Gom dữ liệu | +| `admin/AdminDashboardController` | `GET /admin/reports/charts` trả JSON | +| `static/js/admin-charts.js` | Vẽ biểu đồ | + +## Demo + +Mở `/admin`, chỉ vào hai biểu đồ, rồi bấm **"Xem dạng bảng"** dưới mỗi biểu đồ. + +## Giải thích gì + +**Mỗi 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 khác khi chúng cùng +ý nghĩa là gán màu theo **thứ hạng** chứ không theo dữ liệu, và làm người đọc tưởng màu mang thông +tin gì đó. + +**Tháng không có đơn nào vẫn hiện cột 0.** Bỏ hẳn cột đi sẽ làm trục thời gian nói dối — nhìn như +tháng đó không tồn tại. + +**Mỗi biểu đồ kèm một bảng số liệu.** Biểu đồ không được là cách **duy nhất** đọc được con số — +người dùng trình đọc màn hình, và cả lúc in ra giấy. + +**Ba lỗi chỉ phát hiện được khi chụp màn hình ra nhìn**, test không bắt được: + +1. Nhãn trục `10.000.000` dài tới mức Chart.js phải xoay chéo, xoay xong thì các nhãn chồng lên + nhau → rút về `10 tr` +2. Đường lưới cắt ngang qua cột thay vì lùi về sau → thêm `z: -1` +3. Token màu chế độ tối bị áp theo `prefers-color-scheme`, trong khi khu quản trị chỉ có giao diện + sáng → máy nào để OS dark mode sẽ thấy biểu đồ màu tối trên thẻ trắng + +Điểm 3 là bài học đáng kể: **test xanh không có nghĩa là giao diện đúng.** + +## Trainer hay hỏi + +> *"Sao không dùng biểu đồ tròn cho top tour?"* + +Biểu đồ tròn rất khó so sánh các giá trị gần nhau. Cột ngang đọc được ngay thứ tự, và tên tour dài +có chỗ để hiển thị. + +--- + +# 5. WebSocket + STOMP + +## Áp dụng vào đâu + +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ỷ**. + +| File | Việc | +|---|---| +| `config/WebSocketConfig` | Endpoint `/admin/ws`, kênh `/topic/bookings` | +| `event/BookingEvent` | Bản sao dữ liệu của sự kiện | +| `event/BookingNotifier` | Nghe sự kiện → đẩy xuống trình duyệt | +| `static/js/admin-notifications.js` | Kết nối STOMP, hiện thông báo | + +## Demo + +**Chuẩn bị 2 cửa sổ:** trình duyệt mở `/admin/bookings`, và một terminal. + +Ở terminal, đặt một đơn qua API công khai: + +```bash +TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/register -H 'Content-Type: application/json' \ + -d '{"name":"Demo","email":"demo-'$(date +%s)'@example.com","password":"MatKhau123"}' \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +curl -s -X POST localhost:8080/api/v1/bookings -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"tourId":1,"date":"2026-10-14","adults":2,"children":0, + "contact":{"fullName":"Khách demo","email":"d@example.com","phone":"0900000000"}}' +``` + +Thông báo hiện lên ở trình duyệt **ngay lập tức**, không cần F5. + +## Giải thích gì + +**Endpoint đặt tại `/admin/ws` chứ không phải `/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**. + +**Không dùng SockJS.** SockJS dự phòng bằng các transport dùng POST, mà POST thì vướng CSRF của +chain quản trị — phải mở ngoại lệ CSRF cho cả nhánh đó. WebSocket thuần chỉ cần một `GET Upgrade` +nên không đụng tới CSRF. + +**`@TransactionalEventListener(AFTER_COMMIT)` chứ không `@EventListener` thường.** Nghe ngay lúc +phát sự kiện thì transaction **còn chưa commit**; một lỗi sau đó làm rollback sẽ để lại thông báo +về một đơn **không hề tồn tại**. Có test canh đúng chỗ này +(`sendsNothingWhenTransactionRollsBack`). + +**Đi qua `ApplicationEvent` chứ không gọi thẳng messaging từ service.** `BookingService` không cần +biết gì về WebSocket — và nhờ vậy cắm thêm JMS vào (mục 8) chỉ là thêm một listener. + +## Trainer hay hỏi + +> *"`BookingEvent` sao không truyền thẳng entity `Booking`?"* + +Người nhận xử lý ở **luồng khác** và **ngoài transaction**, nơi entity lazy đã hết dùng được — +chạm vào quan hệ lazy sẽ ném `LazyInitializationException`. Nên sự kiện là **bản sao dữ liệu** tại +thời điểm đó. + +--- + +# 6. Xuất Excel + +## Áp dụng vào đâu + +| 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 | +| `/admin` | Xuất doanh thu tháng | Gộp theo tour, bỏ qua đơn đã huỷ | + +File: `service/ExcelExportService`, `admin/ExcelDownload`. + +## Demo + +Vào `/admin/bookings`, bấm lọc **"Chờ xác nhận"**, rồi bấm **Xuất Excel**. Mở file ra: chỉ có đơn +đang chờ, không phải toàn bộ. + +## Giải thích gì + +**Xuất theo bộ lọc đang xem, không phải xuất tất cả.** Admin lọc xong rồi mới muốn gửi file cho ai +đó — đó là thao tác thường gặp hơn nhiều so với xuất toàn bộ. + +**Số ghi dạng số, không phải chuỗi**, để Excel tính tổng được. Ngày có định dạng `dd/mm/yyyy`. +Dòng tiêu đề được khoá (`createFreezePane`) để cuộn danh sách dài vẫn thấy tên cột. + +**Tên file có dấu tiếng Việt** hiển thị đúng nhờ `ContentDisposition.builder().filename(name, UTF_8)` +— nó tự sinh cả `filename*` theo RFC 5987. + +--- + +# 7. CSRF + +## Áp dụng vào đâu + +TripGo có **ba** `SecurityFilterChain` với cấu hình ngược nhau — đây chính là chỗ để giải thích +CSRF cho ra đầu ra đũa: + +| Chain | Phạm vi | Session | CSRF | Xác thực | +|---|---|---|---|---| +| `AdminSecurityConfig` | `/admin/**` | có | **BẬT** | form login | +| `SocialLoginConfig` | `/oauth2/**`, `/login/oauth2/**` | có | tắt | OAuth2 | +| `SecurityConfig` | còn lại | STATELESS | tắt | Bearer JWT | + +## Demo + +```bash +# Đăng nhập admin, lấy token CSRF từ form +# Rồi gửi POST KHÔNG kèm token: +curl -X POST localhost:8080/admin/tours/1/delete -b cookie.txt +# → 403 Forbidden +``` + +Trên giao diện, mở **Xem nguồn trang** bất kỳ form admin nào, chỉ vào +`` mà Thymeleaf tự chèn. + +## Giải thích gì + +**Vì sao API tắt CSRF mà không sai?** CSRF lợi dụng việc trình duyệt **tự động gửi cookie** kèm +request. API của TripGo xác thực bằng header `Authorization: Bearer ...` — trình duyệt **không tự +gửi header**, nên không có đường tấn công. Bật CSRF cho API stateless chỉ gây phiền, không tăng an +toàn. + +**Vì sao khu quản trị phải bật?** Nó dùng session cookie. Không có CSRF, một trang web độc hại chỉ +cần dụ admin bấm vào link là có thể gửi `POST /admin/tours/5/delete` kèm cookie của admin. + +**Một chi tiết tinh tế:** TripGo **tách lỗi CSRF khỏi lỗi phân quyền**: + +```java +if (exception instanceof CsrfException) → 403 // lỗi kỹ thuật +else → redirect /admin/login?denied // sai quyền +``` + +Gộp chung thì lỗi CSRF sẽ hiện thành *"bạn không có quyền"* — che mất nguyên nhân thật và làm người +debug đi sai hướng cả buổi. Test bắt được đúng chỗ này. + +--- + +# 8. Multithreading & JMS + +## Áp dụng vào đâu + +**Multithreading** — `config/AsyncConfig`: `ThreadPoolTaskExecutor` dùng chung cho job nền và gửi mail. + +**JMS** — mail xác nhận đặt tour đi qua hàng đợi `tripgo.booking.mail`: + +| File | Việc | +|---|---| +| `config/JmsConfig` | Cấu hình hàng đợi + listener | +| `mail/BookingMailDispatcher` | Interface — hai bản cài | +| `mail/DirectBookingMailDispatcher` | Bản dự phòng khi không có broker | + +## Demo + +**Không có broker (mặc định):** + +```bash +./mvnw spring-boot:run +# Đặt một đơn → log hiện: [MAIL] gửi tới ... — TripGo đã nhận đơn đặt tour của bạn +``` + +**Có broker:** + +```bash +docker compose up -d mq +JMS_ENABLED=true ./mvnw spring-boot:run +# Đặt một đơn → cùng dòng log đó, nhưng đã đi qua hàng đợi +# Mở http://localhost:8161 (admin/admin) xem hàng đợi tripgo.booking.mail +``` + +Hoặc chạy test — nhanh và không cần Docker: + +```bash +./mvnw -Dtest=BookingMailDispatchTest test +``` + +Test này chạy **cả hai nhánh**, trong đó nhánh JMS dùng broker nhúng `vm://` nên gói tin đi qua +hàng đợi **thật**. + +## Giải thích gì + +**Vì sao dùng hàng đợi mà không gọi thẳng?** Nếu máy chủ mail chập chờn: gọi thẳng thì mail **mất +luôn**; qua hàng đợi thì listener ném lỗi, message **quay lại hàng đợi để thử lại** +(`spring.jms.listener.session.transacted: true`). + +**Vì sao mặc định tắt?** 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. Môi trường dev và test không cần dựng broker chỉ để chạy ứng dụng. + +**Vì sao vẫn có bản dự phòng?** Nếu tắt JMS mà không có gì thay thế, việc gửi mail sẽ **lặng lẽ +biến mất** khi ai đó quên bật. 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. + +**Gửi dạng chuỗi JSON tự serialize** thay vì dùng message converter có sẵn: gói tin đi qua hàng đợi +có thể được đọc lại bởi **phiên bản ứng dụng khác**, nên định dạng phải là thứ mình kiểm soát được, +không phụ thuộc vào cách một thư viện tuần tự hoá đối tượng Java. + +## Trainer hay hỏi + +> *"`@Async` với JMS khác gì nhau? Đều là chạy nền cả?"* + +| | `@Async` | JMS | +|---|---|---| +| Việc nằm ở đâu | Trong bộ nhớ tiến trình | Trong broker, ngoài tiến trình | +| App chết giữa chừng | **Mất việc** | Việc còn nguyên trong hàng đợi | +| Thử lại khi lỗi | Tự viết | Có sẵn | +| Nhiều instance chia việc | Không | Có | + +`@Async` đủ cho việc không quan trọng; JMS cho việc **không được phép mất**. + +--- + +# 9. Reflection cho export/import + +## Áp dụng vào đâu + +Đây là phần **kiến trúc đẹp nhất** trong nhóm này. Package `demo.tripgo.excel`: + +```java +@Getter @Setter +public class TourExportRow { + @ExcelColumn(header = "Tên tour", order = 2) private String title; + @ExcelColumn(header = "Giá", order = 6) private BigDecimal price; +} +``` + +Chỉ vậy. Không viết một dòng code đọc/ghi Excel nào. + +| File | Việc | +|---|---| +| `excel/ExcelColumn` | Annotation đánh dấu field thành cột | +| `excel/ExcelMapper` | **Quét annotation bằng reflection**, đọc và ghi file | +| `excel/ExcelCellConverter` | Đổi ô Excel sang kiểu Java | +| `excel/ExcelReadResult` | Dòng đọc được + dòng lỗi nằm cạnh nhau | + +Hiện có **5 loại** dùng chung tầng này: `BookingExportRow`, `TourExportRow`, `RevenueExportRow`, +`TourImportRow`, và cả file mẫu. + +## Demo + +Mở song song 2 file cho trainer xem: + +1. `TourExportRow.java` — chỉ có field và annotation, **không có logic** +2. `ExcelMapper.java` — đoạn `scan()`: + +```java +for (Field field : current.getDeclaredFields()) { + ExcelColumn column = field.getAnnotation(ExcelColumn.class); + if (column == null) continue; + field.setAccessible(true); + fields.add(new ExcelField(field, column.header(), column.order(), column.required())); +} +``` + +Nói: *"thêm một loại dữ liệu mới — ví dụ xuất danh sách đánh giá — chỉ cần tạo một lớp và gắn +annotation. Không sửa `ExcelMapper` một dòng nào."* + +## Giải thích gì + +**Reflection giải quyết vấn đề gì?** Không có nó, mỗi loại dữ liệu phải có một hàm ghi riêng: + +```java +// Nếu không dùng reflection — lặp lại cho MỖI loại +sheet.createRow(0).createCell(0).setCellValue("Mã đơn"); +sheet.createRow(0).createCell(1).setCellValue("Khách"); +// ... 8 cột × 5 loại = 40 dòng lặp, và mỗi lần đổi cột phải sửa 2 chỗ +``` + +**Giá phải trả:** reflection chậm hơn gọi trực tiếp, nên `ExcelMapper` **cache kết quả quét** theo +lớp (`ConcurrentHashMap`) — mỗi lớp chỉ quét một lần. + +**Khớp cột theo tên tiêu đề, không theo vị trí** — người dùng đảo cột hay chèn thêm cột ghi chú vẫn +nhập được. + +**Một chi tiết tìm ra nhờ test:** dòng mà người dùng gõ rồi xoá để lại ô **CHUỖI RỖNG** chứ không +phải ô `BLANK`. Không tính cả hai thì file thật đầy những dòng "ma" ở cuối bảng sẽ báo lỗi +*"thiếu giá trị ở cột bắt buộc"*. + +--- + +# 10. Phần ngoài đề bài + +Nói thêm nếu còn thời gian — cho thấy đã nghĩ xa hơn yêu cầu: + +| Hạng mục | Nội dung | +|---|---| +| **Xoá mềm + thùng rác** | Tour/điểm đến xoá rồi khôi phục được; đơn hàng cũ không vỡ | +| **Upload ảnh** | Lưu ngoài classpath; đuôi file suy ra từ content type chứ **không** lấy từ tên file client gửi | +| **Testcontainers** | 9 test chạy Postgres **thật**, vì H2 chấp nhận nhiều thứ Postgres từ chối | +| **Tìm kiếm không dấu** | Gõ "da nang" ra "Đà Nẵng" | +| **SOAP** | Contract-first từ XSD cho hệ thống đối tác | + +**Câu chuyện đắt giá nhất để kể** (nếu trainer hỏi *"em học được gì?"*): + +> Bộ test 286 cái đều xanh, nhưng trang điểm đến vẫn lỗi 500 trên Postgres thật: +> `function lower(bytea) does not exist`. Nguyên nhân: truy vấn có tham số `null`, Postgres không +> suy được kiểu nên bind thành `bytea` — còn H2 trong test thì chạy bình thường. Từ đó em thêm +> Testcontainers để các truy vấn viết tay chạy trên đúng loại DB của production. + +Đây là câu trả lời cho thấy hiểu **giới hạn của test**, không chỉ biết viết test. + +--- + +# 11. Kịch bản demo 15 phút + +| Phút | Việc | Điểm nhấn | +|---|---|---| +| 0–1 | Mở `/admin/login`, đăng nhập | 3 chain bảo mật khác nhau | +| 1–3 | Dashboard: 4 ô số liệu + 2 biểu đồ + bảng kèm theo | Một chuỗi → một màu | +| 3–5 | Đặt đơn từ terminal → **thông báo hiện ngay** | `AFTER_COMMIT` | +| 5–7 | Lọc đơn "Chờ xác nhận" → Xuất Excel → mở file | Xuất theo bộ lọc | +| 7–10 | Nhập Excel có dòng sai → bảng lỗi theo từng dòng | Mỗi dòng một transaction | +| 10–12 | Mở `TourExportRow` + `ExcelMapper.scan()` | Reflection | +| 12–14 | `curl` gọi SOAP, lấy WSDL | Contract-first | +| 14–15 | Chạy `./mvnw test` | 286 test | + +**Chuẩn bị trước khi demo:** + +```bash +# 1. Bật app với chu kỳ job ngắn để demo được job nền +set -a; source .env; set +a && ./mvnw spring-boot:run + +# 2. Có sẵn tài khoản admin +# .env: ADMIN_EMAIL=... ADMIN_PASSWORD=... + +# 3. Có sẵn file Excel mẫu ĐÃ sửa vài dòng sai — đừng sửa live, mất thời gian + +# 4. Mở sẵn: trình duyệt (2 tab), terminal, IDE ở ExcelMapper.java +``` + +**Ba câu dễ bị hỏi nhất — chuẩn bị trước:** + +1. *"Vì sao API tắt CSRF?"* → JWT ở header, trình duyệt không tự gửi header +2. *"`@Transactional` trên method private có chạy không?"* → Không. Proxy không chặn được + self-invocation. Đó là lý do có `TourRowImporter` +3. *"Test chạy H2 mà production chạy Postgres, có vấn đề gì không?"* → Có, và em đã gặp thật. + Kể câu chuyện `lower(bytea)` ở mục 10 diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index b0b0efb..2349230 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -860,9 +860,12 @@ a.stat-card:hover { margin-top: 10px; } -/* ---------- Thông báo realtime ---------- */ +/* ---------- Thông báo realtime ---------- + Tên class cố ý KHÔNG dùng "toast": Bootstrap có sẵn component cùng tên với quy tắc + `.toast:not(.show){display:none}`, nên thông báo của mình sẽ bị ẩn sạch mà không báo lỗi gì. + Đã dính đúng lỗi này một lần — chỉ phát hiện ra khi chụp màn hình trang thật. */ -.toast-host { +.tg-notice-host { position: fixed; right: 20px; bottom: 20px; @@ -874,7 +877,7 @@ a.stat-card:hover { pointer-events: none; } -.toast { +.tg-notice { pointer-events: auto; background: #1f2937; color: #fff; @@ -888,26 +891,26 @@ a.stat-card:hover { gap: 12px; } -.toast a { +.tg-notice a { color: #93c5fd; text-decoration: none; white-space: nowrap; font-weight: 500; } -.toast a:hover { +.tg-notice a:hover { text-decoration: underline; } /* Dải màu trái phân biệt loại sự kiện, kèm chữ chứ không chỉ dựa vào màu. */ -.toast-created { +.tg-notice-created { border-left: 4px solid #eda100; } -.toast-confirmed { +.tg-notice-confirmed { border-left: 4px solid #1baf7a; } -.toast-cancelled { +.tg-notice-cancelled { border-left: 4px solid #9ca3af; } diff --git a/src/main/resources/static/js/admin-notifications.js b/src/main/resources/static/js/admin-notifications.js index 3c1f2b5..1a7b604 100644 --- a/src/main/resources/static/js/admin-notifications.js +++ b/src/main/resources/static/js/admin-notifications.js @@ -35,7 +35,8 @@ function show(event) { const card = document.createElement('div'); - card.className = 'toast toast-' + (event.kind || '').toLowerCase(); + // Tiền tố tg- để không đụng component .toast của Bootstrap (xem ghi chú trong admin.css). + card.className = 'tg-notice toast-' + (event.kind || '').toLowerCase(); // textContent chứ không innerHTML: nội dung có tên khách và tên tour do người dùng nhập. card.textContent = event.message || 'Có cập nhật đơn đặt'; diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html index 61bc251..9e79bc7 100644 --- a/src/main/resources/templates/admin/layout.html +++ b/src/main/resources/templates/admin/layout.html @@ -51,7 +51,7 @@ -
+
From 2601ae8a46cfe42df2fc587df9b05a7ea08661e0 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Thu, 1 Oct 2026 01:19:59 +0700 Subject: [PATCH 21/23] Khu quan tri: dang nhap Facebook, quan ly user, tao don, bieu do theo ngay Dang nhap Facebook cho admin - oauth2Login gan vao chain /admin/** (co session), callback rieng /admin/login/oauth2/code/facebook. Chi bat khi co FACEBOOK_CLIENT_ID. - Dung lai SocialLoginUserService (tim/tao user), user moi co role USER. Kiem quyen ngay luc xac thuc: khong phai ADMIN/ACTIVE thi ve ?denied va khong tao phien nao. - ClientRegistration cua admin KHONG khai la bean: hai ClientRegistrationRepository thi cho nao tu inject cung khong biet chon. Quan ly nguoi dung (/admin/users) - Danh sach, tim theo email/ten (giu dau), loc role, doi USER <-> ADMIN. - Khong tu ha quyen minh, khong ha admin cuoi cung (khoa hang admin bang PESSIMISTIC_WRITE truoc khi dem). Admin tao don ho khach (/admin/bookings/new) - Chon tour -> ngay khoi hanh sap toi con cho -> chon khach dang hoat dong. - Don o trang thai Cho xac nhan, uy lai BookingService.createBooking nen kiem cho co khoa / tinh tien / mail / bao realtime chi o mot noi. Dashboard - Bieu do doanh thu theo ngay dat don, chon thang (khong chon thang tuong lai, server cung chan). Bo hai bieu do 6 thang va top tour. - Sua loi bieu do va nut "Them ngay" khong chay: layout chi nhung
cua trang con, the +
- - diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html index 9e79bc7..6566c7d 100644 --- a/src/main/resources/templates/admin/layout.html +++ b/src/main/resources/templates/admin/layout.html @@ -11,6 +11,8 @@ TripGo Admin + @@ -23,6 +25,7 @@ Tour Đơn đặt Điểm đến + Người dùng
diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html index aeef654..9391c61 100644 --- a/src/main/resources/templates/admin/login.html +++ b/src/main/resources/templates/admin/login.html @@ -29,6 +29,13 @@

TripGo Admin

+ + + + + + diff --git a/src/main/resources/templates/admin/tours/form.html b/src/main/resources/templates/admin/tours/form.html index 2ad9906..2c5dc4b 100644 --- a/src/main/resources/templates/admin/tours/form.html +++ b/src/main/resources/templates/admin/tours/form.html @@ -158,7 +158,9 @@

Thêm tour

+ + - diff --git a/src/main/resources/templates/admin/tours/import.html b/src/main/resources/templates/admin/tours/import.html index 845a10b..6dc10ce 100644 --- a/src/main/resources/templates/admin/tours/import.html +++ b/src/main/resources/templates/admin/tours/import.html @@ -24,7 +24,7 @@

Nhập tour từ Excel

Tour có slug trùng sẽ bị bỏ qua, không ghi đè.

-

⭳ Tải file mẫu

+

Tải file mẫu

Lỗi

@@ -36,7 +36,7 @@

Nhập tour từ Excel

- +
diff --git a/src/main/resources/templates/admin/tours/list.html b/src/main/resources/templates/admin/tours/list.html index 6cfa548..2a78e21 100644 --- a/src/main/resources/templates/admin/tours/list.html +++ b/src/main/resources/templates/admin/tours/list.html @@ -10,9 +10,8 @@ diff --git a/src/main/resources/templates/admin/users/list.html b/src/main/resources/templates/admin/users/list.html new file mode 100644 index 0000000..627e2a6 --- /dev/null +++ b/src/main/resources/templates/admin/users/list.html @@ -0,0 +1,107 @@ + + + + Người dùng · TripGo Admin + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Người dùngĐăng nhập bằngVai tròTrạng tháiNgày tạoThao tác
+ Nguyễn A + a@example.com + Email / mật khẩu + Người dùng + + Hoạt động + Chưa kích hoạt + Bị khoá + — + 01/01/2026 + +
+ + + + + +
+ +
+ + + + + +
+
Chưa có người dùng nào
+ + + +
+ + diff --git a/src/test/java/demo/tripgo/admin/AdminBookingCreateIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminBookingCreateIntegrationTest.java new file mode 100644 index 0000000..08c5869 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminBookingCreateIntegrationTest.java @@ -0,0 +1,333 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.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.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +// Admin tạo đơn hộ khách: đơn gắn vào tài khoản khách có sẵn, trạng thái Chờ xác nhận, và phải +// qua đúng luật trừ chỗ / tính tiền của luồng khách tự đặt. +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminBookingCreateIntegrationTest { + + private static final String CUSTOMER_EMAIL = "khach@example.com"; + + @Autowired MockMvc mvc; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired UserRepository users; + @Autowired demo.tripgo.TestDataCleaner cleaner; + + private Tour tour; + private Departure departure; + private User customer; + + @BeforeEach + void setUp() { + cleaner.clean(); + + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = new Category(); + category.setSlug("beach"); + category.setName("Biển đảo"); + category = categories.save(category); + + tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("4500000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + departure = saveDeparture(LocalDate.now().plusDays(30), 10, 0); + + customer = new User(); + customer.setEmail(CUSTOMER_EMAIL); + customer.setFullName("Nguyễn Văn Khách"); + customer.setPassword("x"); + customer.setRole(Role.USER); + customer.setStatus(UserStatus.ACTIVE); + customer = users.save(customer); + } + + // ---- Trang form ---- + + @Test + @WithMockUser(roles = "ADMIN") + void listPageLinksToCreateForm() throws Exception { + mvc.perform(get("/admin/bookings")) + .andExpect(content().string(containsString("/admin/bookings/new"))); + } + + // Chưa chọn tour: chỉ có bước chọn tour, chưa hiện form nhập đơn. + @Test + @WithMockUser(roles = "ADMIN") + void formStartsWithTourPicker() throws Exception { + mvc.perform(get("/admin/bookings/new")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/bookings/form")) + .andExpect(content().string(containsString("Đà Nẵng 3N2Đ"))) + .andExpect(content().string(not(containsString("Email tài khoản khách")))); + } + + // Chỉ ngày từ hôm nay trở đi VÀ còn chỗ: ngày đã qua và ngày hết chỗ bị bỏ khỏi danh sách. + @Test + @WithMockUser(roles = "ADMIN") + void listsOnlyUpcomingDeparturesWithSeatsLeft() throws Exception { + saveDeparture(LocalDate.now().minusDays(1), 10, 0); + saveDeparture(LocalDate.now().plusDays(40), 10, 10); + + mvc.perform(get("/admin/bookings/new").param("tourId", tour.getId().toString())) + .andExpect(model().attribute("departures", hasSize(1))) + .andExpect(content().string(containsString("còn 10 chỗ"))) + .andExpect(content().string(containsString("Email tài khoản khách"))); + } + + // Ô chọn khách chỉ có khách (role USER) đang hoạt động: không có admin, không có tài khoản khoá. + @Test + @WithMockUser(roles = "ADMIN") + void customerDropdownListsOnlyActiveCustomers() throws Exception { + saveAccount("bi-khoa@example.com", Role.USER, UserStatus.BLOCKED); + saveAccount("admin@tripgo.local", Role.ADMIN, UserStatus.ACTIVE); + + mvc.perform(get("/admin/bookings/new").param("tourId", tour.getId().toString())) + .andExpect(model().attribute("customers", hasSize(1))) + .andExpect(content().string(containsString("khach@example.com — Nguyễn Văn Khách"))) + .andExpect(content().string(not(containsString("bi-khoa@example.com")))) + .andExpect(content().string(not(containsString("admin@tripgo.local")))); + } + + // Giá trị gửi lên). Sai định dạng thì coi // như không chọn -> tháng hiện tại, thay vì trả lỗi 500 cho một tham số người dùng sửa tay. - @GetMapping("/admin/reports/revenue-daily") + @GetMapping("/admin/reports/chart-data") @ResponseBody public DailyRevenue dailyRevenue(@RequestParam(required = false) String month) { return dashboardService.dailyRevenue(parseMonth(month)); diff --git a/src/main/java/demo/tripgo/admin/AdminJobController.java b/src/main/java/demo/tripgo/admin/AdminJobController.java new file mode 100644 index 0000000..3aeeb8a --- /dev/null +++ b/src/main/java/demo/tripgo/admin/AdminJobController.java @@ -0,0 +1,111 @@ +package demo.tripgo.admin; + +import demo.tripgo.exception.ResourceNotFoundException; +import demo.tripgo.job.BackgroundJob; +import demo.tripgo.job.JobRunState; +import demo.tripgo.job.JobRunTracker; +import demo.tripgo.job.MaintenanceJobs; +import org.springframework.boot.convert.DurationStyle; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +// Màn hình "Job nền": lần chạy cuối, kết quả, lỗi, lần chạy kế tiếp của từng job, và nút +// "Chạy ngay". Trạng thái lấy từ JobRunTracker (trong bộ nhớ). +@Controller +@RequestMapping("/admin/jobs") +public class AdminJobController { + + private final JobRunTracker tracker; + private final MaintenanceJobs jobs; + private final Environment environment; + + public AdminJobController(JobRunTracker tracker, MaintenanceJobs jobs, Environment environment) { + this.tracker = tracker; + this.jobs = jobs; + this.environment = environment; + } + + @GetMapping + public String list(Model model) { + boolean scheduleEnabled = environment.getProperty("tasks.maintenance.enabled", Boolean.class, true); + Duration initialDelay = duration("tasks.maintenance.initial-delay", "PT2M"); + + List views = Arrays.stream(BackgroundJob.values()) + .map(job -> { + JobRunState state = tracker.state(job); + Duration delay = duration(job.getDelayProperty(), job.getDefaultDelay()); + return new JobView(job, describe(job), state, formatEvery(delay), + scheduleEnabled ? nextRun(state, delay, initialDelay) : null, scheduleEnabled); + }) + .toList(); + + model.addAttribute("activeMenu", "jobs"); + model.addAttribute("pageHeading", "Job nền"); + model.addAttribute("jobs", views); + model.addAttribute("scheduleEnabled", scheduleEnabled); + return "admin/jobs/list"; + } + + @PostMapping("/{slug}/run") + public String runNow(@PathVariable String slug, RedirectAttributes redirect) { + BackgroundJob job = BackgroundJob.fromSlug(slug) + .orElseThrow(() -> new ResourceNotFoundException("job")); + + if (tracker.state(job).isRunning()) { + redirect.addFlashAttribute("flashMessage", "\"" + job.getTitle() + "\" đang chạy, chờ xong rồi thử lại"); + redirect.addFlashAttribute("flashType", "error"); + } else { + jobs.runNow(job); + redirect.addFlashAttribute("flashMessage", + "Đã bắt đầu chạy \"" + job.getTitle() + "\" trên luồng nền. Tải lại trang để xem kết quả."); + redirect.addFlashAttribute("flashType", "success"); + } + return "redirect:/admin/jobs"; + } + + private String describe(BackgroundJob job) { + return switch (job) { + case CANCEL_EXPIRED_BOOKINGS -> "Huỷ các đơn \"Chờ xác nhận\" quá %d giờ và hoàn lại chỗ cho ngày khởi hành." + .formatted(jobs.getExpireAfterHours()); + case REFRESH_RATINGS -> "So điểm trung bình của từng tour với bảng đánh giá, sửa những tour bị lệch."; + }; + } + + // fixedDelay: lần kế tiếp = lần chạy THEO LỊCH trước đó kết thúc + nhịp. Chưa chạy lần nào thì + // = lúc khởi động + initial-delay. Chỉ là ước tính, nên trang ghi "khoảng". + private LocalDateTime nextRun(JobRunState state, Duration delay, Duration initialDelay) { + if (state.isRunning()) { + return null; + } + return state.lastScheduledFinishedAt() != null + ? state.lastScheduledFinishedAt().plus(delay) + : tracker.getStartedAt().plus(initialDelay); + } + + // Đọc đúng chuỗi mà @Scheduled đọc; DurationStyle hiểu cả "PT2M" lẫn "2m". + private Duration duration(String key, String fallback) { + return DurationStyle.detectAndParse(environment.getProperty(key, fallback)); + } + + private static String formatEvery(Duration delay) { + long seconds = delay.toSeconds(); + if (seconds > 0 && seconds % 3600 == 0) { + return "mỗi " + seconds / 3600 + " giờ"; + } + if (seconds > 0 && seconds % 60 == 0) { + return "mỗi " + seconds / 60 + " phút"; + } + return "mỗi " + seconds + " giây"; + } +} diff --git a/src/main/java/demo/tripgo/admin/JobView.java b/src/main/java/demo/tripgo/admin/JobView.java new file mode 100644 index 0000000..f4c0867 --- /dev/null +++ b/src/main/java/demo/tripgo/admin/JobView.java @@ -0,0 +1,18 @@ +package demo.tripgo.admin; + +import demo.tripgo.job.BackgroundJob; +import demo.tripgo.job.JobRunState; + +import java.time.LocalDateTime; + +// Một thẻ trên màn hình "Job nền". nextRunAt null = không ước tính được (lịch đang tắt, hoặc +// job đang chạy dở). +public record JobView( + BackgroundJob job, + String description, + JobRunState state, + String interval, + LocalDateTime nextRunAt, + boolean scheduleEnabled +) { +} diff --git a/src/main/java/demo/tripgo/config/MaintenanceScheduler.java b/src/main/java/demo/tripgo/config/MaintenanceScheduler.java index 9d16b95..72a3a4a 100644 --- a/src/main/java/demo/tripgo/config/MaintenanceScheduler.java +++ b/src/main/java/demo/tripgo/config/MaintenanceScheduler.java @@ -1,10 +1,9 @@ package demo.tripgo.config; -import demo.tripgo.service.MaintenanceService; +import demo.tripgo.job.BackgroundJob; +import demo.tripgo.job.JobTrigger; +import demo.tripgo.job.MaintenanceJobs; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.context.properties.bind.DefaultValue; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -17,30 +16,27 @@ // @Async đẩy việc sang pool riêng: luồng lập lịch chỉ có một, một job chạy lâu sẽ làm job sau trễ // theo. Trả về void vì không ai chờ kết quả. @Component -@EnableConfigurationProperties(MaintenanceScheduler.MaintenanceProperties.class) @ConditionalOnProperty(prefix = "tasks.maintenance", name = "enabled", havingValue = "true", matchIfMissing = true) public class MaintenanceScheduler { - private final MaintenanceService maintenanceService; - private final MaintenanceProperties properties; + private final MaintenanceJobs jobs; - public MaintenanceScheduler( - MaintenanceService maintenanceService, - MaintenanceProperties properties - ) { - this.maintenanceService = maintenanceService; - this.properties = properties; + public MaintenanceScheduler(MaintenanceJobs jobs) { + this.jobs = jobs; } // fixedDelay chứ không fixedRate: đếm từ lúc lần trước KẾT THÚC, nên job chạy lâu không bị // xếp chồng lên chính nó. + // + // Nội dung job nằm ở MaintenanceJobs (dùng chung với nút "Chạy ngay"); JobRunTracker ghi lại + // từng lần chạy cho màn hình "Job nền" và bắt lỗi của luồng job. @Async(AsyncConfig.TASK_EXECUTOR) @Scheduled( fixedDelayString = "${tasks.maintenance.cancel-expired-delay:PT1H}", initialDelayString = "${tasks.maintenance.initial-delay:PT2M}") public void cancelExpiredBookings() { - maintenanceService.cancelExpiredPendingBookings(properties.expireAfterHours()); + jobs.run(BackgroundJob.CANCEL_EXPIRED_BOOKINGS, JobTrigger.SCHEDULED); } @Async(AsyncConfig.TASK_EXECUTOR) @@ -48,14 +44,6 @@ public void cancelExpiredBookings() { fixedDelayString = "${tasks.maintenance.refresh-rating-delay:PT6H}", initialDelayString = "${tasks.maintenance.initial-delay:PT2M}") public void refreshRatings() { - maintenanceService.refreshStaleRatings(); - } - - @ConfigurationProperties(prefix = "tasks.maintenance") - public record MaintenanceProperties( - @DefaultValue("true") boolean enabled, - // 72 giờ: đủ dài để khách kịp chuyển khoản, đủ ngắn để chỗ không bị giữ vô hạn. - @DefaultValue("72") int expireAfterHours - ) { + jobs.run(BackgroundJob.REFRESH_RATINGS, JobTrigger.SCHEDULED); } } diff --git a/src/main/java/demo/tripgo/config/WebSocketConfig.java b/src/main/java/demo/tripgo/config/WebSocketConfig.java index 72b7a10..2c4ead4 100644 --- a/src/main/java/demo/tripgo/config/WebSocketConfig.java +++ b/src/main/java/demo/tripgo/config/WebSocketConfig.java @@ -21,6 +21,8 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public static final String ENDPOINT = "/admin/ws"; public static final String TOPIC_BOOKINGS = "/topic/bookings"; + // Tín hiệu cho biểu đồ dashboard: có dữ liệu mới thì tải lại (xem ChartUpdatePublisher). + public static final String TOPIC_CHART_UPDATES = "/topic/chart-updates"; @Override public void registerStompEndpoints(StompEndpointRegistry registry) { diff --git a/src/main/java/demo/tripgo/event/ChartUpdate.java b/src/main/java/demo/tripgo/event/ChartUpdate.java new file mode 100644 index 0000000..daa56dd --- /dev/null +++ b/src/main/java/demo/tripgo/event/ChartUpdate.java @@ -0,0 +1,18 @@ +package demo.tripgo.event; + +import java.time.LocalDateTime; + +// Tín hiệu "dữ liệu biểu đồ vừa đổi" gửi qua WebSocket tới /topic/chart-updates. +// +// Cố ý KHÔNG mang số liệu: mỗi admin có thể đang xem một tháng khác nhau, nên client nhận tín hiệu +// rồi tự gọi lại GET /admin/reports/chart-data cho đúng tháng mình đang xem. Gói tin nhỏ, và chỉ có +// một nơi tính số liệu tổng hợp. +public record ChartUpdate(Source source, String message, LocalDateTime at) { + + public enum Source { + // Có đơn được tạo / xác nhận / huỷ. + BOOKING, + // Job nền chạy xong và có làm đổi dữ liệu. + JOB + } +} diff --git a/src/main/java/demo/tripgo/event/ChartUpdatePublisher.java b/src/main/java/demo/tripgo/event/ChartUpdatePublisher.java new file mode 100644 index 0000000..e08a3f4 --- /dev/null +++ b/src/main/java/demo/tripgo/event/ChartUpdatePublisher.java @@ -0,0 +1,52 @@ +package demo.tripgo.event; + +import demo.tripgo.config.WebSocketConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; + +// Báo cho biểu đồ trên dashboard biết là có dữ liệu mới, qua STOMP topic /topic/chart-updates. +// +// Hai nguồn: đơn đặt thay đổi (nghe BookingEvent) và job nền chạy xong (MaintenanceJobs gọi +// jobFinished). Gửi hỏng chỉ ghi log: biểu đồ không cập nhật thì admin vẫn F5 được, còn đơn +// hàng đã lưu xong rồi, không được vì một thông báo mà hỏng theo. +@Component +public class ChartUpdatePublisher { + + private static final Logger log = LoggerFactory.getLogger(ChartUpdatePublisher.class); + + private final SimpMessagingTemplate messagingTemplate; + + public ChartUpdatePublisher(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + // AFTER_COMMIT như BookingNotifier: client nhận tín hiệu là gọi lại API ngay, nên dữ liệu phải + // đã nằm trong DB. Nghe trước khi commit thì client có thể đọc trúng số liệu CŨ. + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onBookingEvent(BookingEvent event) { + send(new ChartUpdate(ChartUpdate.Source.BOOKING, event.message(), LocalDateTime.now())); + } + + // Job huỷ đơn chạy xong. Không huỷ đơn nào thì không báo: số liệu không đổi, báo chỉ làm + // mọi client gọi lại API vô ích mỗi lần job chạy. + public void jobFinished(String jobTitle, int changedCount, String summary) { + if (changedCount <= 0) { + return; + } + send(new ChartUpdate(ChartUpdate.Source.JOB, jobTitle + ": " + summary, LocalDateTime.now())); + } + + private void send(ChartUpdate update) { + try { + messagingTemplate.convertAndSend(WebSocketConfig.TOPIC_CHART_UPDATES, update); + } catch (RuntimeException exception) { + log.warn("Không gửi được tín hiệu cập nhật biểu đồ: {}", exception.getMessage()); + } + } +} diff --git a/src/main/java/demo/tripgo/job/BackgroundJob.java b/src/main/java/demo/tripgo/job/BackgroundJob.java new file mode 100644 index 0000000..110ddd2 --- /dev/null +++ b/src/main/java/demo/tripgo/job/BackgroundJob.java @@ -0,0 +1,53 @@ +package demo.tripgo.job; + +import java.util.Arrays; +import java.util.Optional; + +// Các job nền hiển thị trên màn hình "Job nền". slug dùng trên URL nút "Chạy ngay"; +// delayProperty là khoá cấu hình nhịp chạy mà @Scheduled của job đó đọc. +public enum BackgroundJob { + + CANCEL_EXPIRED_BOOKINGS( + "cancel-expired-bookings", + "Huỷ đơn quá hạn", + "tasks.maintenance.cancel-expired-delay", + "PT1H"), + + REFRESH_RATINGS( + "refresh-ratings", + "Tính lại điểm đánh giá", + "tasks.maintenance.refresh-rating-delay", + "PT6H"); + + private final String slug; + private final String title; + private final String delayProperty; + private final String defaultDelay; + + BackgroundJob(String slug, String title, String delayProperty, String defaultDelay) { + this.slug = slug; + this.title = title; + this.delayProperty = delayProperty; + this.defaultDelay = defaultDelay; + } + + public String getSlug() { + return slug; + } + + public String getTitle() { + return title; + } + + public String getDelayProperty() { + return delayProperty; + } + + public String getDefaultDelay() { + return defaultDelay; + } + + public static Optional fromSlug(String slug) { + return Arrays.stream(values()).filter(job -> job.slug.equals(slug)).findFirst(); + } +} diff --git a/src/main/java/demo/tripgo/job/JobRunState.java b/src/main/java/demo/tripgo/job/JobRunState.java new file mode 100644 index 0000000..b195d2b --- /dev/null +++ b/src/main/java/demo/tripgo/job/JobRunState.java @@ -0,0 +1,47 @@ +package demo.tripgo.job; + +import java.time.LocalDateTime; + +// Ảnh chụp trạng thái MỘT job tại một thời điểm. Bất biến: tracker thay cả bản ghi mỗi lần đổi, +// nên luồng đọc (trang admin) không bao giờ thấy bản ghi đang sửa dở từ luồng job. +// +// lastScheduledFinishedAt tách riêng khỏi lastFinishedAt: lịch fixedDelay tính lần chạy kế tiếp +// từ lần chạy THEO LỊCH trước đó, lần bấm "Chạy ngay" không dời lịch. +public record JobRunState( + Status status, + JobTrigger trigger, + LocalDateTime lastStartedAt, + LocalDateTime lastFinishedAt, + LocalDateTime lastScheduledFinishedAt, + long durationMillis, + String threadName, + String summary, + String error, + long runCount +) { + + public enum Status { + NEVER_RUN("Chưa chạy"), + RUNNING("Đang chạy"), + SUCCESS("Thành công"), + FAILED("Lỗi"); + + private final String label; + + Status(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + } + + public static JobRunState neverRun() { + return new JobRunState(Status.NEVER_RUN, null, null, null, null, 0, null, null, null, 0); + } + + public boolean isRunning() { + return status == Status.RUNNING; + } +} diff --git a/src/main/java/demo/tripgo/job/JobRunTracker.java b/src/main/java/demo/tripgo/job/JobRunTracker.java new file mode 100644 index 0000000..999da0d --- /dev/null +++ b/src/main/java/demo/tripgo/job/JobRunTracker.java @@ -0,0 +1,99 @@ +package demo.tripgo.job; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +// Ghi lại lần chạy gần nhất của từng job nền, để màn hình "Job nền" hiện được. +// +// Lưu TRONG BỘ NHỚ: khởi động lại ứng dụng là mất, nhưng job chạy lại sau vài phút nên chấp +// nhận được, đổi lại không phải thêm bảng. +// +// Đây cũng là chỗ xử lý lỗi của luồng job: lỗi bị bắt ở đây, ghi log + ghi vào trạng thái, KHÔNG +// ném tiếp. Ném ra khỏi một hàm @Async trả void thì không ai nhận được, lỗi chỉ còn trong log. +@Component +public class JobRunTracker { + + private static final Logger log = LoggerFactory.getLogger(JobRunTracker.class); + + private final Map states = new ConcurrentHashMap<>(); + // Mốc ước tính lần chạy ĐẦU TIÊN (= lúc khởi động + initial-delay) khi job chưa chạy lần nào. + private final LocalDateTime startedAt = LocalDateTime.now(); + + public JobRunTracker() { + for (BackgroundJob job : BackgroundJob.values()) { + states.put(job, JobRunState.neverRun()); + } + } + + // Chạy work và ghi nhận kết quả. work trả về một câu tóm tắt cho màn hình. + // Trả false nếu job đang chạy dở (lịch và "Chạy ngay" trùng nhau): không chạy chồng. + public boolean run(BackgroundJob job, JobTrigger trigger, Supplier work) { + if (!tryStart(job, trigger)) { + log.info("Bỏ qua {} ({}): lần chạy trước chưa xong", job.getTitle(), trigger.getLabel()); + return false; + } + + long startNanos = System.nanoTime(); + try { + String summary = work.get(); + finish(job, JobRunState.Status.SUCCESS, startNanos, summary, null); + } catch (RuntimeException exception) { + log.error("Job {} lỗi trên luồng {}", job.getTitle(), Thread.currentThread().getName(), exception); + finish(job, JobRunState.Status.FAILED, startNanos, null, describe(exception)); + } + return true; + } + + public LocalDateTime getStartedAt() { + return startedAt; + } + + public JobRunState state(BackgroundJob job) { + return states.get(job); + } + + public Map snapshot() { + return new EnumMap<>(states); + } + + // compute là thao tác nguyên tử trên một khoá của ConcurrentHashMap: hai luồng cùng bấm thì + // chỉ một luồng chuyển được sang RUNNING. + private boolean tryStart(BackgroundJob job, JobTrigger trigger) { + boolean[] started = {false}; + states.compute(job, (key, current) -> { + if (current.isRunning()) { + return current; + } + started[0] = true; + return new JobRunState( + JobRunState.Status.RUNNING, trigger, LocalDateTime.now(), current.lastFinishedAt(), + current.lastScheduledFinishedAt(), current.durationMillis(), + Thread.currentThread().getName(), current.summary(), null, current.runCount()); + }); + return started[0]; + } + + private void finish( + BackgroundJob job, JobRunState.Status status, long startNanos, String summary, String error + ) { + LocalDateTime now = LocalDateTime.now(); + long durationMillis = Duration.ofNanos(System.nanoTime() - startNanos).toMillis(); + states.compute(job, (key, current) -> new JobRunState( + status, current.trigger(), current.lastStartedAt(), now, + current.trigger() == JobTrigger.SCHEDULED ? now : current.lastScheduledFinishedAt(), + durationMillis, current.threadName(), summary, error, current.runCount() + 1)); + } + + private static String describe(RuntimeException exception) { + String message = exception.getMessage(); + return exception.getClass().getSimpleName() + (message == null ? "" : ": " + message); + } +} diff --git a/src/main/java/demo/tripgo/job/JobTrigger.java b/src/main/java/demo/tripgo/job/JobTrigger.java new file mode 100644 index 0000000..090269f --- /dev/null +++ b/src/main/java/demo/tripgo/job/JobTrigger.java @@ -0,0 +1,17 @@ +package demo.tripgo.job; + +// Ai khởi động lần chạy: lịch @Scheduled hay admin bấm "Chạy ngay". +public enum JobTrigger { + SCHEDULED("Theo lịch"), + MANUAL("Chạy tay"); + + private final String label; + + JobTrigger(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/demo/tripgo/job/MaintenanceJobs.java b/src/main/java/demo/tripgo/job/MaintenanceJobs.java new file mode 100644 index 0000000..eadfb62 --- /dev/null +++ b/src/main/java/demo/tripgo/job/MaintenanceJobs.java @@ -0,0 +1,60 @@ +package demo.tripgo.job; + +import demo.tripgo.config.AsyncConfig; +import demo.tripgo.event.ChartUpdatePublisher; +import demo.tripgo.service.MaintenanceService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +// Nội dung của hai job nền, dùng chung cho lịch @Scheduled (MaintenanceScheduler) và nút +// "Chạy ngay" trên màn hình admin — để hai đường chạy KHÔNG thể lệch nhau. +// +// Không phụ thuộc MaintenanceScheduler: tắt lịch tự động (TASKS_ENABLED=false) thì admin vẫn bấm +// chạy tay được. +@Component +public class MaintenanceJobs { + + private final MaintenanceService maintenanceService; + private final JobRunTracker tracker; + private final ChartUpdatePublisher chartUpdates; + private final int expireAfterHours; + + public MaintenanceJobs( + MaintenanceService maintenanceService, + JobRunTracker tracker, + ChartUpdatePublisher chartUpdates, + @Value("${tasks.maintenance.expire-after-hours:72}") int expireAfterHours + ) { + this.maintenanceService = maintenanceService; + this.tracker = tracker; + this.chartUpdates = chartUpdates; + this.expireAfterHours = expireAfterHours; + } + + public int getExpireAfterHours() { + return expireAfterHours; + } + + public boolean run(BackgroundJob job, JobTrigger trigger) { + return switch (job) { + case CANCEL_EXPIRED_BOOKINGS -> tracker.run(job, trigger, () -> { + MaintenanceService.ExpiredCancellation result = + maintenanceService.cancelExpiredPendingBookings(expireAfterHours); + String summary = "Tìm thấy %d đơn quá %d giờ, đã huỷ %d" + .formatted(result.found(), expireAfterHours, result.cancelled()); + // Job xong -> broadcast cho biểu đồ (doanh thu đổi vì đơn huỷ không còn được tính). + chartUpdates.jobFinished(job.getTitle(), result.cancelled(), summary); + return summary; + }); + case REFRESH_RATINGS -> tracker.run(job, trigger, () -> + "Tính lại %d tour bị lệch số liệu".formatted(maintenanceService.refreshStaleRatings())); + }; + } + + // "Chạy ngay": đẩy sang pool nền để request của admin trả về ngay, không phải chờ job xong. + @Async(AsyncConfig.TASK_EXECUTOR) + public void runNow(BackgroundJob job) { + run(job, JobTrigger.MANUAL); + } +} diff --git a/src/main/java/demo/tripgo/service/MaintenanceService.java b/src/main/java/demo/tripgo/service/MaintenanceService.java index fa95857..c8ad1dc 100644 --- a/src/main/java/demo/tripgo/service/MaintenanceService.java +++ b/src/main/java/demo/tripgo/service/MaintenanceService.java @@ -42,7 +42,7 @@ public MaintenanceService( // // Huỷ TỪNG đơn qua BookingService.cancelById để dùng đúng phần hoàn chỗ có khoá bi quan. // Gom cả mẻ vào một transaction thì một đơn hỏng sẽ kéo đổ cả mẻ. - public int cancelExpiredPendingBookings(int expireAfterHours) { + public ExpiredCancellation cancelExpiredPendingBookings(int expireAfterHours) { LocalDateTime cutoff = LocalDateTime.now().minusHours(expireAfterHours); List expired = bookingRepository.findByStatusAndCreatedAtBefore(BookingStatus.PENDING, cutoff); @@ -62,7 +62,12 @@ public int cancelExpiredPendingBookings(int expireAfterHours) { // thay vì im lặng không phân biệt được "không có đơn quá hạn" với "job đã chết". log.info("Kiểm tra đơn chờ xác nhận quá {} giờ: tìm thấy {}, đã huỷ {}", expireAfterHours, expired.size(), cancelled); - return cancelled; + return new ExpiredCancellation(expired.size(), cancelled); + } + + // found > cancelled nghĩa là có đơn huỷ lỗi (đã ghi log WARN từng đơn), màn hình Job nền + // hiện ra để admin biết mà xem log. + public record ExpiredCancellation(int found, int cancelled) { } // rating_avg và review_count được denormalize để lọc/sắp xếp chạy thẳng ở DB. Chúng được cập diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css index ed687f4..17d6332 100644 --- a/src/main/resources/static/css/admin.css +++ b/src/main/resources/static/css/admin.css @@ -812,6 +812,27 @@ color: #6b7280; } +/* Trạng thái job nền. */ +.badge-job-success { + background: #e7f6ec; + color: #12683a; +} + +.badge-job-running { + background: #e7f1ff; + color: #0a58ca; +} + +.badge-job-failed { + background: #fdecea; + color: #b42318; +} + +.badge-job-never_run { + background: #f1f3f5; + color: #6b7280; +} + /* Vai trò người dùng: admin nổi bật hơn để nhìn lướt là thấy ai có quyền quản trị. */ .badge-admin { background: #e7f1ff; @@ -830,6 +851,11 @@ font-size: 13px; } +/* Giá gốc khi tour đang khuyến mãi. */ +.data-table .price-old { + text-decoration: line-through; +} + /* Ảnh thu nhỏ trong bảng danh sách. */ .row-thumb { width: 64px; @@ -950,6 +976,24 @@ a.stat-card:hover { font-size: 14px; } +/* Nhãn trạng thái realtime cạnh tiêu đề biểu đồ: xám khi chưa/mất kết nối, xanh khi đang nhận. */ +.live-badge { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + background: #f1f3f5; + color: #6b7280; + font-size: 12px; + font-weight: 500; + vertical-align: middle; +} + +.live-badge.is-live { + background: #e7f6ec; + color: #12683a; +} + .chart-total { font-size: 14px; color: #374151; @@ -1050,3 +1094,77 @@ a.stat-card:hover { .tg-notice-cancelled { border-left: 4px solid #9ca3af; } + +/* ---------- Màn hình Job nền ---------- */ + +.job-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 16px; +} + +.job-card { + background: #fff; + border: 1px solid var(--admin-border); + border-radius: 8px; + padding: 18px; + display: flex; + flex-direction: column; +} + +.job-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 12px; +} + +.job-title { + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.job-desc { + color: #6b7280; + font-size: 13px; + margin: 4px 0 0; +} + +.job-facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 14px; + font-size: 14px; + margin: 0 0 16px; +} + +.job-facts dt { + color: #6b7280; + font-weight: 400; +} + +.job-facts dd { + margin: 0; +} + +.job-facts .sub { + display: block; + color: #6b7280; + font-size: 12px; +} + +.job-error { + color: #b42318; +} + +/* Nút luôn nằm đáy thẻ, các thẻ cao thấp khác nhau vẫn thẳng hàng. */ +.job-actions { + margin-top: auto; +} + +.job-actions button:disabled { + opacity: .6; + cursor: not-allowed; +} diff --git a/src/main/resources/static/js/admin-charts.js b/src/main/resources/static/js/admin-charts.js index 0ca8e79..7e158e8 100644 --- a/src/main/resources/static/js/admin-charts.js +++ b/src/main/resources/static/js/admin-charts.js @@ -1,5 +1,10 @@ -// Biểu đồ "Doanh thu theo ngày" trên dashboard. Dữ liệu lấy từ /admin/reports/revenue-daily và -// tải lại mỗi lần đổi tháng. +// Biểu đồ "Doanh thu theo ngày" trên dashboard, cập nhật REALTIME. +// +// Dữ liệu tổng hợp lấy từ GET /admin/reports/chart-data?month=yyyy-MM. Tải lại khi: +// - đổi tháng ở ô chọn; +// - server báo có dữ liệu mới qua STOMP topic /topic/chart-updates (đơn mới / xác nhận / huỷ, +// hoặc job huỷ đơn quá hạn chạy xong) — xem ChartUpdatePublisher. +// Tín hiệu không mang số liệu: nhận được thì gọi lại API cho đúng tháng đang xem. // // Chỉ có MỘT chuỗi số liệu nên dùng một màu duy nhất: tô mỗi cột một màu khác khi // chúng cùng ý nghĩa là gán màu theo thứ hạng chứ không theo dữ liệu, và làm người đọc tưởng @@ -59,11 +64,47 @@ }); loadDaily(monthInput.value); - function loadDaily(month) { + // ---- Realtime ---- + + // Gộp tín hiệu dồn dập: job huỷ 20 đơn liền phát 20 tín hiệu, chỉ cần tải lại MỘT lần. + const REALTIME_DEBOUNCE_MS = 500; + let pendingRefresh = null; + let lastReason = null; + + function onChartUpdate(update) { + lastReason = update && update.message ? update.message : null; + clearTimeout(pendingRefresh); + pendingRefresh = setTimeout(() => { + // Tải lại đúng tháng admin ĐANG xem, không kéo họ về tháng hiện tại. + loadDaily(monthInput.value, lastReason); + }, REALTIME_DEBOUNCE_MS); + } + + function showLive(connected) { + const badge = document.getElementById('dailyLive'); + if (!badge) { + return; + } + badge.classList.toggle('is-live', connected); + badge.textContent = connected ? '● Realtime' : '● Mất kết nối, đang thử lại…'; + } + + // admin-charts.js nằm trong
nên chạy TRƯỚC admin-realtime.js (cuối layout). Đợi + // DOMContentLoaded — lúc đó mọi script đồng bộ trên trang đã chạy xong, tripgoRealtime đã có. + document.addEventListener('DOMContentLoaded', () => { + if (!window.tripgoRealtime) { + return; + } + window.tripgoRealtime.subscribe('/topic/chart-updates', onChartUpdate); + window.tripgoRealtime.onStatus(showLive); + }); + + // reason: câu mô tả khi tải lại vì có tín hiệu realtime (null nếu do người dùng đổi tháng). + function loadDaily(month, reason) { // Đổi tháng liên tục thì các response có thể về lệch thứ tự: chỉ vẽ kết quả của lần chọn // CUỐI, response trễ của lần trước bị bỏ qua. const requestId = ++latestDailyRequest; - fetch('/admin/reports/revenue-daily?month=' + encodeURIComponent(month || ''), + fetch('/admin/reports/chart-data?month=' + encodeURIComponent(month || ''), { headers: { Accept: 'application/json' } }) .then((response) => { if (!response.ok) { @@ -73,7 +114,7 @@ }) .then((data) => { if (requestId === latestDailyRequest) { - renderDaily(data); + renderDaily(data, reason); } }) .catch(() => { @@ -85,7 +126,7 @@ }); } - function renderDaily(data) { + function renderDaily(data, reason) { // Server đổi tháng tương lai / sai định dạng về tháng hiện tại -> ô chọn hiện đúng tháng đó. monthInput.value = data.month; monthInput.max = data.maxMonth; @@ -97,6 +138,13 @@ + ' · ' + data.totalBookings + ' đơn'; fillTable('dailyRevenueTable', data.days, (d) => [d.label, d.bookingCount, formatMoney(d.revenue)]); + const updatedAt = document.getElementById('dailyUpdatedAt'); + if (updatedAt) { + const time = new Date().toLocaleTimeString('vi-VN'); + // textContent: message có mã đơn / tên tour do người dùng nhập. + updatedAt.textContent = 'Cập nhật lúc ' + time + (reason ? ' · ' + reason : ''); + } + const labels = data.days.map((d) => String(d.day)); const values = data.days.map((d) => d.revenue); diff --git a/src/main/resources/static/js/admin-notifications.js b/src/main/resources/static/js/admin-notifications.js index 1a7b604..6f8fe0f 100644 --- a/src/main/resources/static/js/admin-notifications.js +++ b/src/main/resources/static/js/admin-notifications.js @@ -1,37 +1,12 @@ // Nhận thông báo realtime về đơn đặt và hiện một thẻ nhỏ ở góc màn hình. -// -// Kết nối tới /admin/ws — endpoint nằm trong khu quản trị nên chỉ phiên admin bắt tay được. -// Không dùng SockJS: WebSocket thuần chỉ cần một GET Upgrade nên không vướng CSRF. +// Kết nối STOMP dùng chung nằm ở admin-realtime.js. (function () { - if (typeof StompJs === 'undefined') { - return; - } - const HOST = document.getElementById('notification-host'); - if (!HOST) { + if (!HOST || !window.tripgoRealtime) { return; } - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const client = new StompJs.Client({ - brokerURL: protocol + '//' + window.location.host + '/admin/ws', - // Tự kết nối lại khi mạng chập chờn hoặc server khởi động lại. - reconnectDelay: 5000, - heartbeatIncoming: 10000, - heartbeatOutgoing: 10000 - }); - - client.onConnect = function () { - client.subscribe('/topic/bookings', function (frame) { - try { - show(JSON.parse(frame.body)); - } catch (error) { - // Gói tin hỏng không được làm đứt luồng nhận các gói sau. - } - }); - }; - - client.activate(); + window.tripgoRealtime.subscribe('/topic/bookings', show); function show(event) { const card = document.createElement('div'); diff --git a/src/main/resources/static/js/admin-realtime.js b/src/main/resources/static/js/admin-realtime.js new file mode 100644 index 0000000..4721f93 --- /dev/null +++ b/src/main/resources/static/js/admin-realtime.js @@ -0,0 +1,69 @@ +// MỘT kết nối STOMP dùng chung cho mọi script của trang quản trị (thông báo, biểu đồ...). +// +// Kết nối tới /admin/ws — endpoint nằm trong khu quản trị nên chỉ phiên admin bắt tay được. +// Không dùng SockJS: WebSocket thuần chỉ cần một GET Upgrade nên không vướng CSRF (xem WebSocketConfig). +// +// Cách dùng từ script khác: +// tripgoRealtime.subscribe('/topic/xxx', function (payload) { ... }); // payload đã parse JSON +// tripgoRealtime.onStatus(function (connected) { ... }); +// +// subscribe gọi lúc nào cũng được, kể cả trước khi kết nối xong: đăng ký được ghi lại và tự đăng +// ký (lại) mỗi lần kết nối thành công — mất mạng rồi nối lại thì vẫn nhận tiếp, không phải F5. +(function () { + const subscriptions = []; + const statusListeners = []; + let connected = false; + + function setConnected(value) { + connected = value; + statusListeners.forEach(function (listener) { + listener(value); + }); + } + + function attach(client, entry) { + client.subscribe(entry.destination, function (frame) { + try { + entry.handler(JSON.parse(frame.body)); + } catch (error) { + // Gói tin hỏng hay handler lỗi không được làm đứt luồng nhận các gói sau. + } + }); + } + + let client = null; + if (typeof StompJs !== 'undefined') { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + client = new StompJs.Client({ + brokerURL: protocol + '//' + window.location.host + '/admin/ws', + // Tự kết nối lại khi mạng chập chờn hoặc server khởi động lại. + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000 + }); + client.onConnect = function () { + subscriptions.forEach(function (entry) { + attach(client, entry); + }); + setConnected(true); + }; + client.onWebSocketClose = function () { + setConnected(false); + }; + client.activate(); + } + + window.tripgoRealtime = { + subscribe: function (destination, handler) { + const entry = { destination: destination, handler: handler }; + subscriptions.push(entry); + if (client && connected) { + attach(client, entry); + } + }, + onStatus: function (listener) { + statusListeners.push(listener); + listener(connected); + } + }; +})(); diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 72e1189..c3531fa 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -38,7 +38,10 @@

Dashboard

-

Doanh thu theo ngày

+

Doanh thu theo ngày + + ● Đang kết nối… +

Theo ngày đặt đơn · không tính đơn đã huỷ

@@ -48,6 +51,7 @@

Doanh thu theo ngày

+

diff --git a/src/main/resources/templates/admin/jobs/list.html b/src/main/resources/templates/admin/jobs/list.html new file mode 100644 index 0000000..64bc8f2 --- /dev/null +++ b/src/main/resources/templates/admin/jobs/list.html @@ -0,0 +1,70 @@ + + + + Job nền · TripGo Admin + + +
+ + + + +

+ Lịch chạy tự động đang tắt (TASKS_ENABLED=false). Vẫn bấm "Chạy ngay" được. +

+ +
+
+
+
+

Huỷ đơn quá hạn

+

Mô tả

+
+ Thành công +
+ +
+
Nhịp chạy
+
mỗi 1 giờ
+ +
Lần chạy cuối
+
Chưa chạy kể từ khi khởi động
+
+ 14:05:12 01/10/2026 + (Theo lịch, mất 35 ms, luồng tripgo-task-1) + (đang chạy) +
+ +
Kết quả
+
Lỗi
+
Tìm thấy 3 đơn, đã huỷ 3
+ +
Lần chạy tiếp
+
khoảng 15:05
+
—
+ +
Số lần đã chạy
+
0
+
+ + +
+ +
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html index 6566c7d..1553286 100644 --- a/src/main/resources/templates/admin/layout.html +++ b/src/main/resources/templates/admin/layout.html @@ -26,6 +26,7 @@ Đơn đặt Điểm đến Người dùng + Job nền
@@ -58,6 +59,8 @@ + + diff --git a/src/main/resources/templates/admin/tours/list.html b/src/main/resources/templates/admin/tours/list.html index 2a78e21..a163790 100644 --- a/src/main/resources/templates/admin/tours/list.html +++ b/src/main/resources/templates/admin/tours/list.html @@ -26,27 +26,30 @@

Tour - ID - Ảnh Tên tour Điểm đến Giá - Số ngày + Thời lượng + Đánh giá Thao tác - 1 - - - — - Tên tour Điểm đến - 0 - 0 + + + 0 + 0 + + 0 + 3 ngày + + 4.5 Sửa @@ -58,7 +61,7 @@

Tour - Chưa có tour nào diff --git a/src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java index 2b96ff2..581ffb3 100644 --- a/src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java +++ b/src/test/java/demo/tripgo/admin/AdminDashboardIntegrationTest.java @@ -138,7 +138,7 @@ void revenueExcludesCancelledBookings() throws Exception { @Test @WithMockUser(roles = "ADMIN") void dailyRevenueReturnsEveryDayOfMonth() throws Exception { - mvc.perform(get("/admin/reports/revenue-daily").param("month", "2024-02")) + mvc.perform(get("/admin/reports/chart-data").param("month", "2024-02")) .andExpect(status().isOk()) .andExpect(jsonPath("$.month").value("2024-02")) .andExpect(jsonPath("$.days.length()").value(29)) @@ -163,7 +163,7 @@ void dailyRevenueGroupsByBookingDay() throws Exception { placeAt(BookingStatus.CONFIRMED, "8000000", month.plusMonths(1).atDay(1).atStartOfDay()); int lastDay = month.lengthOfMonth() - 1; - mvc.perform(get("/admin/reports/revenue-daily").param("month", month.toString())) + mvc.perform(get("/admin/reports/chart-data").param("month", month.toString())) .andExpect(jsonPath("$.days.length()").value(month.lengthOfMonth())) .andExpect(jsonPath("$.days[4].revenue").value(5000000)) .andExpect(jsonPath("$.days[4].bookingCount").value(2)) @@ -181,12 +181,12 @@ void dailyRevenueFallsBackToCurrentMonth() throws Exception { String current = YearMonth.now().toString(); for (String month : new String[]{YearMonth.now().plusMonths(1).toString(), "thang-9", ""}) { - mvc.perform(get("/admin/reports/revenue-daily").param("month", month)) + mvc.perform(get("/admin/reports/chart-data").param("month", month)) .andExpect(status().isOk()) .andExpect(jsonPath("$.month").value(current)) .andExpect(jsonPath("$.maxMonth").value(current)); } - mvc.perform(get("/admin/reports/revenue-daily")) + mvc.perform(get("/admin/reports/chart-data")) .andExpect(jsonPath("$.month").value(current)); } @@ -201,10 +201,28 @@ void dashboardHasMonthPickerCappedAtCurrentMonth() throws Exception { .andExpect(content().string(containsString("dailyRevenueTable"))); } + // API tổng hợp mang đúng tên /chart-data; tên cũ đã bỏ. + @Test + @WithMockUser(roles = "ADMIN") + void oldDailyRevenueUrlIsGone() throws Exception { + mvc.perform(get("/admin/reports/revenue-daily")) + .andExpect(status().isNotFound()); + } + + // Trang có nhãn trạng thái realtime và nạp kết nối STOMP dùng chung (admin-realtime.js ở layout). + @Test + @WithMockUser(roles = "ADMIN") + void dashboardIsWiredForRealtimeChart() throws Exception { + mvc.perform(get("/admin")) + .andExpect(content().string(containsString("id=\"dailyLive\""))) + .andExpect(content().string(containsString("id=\"dailyUpdatedAt\""))) + .andExpect(content().string(containsString("/js/admin-realtime.js"))); + } + @Test @WithMockUser(roles = "USER") void normalUserCannotReadDailyRevenue() throws Exception { - mvc.perform(get("/admin/reports/revenue-daily")) + mvc.perform(get("/admin/reports/chart-data")) .andExpect(redirectedUrl("/admin/login?denied")); } @@ -218,7 +236,7 @@ void dashboardShipsTableViewAlongsideCharts() throws Exception { } // Layout chỉ nhúng
của trang con; script đặt sau
bị bỏ khi render và biểu đồ - // không bao giờ hiện — không lỗi, không cảnh báo. Test JSON /admin/reports/revenue-daily không bắt được. + // không bao giờ hiện — không lỗi, không cảnh báo. Test JSON /admin/reports/chart-data không bắt được. @Test @WithMockUser(roles = "ADMIN") void dashboardPageLoadsChartScripts() throws Exception { diff --git a/src/test/java/demo/tripgo/admin/AdminJobIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminJobIntegrationTest.java new file mode 100644 index 0000000..5bb0967 --- /dev/null +++ b/src/test/java/demo/tripgo/admin/AdminJobIntegrationTest.java @@ -0,0 +1,200 @@ +package demo.tripgo.admin; + +import demo.tripgo.entity.Booking; +import demo.tripgo.entity.BookingStatus; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.ContactInfo; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.job.BackgroundJob; +import demo.tripgo.job.JobRunState; +import demo.tripgo.job.JobRunTracker; +import demo.tripgo.repository.BookingRepository; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.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.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +// Màn hình "Job nền". Profile test tắt lịch tự động (tasks.maintenance.enabled=false), nên ở đây +// job chỉ chạy khi bấm "Chạy ngay" — đúng thứ cần kiểm, và không có lần chạy nào xen vào test. +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminJobIntegrationTest { + + @Autowired MockMvc mvc; + @Autowired JobRunTracker tracker; + @Autowired BookingRepository bookings; + @Autowired DepartureRepository departures; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired UserRepository users; + @Autowired JdbcTemplate jdbc; + @Autowired demo.tripgo.TestDataCleaner cleaner; + + private Departure departure; + + @BeforeEach + void setUp() { + cleaner.clean(); + + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = new Category(); + category.setSlug("beach"); + category.setName("Biển đảo"); + category = categories.save(category); + + Tour tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("1000000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(2); + departure = departures.save(departure); + } + + @Test + @WithMockUser(roles = "ADMIN") + void pageListsBothJobsAndSaysScheduleIsOff() throws Exception { + mvc.perform(get("/admin/jobs")) + .andExpect(status().isOk()) + .andExpect(view().name("admin/jobs/list")) + .andExpect(model().attribute("jobs", hasSize(2))) + .andExpect(content().string(containsString("Huỷ đơn quá hạn"))) + .andExpect(content().string(containsString("Tính lại điểm đánh giá"))) + .andExpect(content().string(containsString("Lịch chạy tự động đang tắt"))) + .andExpect(content().string(containsString("/admin/jobs/cancel-expired-bookings/run"))); + } + + // Bấm "Chạy ngay": job chạy trên luồng nền (không phải luồng request), huỷ đơn quá hạn, + // và màn hình hiện kết quả + tên luồng. + @Test + @WithMockUser(roles = "ADMIN") + void runNowCancelsExpiredBookingOnBackgroundThread() throws Exception { + saveExpiredPendingBooking(); + long before = tracker.state(BackgroundJob.CANCEL_EXPIRED_BOOKINGS).runCount(); + + mvc.perform(post("/admin/jobs/cancel-expired-bookings/run").with(csrf())) + .andExpect(redirectedUrl("/admin/jobs")) + .andExpect(flash().attribute("flashType", "success")); + + JobRunState state = awaitRun(BackgroundJob.CANCEL_EXPIRED_BOOKINGS, before); + assertThat(state.status()).isEqualTo(JobRunState.Status.SUCCESS); + assertThat(state.summary()).isEqualTo("Tìm thấy 1 đơn quá 72 giờ, đã huỷ 1"); + assertThat(state.threadName()).startsWith("tripgo-task-"); + assertThat(bookings.findAll()).allMatch(b -> b.getStatus() == BookingStatus.CANCELLED); + + mvc.perform(get("/admin/jobs")) + .andExpect(content().string(containsString("Tìm thấy 1 đơn quá 72 giờ, đã huỷ 1"))) + .andExpect(content().string(containsString("Chạy tay"))) + .andExpect(content().string(containsString("tripgo-task-"))); + } + + @Test + @WithMockUser(roles = "ADMIN") + void unknownJobIsNotFound() throws Exception { + mvc.perform(post("/admin/jobs/xoa-het-du-lieu/run").with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(roles = "ADMIN") + void runNowWithoutCsrfIsRejected() throws Exception { + mvc.perform(post("/admin/jobs/cancel-expired-bookings/run")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(roles = "USER") + void normalUserCannotSeeJobs() throws Exception { + mvc.perform(get("/admin/jobs")) + .andExpect(redirectedUrl("/admin/login?denied")); + } + + // Job chạy bất đồng bộ: chờ tới khi số lần chạy tăng và trạng thái không còn RUNNING. + private JobRunState awaitRun(BackgroundJob job, long runCountBefore) throws InterruptedException { + long deadline = System.currentTimeMillis() + 5_000; + while (System.currentTimeMillis() < deadline) { + JobRunState state = tracker.state(job); + if (state.runCount() > runCountBefore && !state.isRunning()) { + return state; + } + Thread.sleep(50); + } + throw new AssertionError("Job " + job + " không chạy xong trong 5 giây"); + } + + private void saveExpiredPendingBooking() { + User user = new User(); + user.setFullName("Khách"); + user.setEmail(UUID.randomUUID() + "@example.com"); + user.setPassword("x"); + user.setRole(Role.USER); + user = users.save(user); + + ContactInfo contact = new ContactInfo(); + contact.setFullName("Khách"); + contact.setEmail("k@example.com"); + contact.setPhone("0900000000"); + + Booking booking = new Booking(); + booking.setUser(user); + booking.setTour(departure.getTour()); + booking.setDeparture(departure); + booking.setCode("TG-" + UUID.randomUUID().toString().substring(0, 8)); + booking.setAdults(2); + booking.setChildren(0); + booking.setTotalPrice(new BigDecimal("2000000")); + booking.setStatus(BookingStatus.PENDING); + booking.setContact(contact); + booking = bookings.saveAndFlush(booking); + + jdbc.update("update bookings set created_at = ? where id = ?", + LocalDateTime.now().minusHours(80), booking.getId()); + } +} diff --git a/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java b/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java index 34be668..a2d4fda 100644 --- a/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java +++ b/src/test/java/demo/tripgo/admin/AdminTourListIntegrationTest.java @@ -82,19 +82,51 @@ void listsToursWithDestinationAndPrice() throws Exception { .andExpect(content().string(containsString("+ Thêm tour"))); } - // Cột ảnh: có thumbnail thì render , không có thì hiện dấu gạch chứ không để ô trống. + // Bảng chỉ còn 6 cột; ID và ảnh đã bỏ (ảnh vẫn còn ở form sửa tour). @Test @WithMockUser(roles = "ADMIN") - void showsThumbnailColumn() throws Exception { + void tableShowsOnlyAgreedColumns() throws Exception { Tour withImage = saveTour("Tour có ảnh"); withImage.setThumbnailUrl("/uploads/dulich.jpg"); tours.saveAndFlush(withImage); - saveTour("Tour không ảnh"); mvc.perform(get("/admin/tours")) - .andExpect(status().isOk()) - .andExpect(content().string(containsString("Tên tour"))) + .andExpect(content().string(containsString("Thời lượng"))) + .andExpect(content().string(containsString("Đánh giá"))) + .andExpect(content().string(not(containsString("ID")))) + .andExpect(content().string(not(containsString("Ảnh")))) + .andExpect(content().string(not(containsString("/uploads/dulich.jpg")))) + .andExpect(content().string(containsString("3 ngày"))); + } + + // Có khuyến mãi: giá khuyến mãi đứng trên, giá gốc gạch ngang bên dưới. + @Test + @WithMockUser(roles = "ADMIN") + void showsDiscountPriceWithOriginalStruckThrough() throws Exception { + Tour tour = saveTour("Tour khuyến mãi"); + tour.setDiscountPrice(new BigDecimal("3990000")); + tours.saveAndFlush(tour); + + mvc.perform(get("/admin/tours")) + .andExpect(content().string(containsString("3.990.000"))) + .andExpect(content().string(containsString(""))); + } + + // Điểm làm tròn 1 chữ số; tour chưa ai đánh giá hiện "—" chứ không phải 0.0. + @Test + @WithMockUser(roles = "ADMIN") + void showsRatingOrDashWhenNoReviews() throws Exception { + Tour rated = saveTour("Tour có đánh giá"); + rated.setRatingAvg(4.5); + rated.setReviewCount(12); + tours.saveAndFlush(rated); + saveTour("Tour chưa đánh giá"); + + mvc.perform(get("/admin/tours")) + .andExpect(content().string(containsString(">4.5"))) + .andExpect(content().string(containsString(">—"))) + .andExpect(content().string(not(containsString(">0.0")))); } @Test diff --git a/src/test/java/demo/tripgo/event/ChartRealtimeIntegrationTest.java b/src/test/java/demo/tripgo/event/ChartRealtimeIntegrationTest.java new file mode 100644 index 0000000..2f40043 --- /dev/null +++ b/src/test/java/demo/tripgo/event/ChartRealtimeIntegrationTest.java @@ -0,0 +1,197 @@ +package demo.tripgo.event; + +import demo.tripgo.TestDataCleaner; +import demo.tripgo.config.WebSocketConfig; +import demo.tripgo.dto.request.ContactRequest; +import demo.tripgo.dto.request.CreateBookingRequest; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.job.BackgroundJob; +import demo.tripgo.job.JobTrigger; +import demo.tripgo.job.MaintenanceJobs; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.repository.UserRepository; +import demo.tripgo.service.BookingAdminService; +import demo.tripgo.service.BookingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +// Biểu đồ realtime: có dữ liệu mới thì server phải gửi tín hiệu tới /topic/chart-updates để +// trình duyệt tải lại /admin/reports/chart-data. Bắt tay WebSocket thật là của Spring; ở đây +// thay SimpMessagingTemplate bằng mock để kiểm đúng phần TripGo tự viết (khi nào gửi, gửi gì). +@SpringBootTest +@ActiveProfiles("test") +class ChartRealtimeIntegrationTest { + + @Autowired TestDataCleaner cleaner; + @Autowired BookingService bookingService; + @Autowired BookingAdminService bookingAdminService; + @Autowired MaintenanceJobs maintenanceJobs; + @Autowired TourRepository tours; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired DepartureRepository departures; + @Autowired UserRepository users; + @Autowired JdbcTemplate jdbc; + + @MockitoBean SimpMessagingTemplate messagingTemplate; + + private Tour tour; + private User customer; + + @BeforeEach + void setUp() { + cleaner.clean(); + + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = new Category(); + category.setSlug("beach"); + category.setName("Biển đảo"); + category = categories.save(category); + + tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("1000000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + Departure departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(0); + departures.save(departure); + + customer = new User(); + customer.setFullName("Khách"); + customer.setEmail(UUID.randomUUID() + "@example.com"); + customer.setPassword("x"); + customer.setRole(Role.USER); + customer = users.save(customer); + } + + // ---- Đơn thay đổi -> tín hiệu BOOKING ---- + + @Test + void newBookingSignalsChart() { + bookingService.createBooking(customer, bookingRequest()); + + ChartUpdate update = lastUpdate(); + assertThat(update.source()).isEqualTo(ChartUpdate.Source.BOOKING); + assertThat(update.message()).startsWith("Đơn mới TG-"); + assertThat(update.at()).isNotNull(); + } + + @Test + void confirmAndCancelAlsoSignalChart() { + Long first = bookingService.createBooking(customer, bookingRequest()).id(); + Long second = bookingService.createBooking(customer, bookingRequest()).id(); + + clearInvocations(messagingTemplate); + bookingAdminService.confirm(first); + assertThat(lastUpdate().message()).startsWith("Đã xác nhận đơn"); + + clearInvocations(messagingTemplate); + bookingAdminService.cancel(second); + assertThat(lastUpdate().message()).startsWith("Đã huỷ đơn"); + } + + // Rollback thì KHÔNG báo: client sẽ tải lại và thấy số liệu y nguyên, hoặc tệ hơn là đọc + // trúng dữ liệu chưa commit nếu nghe trước commit. + @Test + void rolledBackBookingSendsNoSignal() { + try { + bookingService.createBooking(customer, new CreateBookingRequest( + 999999L, LocalDate.now().plusDays(30), 2, 0, contact())); + } catch (RuntimeException expected) { + // Tour không tồn tại -> rollback. + } + + verify(messagingTemplate, never()) + .convertAndSend(eq(WebSocketConfig.TOPIC_CHART_UPDATES), any(Object.class)); + } + + // ---- Job xong -> tín hiệu JOB ---- + + @Test + void cancelJobBroadcastsWhenItChangedData() { + Long id = bookingService.createBooking(customer, bookingRequest()).id(); + jdbc.update("update bookings set created_at = ? where id = ?", LocalDateTime.now().minusHours(80), id); + clearInvocations(messagingTemplate); + + maintenanceJobs.run(BackgroundJob.CANCEL_EXPIRED_BOOKINGS, JobTrigger.MANUAL); + + List updates = allUpdates(); + assertThat(updates).anySatisfy(update -> { + assertThat(update.source()).isEqualTo(ChartUpdate.Source.JOB); + assertThat(update.message()).isEqualTo("Huỷ đơn quá hạn: Tìm thấy 1 đơn quá 72 giờ, đã huỷ 1"); + }); + } + + // Không huỷ đơn nào thì số liệu không đổi: không báo, để mọi client khỏi gọi lại API vô ích. + @Test + void cancelJobStaysQuietWhenNothingChanged() { + bookingService.createBooking(customer, bookingRequest()); + clearInvocations(messagingTemplate); + + maintenanceJobs.run(BackgroundJob.CANCEL_EXPIRED_BOOKINGS, JobTrigger.MANUAL); + + verify(messagingTemplate, never()) + .convertAndSend(eq(WebSocketConfig.TOPIC_CHART_UPDATES), any(Object.class)); + } + + private ChartUpdate lastUpdate() { + return allUpdates().getLast(); + } + + private List allUpdates() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ChartUpdate.class); + verify(messagingTemplate, atLeastOnce()) + .convertAndSend(eq(WebSocketConfig.TOPIC_CHART_UPDATES), captor.capture()); + return captor.getAllValues(); + } + + private CreateBookingRequest bookingRequest() { + return new CreateBookingRequest(tour.getId(), LocalDate.now().plusDays(30), 2, 0, contact()); + } + + private ContactRequest contact() { + return new ContactRequest("Khách", "k@example.com", "0900000000", null); + } +} diff --git a/src/test/java/demo/tripgo/event/ChartRealtimeWebSocketTest.java b/src/test/java/demo/tripgo/event/ChartRealtimeWebSocketTest.java new file mode 100644 index 0000000..9dee077 --- /dev/null +++ b/src/test/java/demo/tripgo/event/ChartRealtimeWebSocketTest.java @@ -0,0 +1,186 @@ +package demo.tripgo.event; + +import demo.tripgo.TestDataCleaner; +import demo.tripgo.dto.request.ContactRequest; +import demo.tripgo.dto.request.CreateBookingRequest; +import demo.tripgo.entity.Category; +import demo.tripgo.entity.Departure; +import demo.tripgo.entity.Destination; +import demo.tripgo.entity.Role; +import demo.tripgo.entity.Tour; +import demo.tripgo.entity.User; +import demo.tripgo.entity.UserStatus; +import demo.tripgo.repository.CategoryRepository; +import demo.tripgo.repository.DepartureRepository; +import demo.tripgo.repository.DestinationRepository; +import demo.tripgo.repository.TourRepository; +import demo.tripgo.repository.UserRepository; +import demo.tripgo.service.BookingService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.JacksonJsonMessageConverter; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +// Đi trọn đường thật, không mock: đăng nhập admin bằng form -> bắt tay WebSocket /admin/ws bằng +// cookie phiên (qua chain bảo mật /admin/**) -> subscribe /topic/chart-updates -> tạo đơn -> nhận +// tín hiệu. ChartRealtimeIntegrationTest kiểm KHI NÀO gửi; test này kiểm gói tin thật sự tới được +// trình duyệt của admin (security, broker, chuyển JSON). +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class ChartRealtimeWebSocketTest { + + private static final String ADMIN_EMAIL = "ws-admin@tripgo.local"; + private static final String ADMIN_PASSWORD = "MatKhau123"; + + @LocalServerPort int port; + @Autowired TestDataCleaner cleaner; + @Autowired UserRepository users; + @Autowired PasswordEncoder passwordEncoder; + @Autowired DestinationRepository destinations; + @Autowired CategoryRepository categories; + @Autowired TourRepository tours; + @Autowired DepartureRepository departures; + @Autowired BookingService bookingService; + + private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); + + @Test + void adminBrowserReceivesChartUpdateWhenBookingIsCreated() throws Exception { + cleaner.clean(); + saveUser(ADMIN_EMAIL, passwordEncoder.encode(ADMIN_PASSWORD), Role.ADMIN); + User customer = saveUser("khach@example.com", "x", Role.USER); + Departure departure = saveDeparture(); + + BlockingQueue> received = new LinkedBlockingQueue<>(); + StompSession session = connect(loginAsAdmin()); + session.subscribe("/topic/chart-updates", new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return Map.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + received.add((Map) payload); + } + }); + // subscribe là bất đồng bộ: chờ broker ghi nhận đăng ký trước khi phát sự kiện. + Thread.sleep(300); + + bookingService.createBooking(customer, new CreateBookingRequest( + departure.getTour().getId(), departure.getDepartureDate(), 1, 0, + new ContactRequest("Khách", "khach@example.com", "0900000000", null))); + + Map update = received.poll(5, TimeUnit.SECONDS); + assertThat(update).as("không nhận được tín hiệu qua WebSocket").isNotNull(); + assertThat(update.get("source")).isEqualTo("BOOKING"); + assertThat((String) update.get("message")).startsWith("Đơn mới TG-"); + session.disconnect(); + } + + // Form login thật: lấy token CSRF trên trang đăng nhập, POST, giữ cookie phiên mới + // (Spring đổi session id sau khi đăng nhập để chống session fixation). + private String loginAsAdmin() throws Exception { + String base = "http://localhost:" + port; + HttpResponse page = http.send( + HttpRequest.newBuilder(URI.create(base + "/admin/login")).build(), HttpResponse.BodyHandlers.ofString()); + String cookie = sessionCookie(page); + Matcher csrf = Pattern.compile("name=\"_csrf\" value=\"([^\"]+)\"").matcher(page.body()); + assertThat(csrf.find()).isTrue(); + + String form = "email=" + encode(ADMIN_EMAIL) + "&password=" + encode(ADMIN_PASSWORD) + + "&_csrf=" + encode(csrf.group(1)); + HttpResponse login = http.send(HttpRequest.newBuilder(URI.create(base + "/admin/login")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Cookie", cookie) + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(login.headers().firstValue("location")).hasValue(base + "/admin"); + return sessionCookie(login); + } + + private StompSession connect(String sessionCookie) throws Exception { + WebSocketStompClient stomp = new WebSocketStompClient(new StandardWebSocketClient()); + stomp.setMessageConverter(new JacksonJsonMessageConverter()); + WebSocketHttpHeaders handshake = new WebSocketHttpHeaders(); + handshake.add("Cookie", sessionCookie); + return stomp.connectAsync("ws://localhost:" + port + "/admin/ws", handshake, new StompHeaders(), + new StompSessionHandlerAdapter() { }).get(5, TimeUnit.SECONDS); + } + + private static String sessionCookie(HttpResponse response) { + return response.headers().firstValue("set-cookie").orElseThrow().split(";")[0]; + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private User saveUser(String email, String password, Role role) { + User user = new User(); + user.setEmail(email); + user.setFullName("Tài khoản " + role); + user.setPassword(password); + user.setRole(role); + user.setStatus(UserStatus.ACTIVE); + return users.save(user); + } + + private Departure saveDeparture() { + Destination destination = new Destination(); + destination.setName("Đà Nẵng"); + destination.setSlug("da-nang"); + destination = destinations.save(destination); + + Category category = new Category(); + category.setSlug("beach"); + category.setName("Biển đảo"); + category = categories.save(category); + + Tour tour = new Tour(); + tour.setTitle("Đà Nẵng 3N2Đ"); + tour.setSlug("da-nang-3n2d"); + tour.setDestination(destination); + tour.setCategory(category); + tour.setDurationDays(3); + tour.setPrice(new BigDecimal("1000000")); + tour.setMaxGuests(20); + tour = tours.save(tour); + + Departure departure = new Departure(); + departure.setTour(tour); + departure.setDepartureDate(LocalDate.now().plusDays(30)); + departure.setTotalSeats(20); + departure.setBookedSeats(0); + return departures.save(departure); + } +} diff --git a/src/test/java/demo/tripgo/job/JobRunTrackerTest.java b/src/test/java/demo/tripgo/job/JobRunTrackerTest.java new file mode 100644 index 0000000..020c0d9 --- /dev/null +++ b/src/test/java/demo/tripgo/job/JobRunTrackerTest.java @@ -0,0 +1,103 @@ +package demo.tripgo.job; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +// Phần ghi nhận + xử lý lỗi của luồng job, không cần Spring. +class JobRunTrackerTest { + + private final JobRunTracker tracker = new JobRunTracker(); + private static final BackgroundJob JOB = BackgroundJob.CANCEL_EXPIRED_BOOKINGS; + + @Test + void startsAsNeverRun() { + assertThat(tracker.state(JOB).status()).isEqualTo(JobRunState.Status.NEVER_RUN); + assertThat(tracker.state(JOB).runCount()).isZero(); + } + + @Test + void recordsSuccessfulRun() { + assertThat(tracker.run(JOB, JobTrigger.SCHEDULED, () -> "Đã huỷ 3")).isTrue(); + + JobRunState state = tracker.state(JOB); + assertThat(state.status()).isEqualTo(JobRunState.Status.SUCCESS); + assertThat(state.summary()).isEqualTo("Đã huỷ 3"); + assertThat(state.threadName()).isEqualTo(Thread.currentThread().getName()); + assertThat(state.lastStartedAt()).isNotNull(); + assertThat(state.lastFinishedAt()).isNotNull(); + assertThat(state.lastScheduledFinishedAt()).isEqualTo(state.lastFinishedAt()); + assertThat(state.runCount()).isEqualTo(1); + } + + // Lỗi bị bắt và ghi lại, KHÔNG ném ra ngoài; lần chạy sau vẫn chạy và xoá lỗi cũ. + @Test + void failureIsRecordedAndNextRunStillWorks() { + tracker.run(JOB, JobTrigger.SCHEDULED, () -> { + throw new IllegalStateException("mất kết nối DB"); + }); + + JobRunState failed = tracker.state(JOB); + assertThat(failed.status()).isEqualTo(JobRunState.Status.FAILED); + assertThat(failed.error()).isEqualTo("IllegalStateException: mất kết nối DB"); + + tracker.run(JOB, JobTrigger.SCHEDULED, () -> "ổn rồi"); + + JobRunState recovered = tracker.state(JOB); + assertThat(recovered.status()).isEqualTo(JobRunState.Status.SUCCESS); + assertThat(recovered.error()).isNull(); + assertThat(recovered.runCount()).isEqualTo(2); + } + + // Lịch fixedDelay tính từ lần chạy THEO LỊCH: bấm "Chạy ngay" không được dời mốc đó. + @Test + void manualRunDoesNotMoveScheduledMark() { + tracker.run(JOB, JobTrigger.SCHEDULED, () -> "lịch"); + var scheduledMark = tracker.state(JOB).lastScheduledFinishedAt(); + + tracker.run(JOB, JobTrigger.MANUAL, () -> "tay"); + + assertThat(tracker.state(JOB).trigger()).isEqualTo(JobTrigger.MANUAL); + assertThat(tracker.state(JOB).lastScheduledFinishedAt()).isEqualTo(scheduledMark); + } + + // Lịch và "Chạy ngay" trùng nhau: lần thứ hai bị bỏ qua chứ không chạy chồng. + @Test + void doesNotRunTwiceAtTheSameTime() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + Future first = pool.submit(() -> tracker.run(JOB, JobTrigger.SCHEDULED, () -> { + started.countDown(); + await(release); + return "xong"; + })); + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(tracker.state(JOB).isRunning()).isTrue(); + + assertThat(tracker.run(JOB, JobTrigger.MANUAL, () -> "không được chạy")).isFalse(); + + release.countDown(); + assertThat(first.get(5, TimeUnit.SECONDS)).isTrue(); + assertThat(tracker.state(JOB).summary()).isEqualTo("xong"); + assertThat(tracker.state(JOB).runCount()).isEqualTo(1); + } finally { + pool.shutdownNow(); + } + } + + private static void await(CountDownLatch latch) { + try { + latch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/demo/tripgo/service/MaintenanceServiceIntegrationTest.java b/src/test/java/demo/tripgo/service/MaintenanceServiceIntegrationTest.java index 7b5fbcf..93469f6 100644 --- a/src/test/java/demo/tripgo/service/MaintenanceServiceIntegrationTest.java +++ b/src/test/java/demo/tripgo/service/MaintenanceServiceIntegrationTest.java @@ -94,7 +94,7 @@ void cancelsOnlyPendingBookingsOlderThanCutoff() { departure.setBookedSeats(9); departures.saveAndFlush(departure); - assertThat(maintenance.cancelExpiredPendingBookings(72)).isEqualTo(1); + assertThat(maintenance.cancelExpiredPendingBookings(72).cancelled()).isEqualTo(1); assertThat(status(old)).isEqualTo(BookingStatus.CANCELLED); // Đơn mới và đơn đã xác nhận không được đụng tới. @@ -110,7 +110,7 @@ void cancelsOnlyPendingBookingsOlderThanCutoff() { void doesNothingWhenNoBookingExpired() { saveBooking(BookingStatus.PENDING, 2, LocalDateTime.now().minusHours(1)); - assertThat(maintenance.cancelExpiredPendingBookings(72)).isZero(); + assertThat(maintenance.cancelExpiredPendingBookings(72).cancelled()).isZero(); } // Chạy lại lần hai không được trừ chỗ thêm lần nữa. @@ -120,8 +120,8 @@ void runningTwiceIsIdempotent() { departure.setBookedSeats(3); departures.saveAndFlush(departure); - assertThat(maintenance.cancelExpiredPendingBookings(72)).isEqualTo(1); - assertThat(maintenance.cancelExpiredPendingBookings(72)).isZero(); + assertThat(maintenance.cancelExpiredPendingBookings(72).cancelled()).isEqualTo(1); + assertThat(maintenance.cancelExpiredPendingBookings(72).cancelled()).isZero(); assertThat(departures.findById(departure.getId()).orElseThrow().getBookedSeats()).isZero(); } From cfe562e094ec8c21b4045f9b60b162cb18909363 Mon Sep 17 00:00:00 2001 From: NhiTT Date: Thu, 1 Oct 2026 16:12:18 +0700 Subject: [PATCH 23/23] fix all --- BAI-HOC-DEMO.md | 644 ------------------------------------------------ 1 file changed, 644 deletions(-) delete mode 100644 BAI-HOC-DEMO.md diff --git a/BAI-HOC-DEMO.md b/BAI-HOC-DEMO.md deleted file mode 100644 index 1a1e1a5..0000000 --- a/BAI-HOC-DEMO.md +++ /dev/null @@ -1,644 +0,0 @@ -# TripGo — Bản đồ bài học mini project và kịch bản demo - -Tài liệu này trả lời hai câu hỏi: - -1. Mỗi chức năng của đề bài mini project (ứng dụng theo dõi tương tác Facebook/X) được áp dụng - vào **chỗ nào** trong TripGo? -2. Demo thế nào và **giải thích ra sao** để trainer thấy mình hiểu chứ không chỉ chạy được. - -> **Nói trước hai khác biệt so với đề bài**, để không ai hiểu nhầm là đã làm nhưng thực ra chưa: -> -> - **Spring Boot 4.1.1**, không phải 3.x. TripGo có sẵn từ trước và đã dùng bản này. Hệ quả thật: -> `@MockBean` đã bị gỡ (dùng `@MockitoBean`), `spring-boot-starter-web` đổi tên thành -> `spring-boot-starter-webmvc`, Jackson lên phiên bản 3 (`tools.jackson.databind`). -> - **Chỉ có đăng nhập Facebook, chưa có X (Twitter)**. Phần khung đã sẵn sàng cho nhà cung cấp -> thứ hai (xem mục 1), thêm X chỉ là thêm một `ClientRegistration`. - ---- - -## Bảng đối chiếu nhanh - -| # | Bài học trong đề | Áp dụng vào TripGo | Trạng thái | -|---|---|---|---| -| 1 | Social Login (FB, TW) | Đăng nhập Facebook cho khách, đổi sang JWT của TripGo | ✅ (chưa có X) | -| 2 | Import Excel danh sách bài viết | Nhập **tour** hàng loạt từ Excel | ✅ | -| 3 | Background job crawl & update metrics | Job nền: tự huỷ đơn quá hạn, tính lại đánh giá | ⚠️ khác bản chất | -| 4 | Hiển thị biểu đồ (Chart.js) | 2 biểu đồ trên dashboard quản trị | ✅ | -| 5 | Thông báo realtime qua WebSocket | Báo admin khi có đơn mới / đổi trạng thái | ✅ | -| 6 | Xuất kết quả ra Excel | Xuất đơn đặt, tour, doanh thu tháng | ✅ | -| 7 | Bảo mật với CSRF | Bật cho khu quản trị, tắt cho API stateless | ✅ | -| 8 | Multithreading & JMS | `ThreadPoolTaskExecutor` + hàng đợi gửi mail ActiveMQ | ✅ | -| 9 | Reflection cho export/import động | `@ExcelColumn` + `ExcelMapper` | ✅ | - -**Mục 3 khác bản chất** — nói thẳng chỗ này với trainer: đề bài crawl dữ liệu từ **API bên ngoài** -(Facebook/X), còn TripGo không có hệ thống ngoài nào để crawl. Nên phần áp dụng là **cơ chế job nền** -(`@Scheduled` + `@Async` + pool riêng + tắt được + một việc hỏng không đổ cả mẻ), còn nội dung công -việc là dọn dẹp dữ liệu nội bộ. Chi tiết ở mục 3. - ---- - -## Bảng đối chiếu yêu cầu kỹ thuật - -| Hạng mục | Yêu cầu | TripGo dùng gì | Ở đâu | -|---|---|---|---| -| Framework | Spring Boot 3.x | Spring Boot **4.1.1** | `pom.xml` | -| Security | Spring Security + OAuth2 + CSRF | 3 `SecurityFilterChain` tách biệt | `SecurityConfig`, `AdminSecurityConfig`, `SocialLoginConfig` | -| Database | MySQL / PostgreSQL | PostgreSQL 16 | `docker-compose.yml` | -| Messaging | JMS (ActiveMQ / RabbitMQ) | ActiveMQ Classic | `JmsConfig` | -| Realtime | WebSocket + STOMP | STOMP trên WebSocket thuần | `WebSocketConfig` | -| Scheduler | `@Scheduled` + `@Async` | Cả hai | `MaintenanceScheduler` | -| Multithread | `ThreadPoolTaskExecutor` | Pool riêng, có chính sách từ chối | `AsyncConfig` | -| File Handling | Apache POI | POI 5.5.1, tầng generic | `excel/ExcelMapper` | -| WebService | SOAP (Spring-WS) | Contract-first từ XSD | `WebServiceConfig`, `soap/` | -| Chart | Chart.js hoặc ECharts | Chart.js 4.4.4 | `static/js/admin-charts.js` | -| Testing | JUnit5 + Mockito + MockMvc | **286 test**, cả Testcontainers | `src/test/` | -| API Docs | Swagger / OpenAPI | springdoc-openapi | `/swagger-ui.html` | -| Logging | SLF4J + Logback | Mặc định của Spring Boot | toàn bộ service | -| Frontend | Thymeleaf hoặc React | Thymeleaf + Bootstrap 5 | `templates/admin/` | - ---- - -# 1. Social Login - -## Áp dụng vào đâu - -Khách đăng nhập TripGo bằng Facebook thay vì phải đăng ký email/mật khẩu. - -| File | Việc | -|---|---| -| `config/SocialLoginConfig` | Khai `ClientRegistration` cho Facebook + chain bảo mật riêng | -| `security/SocialLoginUserService` | Sau khi Facebook xác thực xong: tìm-hoặc-tạo user trong bảng `users` | -| `security/OAuth2LoginSuccessHandler` | **Đổi sang JWT của TripGo** | -| `security/SocialUserAttributes` | Chuẩn hoá dữ liệu thô của Facebook | -| `entity/AuthProvider` | `LOCAL` / `FACEBOOK` — thêm Google/X chỉ là thêm hằng số | - -## Demo - -```bash -# .env phải có FACEBOOK_CLIENT_ID và FACEBOOK_CLIENT_SECRET -set -a; source .env; set +a && ./mvnw spring-boot:run -``` - -Mở trình duyệt: `http://localhost:8080/oauth2/authorization/facebook` - -Sau khi đồng ý trên Facebook, callback trả thẳng JSON: - -```json -{ "token": "", "user": { "id": 42, "email": "...", "name": "..." } } -``` - -Copy `token` đó rồi gọi một API cần đăng nhập để chứng minh nó dùng được thật: - -```bash -curl -H "Authorization: Bearer " http://localhost:8080/api/v1/auth/me -``` - -## Giải thích gì - -**Vấn đề cốt lõi**: OAuth2 cần **session** để giữ tham số `state` giữa hai request (lúc chuyển sang -Facebook và lúc Facebook gọi về). Nhưng API của TripGo là **stateless JWT**. Hai thứ này xung khắc. - -**Cách giải quyết**: một `SecurityFilterChain` riêng chỉ nhận `/oauth2/**` và `/login/oauth2/**`, -được phép có session; xong bắt tay thì `OAuth2LoginSuccessHandler` phát JWT và **huỷ session ngay**. -Từ đó trở đi client chỉ dùng JWT như mọi endpoint khác. - -**Ba chi tiết đáng nói:** - -1. **Email trùng thì gắn vào tài khoản cũ.** Khách từng đăng ký bằng `a@x.com`, giờ đăng nhập - Facebook cũng `a@x.com` → gắn Facebook vào chính tài khoản đó, giữ nguyên wishlist và đơn hàng. - Nếu email đã thuộc một tài khoản Facebook **khác** thì dừng lại — không cướp tài khoản người ta. -2. **Không có email vẫn đăng nhập được.** App Facebook mới chưa được duyệt quyền `email`. Cột - `email` là `NOT NULL + unique`, nên hệ thống tự sinh địa chỉ giữ chỗ `facebook.@facebook.local`. -3. **PKCE** được bật (`code_challenge_method=S256`): kể cả khi mã authorization bị lộ trên đường - redirect, thiếu `code_verifier` vẫn không đổi được token. - -## Trainer hay hỏi - -> *"Tài khoản Facebook không có mật khẩu thì `POST /auth/login` xử lý sao?"* - -`AuthService.login` kiểm tra `user.getPassword() == null` **trước** khi so khớp, và trả lời rõ -*"Tài khoản này đăng nhập bằng Facebook"*. Không kiểm thì `passwordEncoder.matches(raw, null)` sẽ -nổ hoặc trả sai lệch. - -> *"Thêm Twitter/X thì phải sửa gì?"* - -Thêm một `ClientRegistration` trong `SocialLoginConfig`, thêm hằng số vào `AuthProvider`, và thêm -một nhánh `case X ->` trong `SocialUserAttributes.of()`. Không đụng tới phần còn lại. - ---- - -# 2. Import Excel - -## Áp dụng vào đâu - -Đề bài nhập danh sách **bài viết**; TripGo nhập danh sách **tour** — cùng một bài toán. - -| File | Việc | -|---|---| -| `admin/AdminTourImportController` | Trang `/admin/tours/import`, nút tải file mẫu | -| `service/TourImportService` | Điều phối: đọc file → lưu từng dòng → tổng kết | -| `service/TourRowImporter` | Lưu **một** dòng, trong transaction riêng | -| `admin/excel/TourImportRow` | Khai các cột bằng `@ExcelColumn` | - -## Demo - -1. Vào `/admin/tours` → **Nhập Excel** -2. Bấm **Tải file mẫu** → mở ra, thêm vài dòng, **cố tình làm sai 2–3 dòng** - (sai slug điểm đến, giá KM cao hơn giá gốc, để trống cột Giá) -3. Tải lên → màn hình hiện: - -``` -Tổng dòng: 5 Đã nhập: 2 Bỏ qua: 3 - dòng 4: không có điểm đến với slug 'khong-ton-tai' - dòng 5: giá khuyến mãi phải nhỏ hơn giá gốc - dòng 6: thiếu giá trị ở cột bắt buộc 'Giá' -``` - -4. **Tải lên lại đúng file đó** → tất cả bị bỏ qua vì trùng slug, không tạo bản sao - -## Giải thích gì - -**Quyết định 1 — nhập được dòng nào hay dòng đó.** Từ chối cả file chỉ vì một ô gõ nhầm sẽ biến -việc nhập 200 tour thành vòng lặp sửa-thử vô tận. Số dòng báo lỗi **khớp đúng thanh số dòng trong -Excel** để người dùng tìm được chỗ sửa. - -**Quyết định 2 — mỗi dòng một transaction** (`REQUIRES_NEW` trong `TourRowImporter`). Để cả file -trong một transaction thì một dòng vi phạm ràng buộc DB sẽ **rollback những dòng đã lưu thành công**. - -**Quyết định 3 — trùng slug thì bỏ qua, không ghi đè.** Chạy lại cùng một file không tạo bản sao, -và không lặng lẽ đè lên dữ liệu admin đã sửa tay. - -## Trainer hay hỏi - -> *"Vì sao `TourRowImporter` là một bean riêng chứ không phải method private?"* - -**Đây là câu đáng giá nhất của mục này.** `@Transactional` hoạt động qua proxy. Gọi một method -`@Transactional` từ **chính lớp đó** (self-invocation) sẽ **không mở transaction nào cả** — không -báo lỗi, không có triệu chứng, chỉ âm thầm sai cho tới lúc dữ liệu hỏng. Tách sang bean khác thì -lời gọi đi qua proxy và annotation mới có tác dụng. - -> *"Người dùng đảo thứ tự cột thì sao?"* - -Cột được khớp theo **tên tiêu đề**, không theo vị trí. Thừa cột cũng không sao. - ---- - -# 3. Background job - -## Áp dụng vào đâu — và khác đề bài chỗ nào - -Đề bài: mỗi giờ gọi API Facebook/X lấy số like/share về lưu, để vẽ được biểu đồ theo thời gian. - -TripGo **không có hệ thống ngoài nào để crawl**. Nên phần áp dụng là **cơ chế**, còn nội dung công -việc là dọn dẹp nội bộ: - -| Job | Nhịp | Việc | -|---|---|---| -| Tự huỷ đơn quá hạn | 1 giờ | Đơn `PENDING` quá 72h → huỷ và **hoàn chỗ** cho khách khác | -| Tính lại đánh giá | 6 giờ | So `rating_avg`/`review_count` với bảng `reviews`, sửa tour bị lệch | - -| File | Việc | -|---|---| -| `config/MaintenanceScheduler` | Lịch chạy (`@Scheduled` + `@Async`) | -| `service/MaintenanceService` | Việc thật — tách ra để test gọi thẳng, không phải chờ tới giờ | -| `config/AsyncConfig` | `ThreadPoolTaskExecutor` riêng | - -## Demo - -Job chạy mỗi giờ nên **không ngồi chờ được**. Hai cách demo: - -**Cách 1 — rút ngắn chu kỳ** (thuyết phục nhất): - -```bash -BOOKING_EXPIRE_HOURS=0 \ - ./mvnw spring-boot:run -Dspring-boot.run.arguments=--tasks.maintenance.initial-delay=PT5S,--tasks.maintenance.cancel-expired-delay=PT10S -``` - -Đặt một đơn qua API, chờ ~15 giây, xem log: - -``` -Đã tự huỷ 1 đơn chờ xác nhận quá 0 giờ -``` - -Rồi mở `/admin/bookings` thấy đơn chuyển sang **Đã huỷ**, và số chỗ trống của ngày khởi hành đã tăng lại. - -**Cách 2 — chạy test**, nhanh và chắc chắn: - -```bash -./mvnw -Dtest=MaintenanceServiceIntegrationTest test -``` - -6 test, trong đó có `runningTwiceIsIdempotent` (chạy hai lần không trừ chỗ hai lần). - -## Giải thích gì - -**`fixedDelay` chứ không `fixedRate`.** `fixedRate` đếm 1 giờ từ lúc **bắt đầu**; job chạy 70 phút -với chu kỳ 60 phút sẽ tự chồng lên chính nó. `fixedDelay` đếm từ lúc **kết thúc**. - -**Pool riêng, không dùng executor mặc định.** Mặc định của Spring Boot là `SimpleAsyncTaskExecutor` -— nó tạo **một luồng mới cho mỗi lần gọi, không giới hạn**. Một job chạy lâu gặp lúc nhiều việc dồn -lại sẽ sinh luồng mất kiểm soát. Pool của TripGo giới hạn 2–4 luồng, hàng đợi 50, và khi đầy thì -`CallerRunsPolicy` (chạy ngay trên luồng gọi) — **chậm còn hơn mất việc**. - -**Một đơn hỏng không kéo đổ cả mẻ.** Vòng lặp bắt lỗi từng đơn và ghi log, thay vì gom cả mẻ vào -một transaction. - -**Job tắt trong profile test.** Nó chạy xen vào giữa test sẽ sửa dữ liệu ngay dưới chân test và -sinh lỗi chập chờn rất khó lần. - -## Trainer hay hỏi - -> *"Sao không crawl gì cả?"* - -Trả lời thẳng: TripGo không có hệ thống ngoài để crawl, nên mình áp dụng đúng **cơ chế** job nền -vào bài toán có thật của dự án. Nếu cần crawl thật, chỗ cắm vào là `MaintenanceService` — thêm một -method và một `@Scheduled`, phần hạ tầng (pool, tắt/bật, chống lỗi) dùng lại nguyên vẹn. - ---- - -# 4. Biểu đồ (Chart.js) - -## Áp dụng vào đâu - -Hai biểu đồ trên dashboard `/admin`: - -- **Doanh thu 6 tháng gần nhất** (cột dọc) -- **5 tour doanh thu cao nhất tháng này** (cột ngang) - -| File | Việc | -|---|---| -| `service/DashboardService.charts()` | Gom dữ liệu | -| `admin/AdminDashboardController` | `GET /admin/reports/charts` trả JSON | -| `static/js/admin-charts.js` | Vẽ biểu đồ | - -## Demo - -Mở `/admin`, chỉ vào hai biểu đồ, rồi bấm **"Xem dạng bảng"** dưới mỗi biểu đồ. - -## Giải thích gì - -**Mỗi 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 khác khi chúng cùng -ý nghĩa là gán màu theo **thứ hạng** chứ không theo dữ liệu, và làm người đọc tưởng màu mang thông -tin gì đó. - -**Tháng không có đơn nào vẫn hiện cột 0.** Bỏ hẳn cột đi sẽ làm trục thời gian nói dối — nhìn như -tháng đó không tồn tại. - -**Mỗi biểu đồ kèm một bảng số liệu.** Biểu đồ không được là cách **duy nhất** đọc được con số — -người dùng trình đọc màn hình, và cả lúc in ra giấy. - -**Ba lỗi chỉ phát hiện được khi chụp màn hình ra nhìn**, test không bắt được: - -1. Nhãn trục `10.000.000` dài tới mức Chart.js phải xoay chéo, xoay xong thì các nhãn chồng lên - nhau → rút về `10 tr` -2. Đường lưới cắt ngang qua cột thay vì lùi về sau → thêm `z: -1` -3. Token màu chế độ tối bị áp theo `prefers-color-scheme`, trong khi khu quản trị chỉ có giao diện - sáng → máy nào để OS dark mode sẽ thấy biểu đồ màu tối trên thẻ trắng - -Điểm 3 là bài học đáng kể: **test xanh không có nghĩa là giao diện đúng.** - -## Trainer hay hỏi - -> *"Sao không dùng biểu đồ tròn cho top tour?"* - -Biểu đồ tròn rất khó so sánh các giá trị gần nhau. Cột ngang đọc được ngay thứ tự, và tên tour dài -có chỗ để hiển thị. - ---- - -# 5. WebSocket + STOMP - -## Áp dụng vào đâu - -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ỷ**. - -| File | Việc | -|---|---| -| `config/WebSocketConfig` | Endpoint `/admin/ws`, kênh `/topic/bookings` | -| `event/BookingEvent` | Bản sao dữ liệu của sự kiện | -| `event/BookingNotifier` | Nghe sự kiện → đẩy xuống trình duyệt | -| `static/js/admin-notifications.js` | Kết nối STOMP, hiện thông báo | - -## Demo - -**Chuẩn bị 2 cửa sổ:** trình duyệt mở `/admin/bookings`, và một terminal. - -Ở terminal, đặt một đơn qua API công khai: - -```bash -TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/register -H 'Content-Type: application/json' \ - -d '{"name":"Demo","email":"demo-'$(date +%s)'@example.com","password":"MatKhau123"}' \ - | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") - -curl -s -X POST localhost:8080/api/v1/bookings -H "Authorization: Bearer $TOKEN" \ - -H 'Content-Type: application/json' \ - -d '{"tourId":1,"date":"2026-10-14","adults":2,"children":0, - "contact":{"fullName":"Khách demo","email":"d@example.com","phone":"0900000000"}}' -``` - -Thông báo hiện lên ở trình duyệt **ngay lập tức**, không cần F5. - -## Giải thích gì - -**Endpoint đặt tại `/admin/ws` chứ không phải `/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**. - -**Không dùng SockJS.** SockJS dự phòng bằng các transport dùng POST, mà POST thì vướng CSRF của -chain quản trị — phải mở ngoại lệ CSRF cho cả nhánh đó. WebSocket thuần chỉ cần một `GET Upgrade` -nên không đụng tới CSRF. - -**`@TransactionalEventListener(AFTER_COMMIT)` chứ không `@EventListener` thường.** Nghe ngay lúc -phát sự kiện thì transaction **còn chưa commit**; một lỗi sau đó làm rollback sẽ để lại thông báo -về một đơn **không hề tồn tại**. Có test canh đúng chỗ này -(`sendsNothingWhenTransactionRollsBack`). - -**Đi qua `ApplicationEvent` chứ không gọi thẳng messaging từ service.** `BookingService` không cần -biết gì về WebSocket — và nhờ vậy cắm thêm JMS vào (mục 8) chỉ là thêm một listener. - -## Trainer hay hỏi - -> *"`BookingEvent` sao không truyền thẳng entity `Booking`?"* - -Người nhận xử lý ở **luồng khác** và **ngoài transaction**, nơi entity lazy đã hết dùng được — -chạm vào quan hệ lazy sẽ ném `LazyInitializationException`. Nên sự kiện là **bản sao dữ liệu** tại -thời điểm đó. - ---- - -# 6. Xuất Excel - -## Áp dụng vào đâu - -| 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 | -| `/admin` | Xuất doanh thu tháng | Gộp theo tour, bỏ qua đơn đã huỷ | - -File: `service/ExcelExportService`, `admin/ExcelDownload`. - -## Demo - -Vào `/admin/bookings`, bấm lọc **"Chờ xác nhận"**, rồi bấm **Xuất Excel**. Mở file ra: chỉ có đơn -đang chờ, không phải toàn bộ. - -## Giải thích gì - -**Xuất theo bộ lọc đang xem, không phải xuất tất cả.** Admin lọc xong rồi mới muốn gửi file cho ai -đó — đó là thao tác thường gặp hơn nhiều so với xuất toàn bộ. - -**Số ghi dạng số, không phải chuỗi**, để Excel tính tổng được. Ngày có định dạng `dd/mm/yyyy`. -Dòng tiêu đề được khoá (`createFreezePane`) để cuộn danh sách dài vẫn thấy tên cột. - -**Tên file có dấu tiếng Việt** hiển thị đúng nhờ `ContentDisposition.builder().filename(name, UTF_8)` -— nó tự sinh cả `filename*` theo RFC 5987. - ---- - -# 7. CSRF - -## Áp dụng vào đâu - -TripGo có **ba** `SecurityFilterChain` với cấu hình ngược nhau — đây chính là chỗ để giải thích -CSRF cho ra đầu ra đũa: - -| Chain | Phạm vi | Session | CSRF | Xác thực | -|---|---|---|---|---| -| `AdminSecurityConfig` | `/admin/**` | có | **BẬT** | form login | -| `SocialLoginConfig` | `/oauth2/**`, `/login/oauth2/**` | có | tắt | OAuth2 | -| `SecurityConfig` | còn lại | STATELESS | tắt | Bearer JWT | - -## Demo - -```bash -# Đăng nhập admin, lấy token CSRF từ form -# Rồi gửi POST KHÔNG kèm token: -curl -X POST localhost:8080/admin/tours/1/delete -b cookie.txt -# → 403 Forbidden -``` - -Trên giao diện, mở **Xem nguồn trang** bất kỳ form admin nào, chỉ vào -`` mà Thymeleaf tự chèn. - -## Giải thích gì - -**Vì sao API tắt CSRF mà không sai?** CSRF lợi dụng việc trình duyệt **tự động gửi cookie** kèm -request. API của TripGo xác thực bằng header `Authorization: Bearer ...` — trình duyệt **không tự -gửi header**, nên không có đường tấn công. Bật CSRF cho API stateless chỉ gây phiền, không tăng an -toàn. - -**Vì sao khu quản trị phải bật?** Nó dùng session cookie. Không có CSRF, một trang web độc hại chỉ -cần dụ admin bấm vào link là có thể gửi `POST /admin/tours/5/delete` kèm cookie của admin. - -**Một chi tiết tinh tế:** TripGo **tách lỗi CSRF khỏi lỗi phân quyền**: - -```java -if (exception instanceof CsrfException) → 403 // lỗi kỹ thuật -else → redirect /admin/login?denied // sai quyền -``` - -Gộp chung thì lỗi CSRF sẽ hiện thành *"bạn không có quyền"* — che mất nguyên nhân thật và làm người -debug đi sai hướng cả buổi. Test bắt được đúng chỗ này. - ---- - -# 8. Multithreading & JMS - -## Áp dụng vào đâu - -**Multithreading** — `config/AsyncConfig`: `ThreadPoolTaskExecutor` dùng chung cho job nền và gửi mail. - -**JMS** — mail xác nhận đặt tour đi qua hàng đợi `tripgo.booking.mail`: - -| File | Việc | -|---|---| -| `config/JmsConfig` | Cấu hình hàng đợi + listener | -| `mail/BookingMailDispatcher` | Interface — hai bản cài | -| `mail/DirectBookingMailDispatcher` | Bản dự phòng khi không có broker | - -## Demo - -**Không có broker (mặc định):** - -```bash -./mvnw spring-boot:run -# Đặt một đơn → log hiện: [MAIL] gửi tới ... — TripGo đã nhận đơn đặt tour của bạn -``` - -**Có broker:** - -```bash -docker compose up -d mq -JMS_ENABLED=true ./mvnw spring-boot:run -# Đặt một đơn → cùng dòng log đó, nhưng đã đi qua hàng đợi -# Mở http://localhost:8161 (admin/admin) xem hàng đợi tripgo.booking.mail -``` - -Hoặc chạy test — nhanh và không cần Docker: - -```bash -./mvnw -Dtest=BookingMailDispatchTest test -``` - -Test này chạy **cả hai nhánh**, trong đó nhánh JMS dùng broker nhúng `vm://` nên gói tin đi qua -hàng đợi **thật**. - -## Giải thích gì - -**Vì sao dùng hàng đợi mà không gọi thẳng?** Nếu máy chủ mail chập chờn: gọi thẳng thì mail **mất -luôn**; qua hàng đợi thì listener ném lỗi, message **quay lại hàng đợi để thử lại** -(`spring.jms.listener.session.transacted: true`). - -**Vì sao mặc định tắt?** 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. Môi trường dev và test không cần dựng broker chỉ để chạy ứng dụng. - -**Vì sao vẫn có bản dự phòng?** Nếu tắt JMS mà không có gì thay thế, việc gửi mail sẽ **lặng lẽ -biến mất** khi ai đó quên bật. 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. - -**Gửi dạng chuỗi JSON tự serialize** thay vì dùng message converter có sẵn: gói tin đi qua hàng đợi -có thể được đọc lại bởi **phiên bản ứng dụng khác**, nên định dạng phải là thứ mình kiểm soát được, -không phụ thuộc vào cách một thư viện tuần tự hoá đối tượng Java. - -## Trainer hay hỏi - -> *"`@Async` với JMS khác gì nhau? Đều là chạy nền cả?"* - -| | `@Async` | JMS | -|---|---|---| -| Việc nằm ở đâu | Trong bộ nhớ tiến trình | Trong broker, ngoài tiến trình | -| App chết giữa chừng | **Mất việc** | Việc còn nguyên trong hàng đợi | -| Thử lại khi lỗi | Tự viết | Có sẵn | -| Nhiều instance chia việc | Không | Có | - -`@Async` đủ cho việc không quan trọng; JMS cho việc **không được phép mất**. - ---- - -# 9. Reflection cho export/import - -## Áp dụng vào đâu - -Đây là phần **kiến trúc đẹp nhất** trong nhóm này. Package `demo.tripgo.excel`: - -```java -@Getter @Setter -public class TourExportRow { - @ExcelColumn(header = "Tên tour", order = 2) private String title; - @ExcelColumn(header = "Giá", order = 6) private BigDecimal price; -} -``` - -Chỉ vậy. Không viết một dòng code đọc/ghi Excel nào. - -| File | Việc | -|---|---| -| `excel/ExcelColumn` | Annotation đánh dấu field thành cột | -| `excel/ExcelMapper` | **Quét annotation bằng reflection**, đọc và ghi file | -| `excel/ExcelCellConverter` | Đổi ô Excel sang kiểu Java | -| `excel/ExcelReadResult` | Dòng đọc được + dòng lỗi nằm cạnh nhau | - -Hiện có **5 loại** dùng chung tầng này: `BookingExportRow`, `TourExportRow`, `RevenueExportRow`, -`TourImportRow`, và cả file mẫu. - -## Demo - -Mở song song 2 file cho trainer xem: - -1. `TourExportRow.java` — chỉ có field và annotation, **không có logic** -2. `ExcelMapper.java` — đoạn `scan()`: - -```java -for (Field field : current.getDeclaredFields()) { - ExcelColumn column = field.getAnnotation(ExcelColumn.class); - if (column == null) continue; - field.setAccessible(true); - fields.add(new ExcelField(field, column.header(), column.order(), column.required())); -} -``` - -Nói: *"thêm một loại dữ liệu mới — ví dụ xuất danh sách đánh giá — chỉ cần tạo một lớp và gắn -annotation. Không sửa `ExcelMapper` một dòng nào."* - -## Giải thích gì - -**Reflection giải quyết vấn đề gì?** Không có nó, mỗi loại dữ liệu phải có một hàm ghi riêng: - -```java -// Nếu không dùng reflection — lặp lại cho MỖI loại -sheet.createRow(0).createCell(0).setCellValue("Mã đơn"); -sheet.createRow(0).createCell(1).setCellValue("Khách"); -// ... 8 cột × 5 loại = 40 dòng lặp, và mỗi lần đổi cột phải sửa 2 chỗ -``` - -**Giá phải trả:** reflection chậm hơn gọi trực tiếp, nên `ExcelMapper` **cache kết quả quét** theo -lớp (`ConcurrentHashMap`) — mỗi lớp chỉ quét một lần. - -**Khớp cột theo tên tiêu đề, không theo vị trí** — người dùng đảo cột hay chèn thêm cột ghi chú vẫn -nhập được. - -**Một chi tiết tìm ra nhờ test:** dòng mà người dùng gõ rồi xoá để lại ô **CHUỖI RỖNG** chứ không -phải ô `BLANK`. Không tính cả hai thì file thật đầy những dòng "ma" ở cuối bảng sẽ báo lỗi -*"thiếu giá trị ở cột bắt buộc"*. - ---- - -# 10. Phần ngoài đề bài - -Nói thêm nếu còn thời gian — cho thấy đã nghĩ xa hơn yêu cầu: - -| Hạng mục | Nội dung | -|---|---| -| **Xoá mềm + thùng rác** | Tour/điểm đến xoá rồi khôi phục được; đơn hàng cũ không vỡ | -| **Upload ảnh** | Lưu ngoài classpath; đuôi file suy ra từ content type chứ **không** lấy từ tên file client gửi | -| **Testcontainers** | 9 test chạy Postgres **thật**, vì H2 chấp nhận nhiều thứ Postgres từ chối | -| **Tìm kiếm không dấu** | Gõ "da nang" ra "Đà Nẵng" | -| **SOAP** | Contract-first từ XSD cho hệ thống đối tác | - -**Câu chuyện đắt giá nhất để kể** (nếu trainer hỏi *"em học được gì?"*): - -> Bộ test 286 cái đều xanh, nhưng trang điểm đến vẫn lỗi 500 trên Postgres thật: -> `function lower(bytea) does not exist`. Nguyên nhân: truy vấn có tham số `null`, Postgres không -> suy được kiểu nên bind thành `bytea` — còn H2 trong test thì chạy bình thường. Từ đó em thêm -> Testcontainers để các truy vấn viết tay chạy trên đúng loại DB của production. - -Đây là câu trả lời cho thấy hiểu **giới hạn của test**, không chỉ biết viết test. - ---- - -# 11. Kịch bản demo 15 phút - -| Phút | Việc | Điểm nhấn | -|---|---|---| -| 0–1 | Mở `/admin/login`, đăng nhập | 3 chain bảo mật khác nhau | -| 1–3 | Dashboard: 4 ô số liệu + 2 biểu đồ + bảng kèm theo | Một chuỗi → một màu | -| 3–5 | Đặt đơn từ terminal → **thông báo hiện ngay** | `AFTER_COMMIT` | -| 5–7 | Lọc đơn "Chờ xác nhận" → Xuất Excel → mở file | Xuất theo bộ lọc | -| 7–10 | Nhập Excel có dòng sai → bảng lỗi theo từng dòng | Mỗi dòng một transaction | -| 10–12 | Mở `TourExportRow` + `ExcelMapper.scan()` | Reflection | -| 12–14 | `curl` gọi SOAP, lấy WSDL | Contract-first | -| 14–15 | Chạy `./mvnw test` | 286 test | - -**Chuẩn bị trước khi demo:** - -```bash -# 1. Bật app với chu kỳ job ngắn để demo được job nền -set -a; source .env; set +a && ./mvnw spring-boot:run - -# 2. Có sẵn tài khoản admin -# .env: ADMIN_EMAIL=... ADMIN_PASSWORD=... - -# 3. Có sẵn file Excel mẫu ĐÃ sửa vài dòng sai — đừng sửa live, mất thời gian - -# 4. Mở sẵn: trình duyệt (2 tab), terminal, IDE ở ExcelMapper.java -``` - -**Ba câu dễ bị hỏi nhất — chuẩn bị trước:** - -1. *"Vì sao API tắt CSRF?"* → JWT ở header, trình duyệt không tự gửi header -2. *"`@Transactional` trên method private có chạy không?"* → Không. Proxy không chặn được - self-invocation. Đó là lý do có `TourRowImporter` -3. *"Test chạy H2 mà production chạy Postgres, có vấn đề gì không?"* → Có, và em đã gặp thật. - Kể câu chuyện `lower(bytea)` ở mục 10