From eac981a250b6ad824841a34ea4211c871f9daefc Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 22 Sep 2026 20:36:02 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=94=84=EB=9F=B0=ED=8A=B8-=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20API=20=EA=B3=84=EC=95=BD=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=EC=99=80=20=EB=B3=B4=EC=95=88=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프런트 호출 전체를 서버 매핑과 대조해 드러난 문제들이다. 대부분 에러 없이 빈 화면이나 틀린 값으로 조용히 실패하고 있었다. 시설 - 리뷰 작성·수정·삭제, 예약 생성·조회·취소가 전부 500 이던 문제. JWT 필터의 principal 은 이메일 문자열이라 @AuthenticationPrincipal UserDetails 가 늘 null 이었다. CurrentUserFacade 로 바꾸고, 예약 소유 확인은 403 으로 준다. - 시설 전체 예약 목록·오늘 예약(보호자 이름·연락처 포함)이 로그인만 하면 열려 있었고, 본인이 자기 예약을 CONFIRMED 로 바꿀 수 있었다. 세 경로를 ADMIN 전용으로. - 시설 상세·리뷰·검색·예측을 비로그인에 연다 (공공데이터, 프런트가 로그인 전 노출). - 검색이 facilityType 을 무시하던 문제, size 누락 시 예외. - @ValidateLocation 이 인자 toString 에 "latitude" 글자가 있는지를 봐서 반경 검색· 지역별 조회·시설 검색이 입력과 무관하게 항상 400 이었다. 좌표 파라미터만 검사하도록. - 관리자 화면이 쓰는 REJECTED(반려) 상태를 추가. 겹침 계산에서 제외. 커뮤니티 - 익명 글이 실명으로 올라가던 문제. primitive boolean isAnonymous 의 JSON 키가 anonymous 라 프런트가 보낸 값이 버려졌다. - 익명 글 응답에 실명과 작성자 ID 가 그대로 실려 있었다. 이름은 가리고 ID 는 본인에게만. - 좋아요 수가 갱신되지 않고, isLiked/isBookmarked 가 늘 false 이던 문제. - 요청 본문 @Valid 적용. 길이 제한은 DB 컬럼(TEXT, VARCHAR(200))에 맞춘다. 건강·정책·알림·사용자 - 예방접종 완료 처리에서 일정이 경로의 아이 것인지 확인하지 않아 남의 기록을 바꿀 수 있었다. - 기록 유형 MEDICATION/SYMPTOM 이 서버에 없어 저장이 실패. 모르는 유형은 400. - 건강 기록 수정이 수정 화면에 없는 필드(완료 여부 등)를 null 로 덮던 문제. - 형제 요약의 다음 접종이 코드(HEP_B)로 나가던 문제. - 정책 검색 응답을 다른 목록과 같은 PolicyDto 로, 페이지 필드는 프런트가 읽는 이름으로. - 알림 isRead, 성장 곡선 zScore 의 JSON 키 이름. - 프로필 수정에서 빈 휴대폰 번호(지우기)와 하이픈 없는 번호를 받는다. --- docs/reference/access-control-matrix.md | 9 +- .../core/aspect/ValidationAspect.java | 50 +++- .../core/security/CurrentUserFacade.java | 21 ++ .../core/security/SecurityConfig.java | 13 +- .../CareFacilityBookingAdminService.java | 13 +- .../careFacility/app/CareFacilityFacade.java | 25 +- .../controller/CareFacilityController.java | 55 ++-- .../entity/CareFacilityBooking.java | 11 +- .../CareFacilityBookingRepository.java | 3 +- .../service/CareFacilityBookingService.java | 78 +++--- .../service/CareFacilityService.java | 39 ++- .../controller/CommunityController.java | 9 +- .../CommunityCreateCommentRequest.java | 8 +- .../request/CommunityCreatePostRequest.java | 10 +- .../CommunityUpdateCommentRequest.java | 2 +- .../request/CommunityUpdatePostRequest.java | 4 +- .../community/repository/PostRepository.java | 8 + .../community/service/CommunityService.java | 84 ++++++- .../health/controller/ChildController.java | 2 +- .../health/controller/HealthController.java | 2 +- .../dto/response/GrowthPointResponse.java | 6 + .../domain/health/entity/HealthRecord.java | 17 ++ .../health/mapper/HealthRecordMapper.java | 2 +- .../domain/health/service/HealthService.java | 12 +- .../service/SiblingOverviewService.java | 2 +- .../service/VaccinationScheduleService.java | 9 +- .../response/NotificationInfoResponse.java | 3 + .../dto/response/PolicyListResponse.java | 14 +- .../domain/policy/service/PolicyService.java | 47 ++-- .../user/controller/UserController.java | 4 + .../dto/request/UserUpdateRequestDto.java | 23 +- .../contract/JsonFieldNameContractTest.java | 51 ++++ .../CareFacilityBookingServiceTest.java | 16 +- .../CommunityServiceHiddenPostTest.java | 1 + .../CommunityServiceOwnershipTest.java | 1 + ...ccinationScheduleServiceOwnershipTest.java | 71 ++++++ .../dto/request/UserUpdateRequestDtoTest.java | 48 ++++ .../CommunityPolicyContractTest.java | 230 +++++++++++++++++ .../EndpointAuthorizationCoverageTest.java | 7 +- .../FacilityBookingContractTest.java | 237 ++++++++++++++++++ 40 files changed, 1075 insertions(+), 172 deletions(-) create mode 100644 src/test/java/com/carecode/contract/JsonFieldNameContractTest.java create mode 100644 src/test/java/com/carecode/domain/health/service/VaccinationScheduleServiceOwnershipTest.java create mode 100644 src/test/java/com/carecode/domain/user/dto/request/UserUpdateRequestDtoTest.java create mode 100644 src/test/java/com/carecode/integration/CommunityPolicyContractTest.java create mode 100644 src/test/java/com/carecode/integration/FacilityBookingContractTest.java diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md index 9174f4f8..2d33beba 100644 --- a/docs/reference/access-control-matrix.md +++ b/docs/reference/access-control-matrix.md @@ -76,6 +76,9 @@ flowchart TD | `/facilities/statistics` | — | | `/facilities/{id}/view` | 조회수 증가 | | `/facilities/{id}/rating` (GET) | 평점 조회 | +| `GET /facilities/{id}`, `/facilities/{id}/with-reviews`, `/facilities/{id}/reviews` | 시설 상세·공개 리뷰. 프런트가 로그인 전에도 보여 준다 | +| `GET /facilities/{id}/admission-forecast`, `/facilities/{id}/popularity`, `/facilities/{id}/waitlist/stats` | 공공데이터 기반 예측·집계 | +| `POST /facilities/search`, `POST /facilities/advanced-search` | 조건을 본문으로 받는 조회 | | `/api/public/care-facilities/**` | 공공데이터 조회 | ### 병원 @@ -135,8 +138,8 @@ flowchart TD | `/users/privacy/**` | 열람·동의·탈퇴 | | `/children/**` | 자녀 정보 | | `/notifications/**` | — | -| `/facilities/search` | 개인화 검색 | -| `/facilities/{id}/bookings/**` | 예약 | +| `POST /facilities/{id}/bookings`, `/facilities/bookings/user`, `/facilities/bookings/{bookingId}` | 본인 예약. 남의 예약은 403 | +| `/facilities/{id}/reviews` (POST), `/facilities/reviews/{reviewId}` | 리뷰 작성·수정·삭제 (본인 것만) | | `/facilities/waitlist/**`, `POST /facilities/{facilityId}/waitlist` | 대기 등록 | | `/community/comments/**` | 댓글 작성·수정 | | `POST /facilities/{id}/rating` | 평점 등록 | @@ -166,6 +169,8 @@ flowchart TD | `/api/admin/analytics/**` | 퍼널·리텐션 | | `/api/admin/policy-verification/**` | 금액 수기 검증 | | `/api/admin/reports/**` | 신고 처리 | +| `GET /facilities/{id}/bookings`, `/facilities/{id}/bookings/today`, `/facilities/bookings/today` | 다른 사용자의 예약(보호자 이름·연락처)이 담긴다. 메서드 `@PreAuthorize` | +| `PUT /facilities/bookings/{bookingId}/status` | 확정·완료·반려는 시설 측 업무. 본인 취소는 `DELETE` 로 한다 | ### 사용자 관리 (`/users` 에서 이관) diff --git a/src/main/java/com/carecode/core/aspect/ValidationAspect.java b/src/main/java/com/carecode/core/aspect/ValidationAspect.java index bc672e5e..21d5b1d7 100644 --- a/src/main/java/com/carecode/core/aspect/ValidationAspect.java +++ b/src/main/java/com/carecode/core/aspect/ValidationAspect.java @@ -3,10 +3,12 @@ import com.carecode.core.annotation.ValidateChildAge; import com.carecode.core.annotation.ValidateLocation; import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; @Aspect @@ -14,21 +16,49 @@ @Slf4j public class ValidationAspect { + /** + * 좌표를 받는 메서드면 좌표가 있고 범위 안인지 확인한다. + * + *

예전 구현은 인자의 {@code toString()} 에 "latitude" 라는 글자가 있는지를 봤다. + * 좌표 인자는 {@code 37.5} 같은 숫자라 그 글자가 나올 수 없고, 검색 DTO 는 toString 이 없고, + * 지역명은 그냥 문자열이라, 이 어노테이션이 붙은 API(반경 검색·지역별 조회·시설 검색·지역별 정책)가 + * 입력과 무관하게 전부 400 이었다. + * + *

이제 파라미터 이름이 {@code latitude}/{@code longitude} 인 인자만 좌표로 본다. + * 좌표를 받지 않는 메서드(지역명·검색 조건)에는 확인할 게 없으므로 통과시킨다. + */ @Before("@annotation(validateLocation)") public void validateLocation(JoinPoint joinPoint, ValidateLocation validateLocation) { + String[] names = ((MethodSignature) joinPoint.getSignature()).getParameterNames(); Object[] args = joinPoint.getArgs(); - - // 위치 정보 검증 로직 - boolean hasLocation = false; - for (Object arg : args) { - if (arg != null && (arg.toString().contains("latitude") || arg.toString().contains("longitude"))) { - hasLocation = true; - break; + if (names == null) { + return; + } + + boolean takesCoordinates = false; + Double latitude = null; + Double longitude = null; + for (int i = 0; i < names.length && i < args.length; i++) { + if ("latitude".equals(names[i])) { + takesCoordinates = true; + latitude = args[i] instanceof Number n ? n.doubleValue() : null; + } else if ("longitude".equals(names[i])) { + takesCoordinates = true; + longitude = args[i] instanceof Number n ? n.doubleValue() : null; } } - - if (validateLocation.required() && !hasLocation) { - throw new BusinessException(validateLocation.message()); + if (!takesCoordinates) { + return; + } + + if (latitude == null || longitude == null) { + if (validateLocation.required()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, validateLocation.message()); + } + return; + } + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "위도·경도 범위가 올바르지 않습니다."); } } diff --git a/src/main/java/com/carecode/core/security/CurrentUserFacade.java b/src/main/java/com/carecode/core/security/CurrentUserFacade.java index 53a663ca..e567afa8 100644 --- a/src/main/java/com/carecode/core/security/CurrentUserFacade.java +++ b/src/main/java/com/carecode/core/security/CurrentUserFacade.java @@ -11,6 +11,8 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import java.util.Optional; + /** Resolves the authenticated user from SecurityContextHolder and the persistence layer */ @Slf4j @Component @@ -37,6 +39,25 @@ public String requireCurrentUserEmail() { return authentication.getName(); } + /** + * 로그인했으면 사용자, 아니면 빈 값. 공개 API 가 "로그인한 사람에게만 덧붙일 정보" + * (좋아요 여부 등)를 계산할 때 쓴다. 비로그인을 예외로 다루지 않는다. + */ + public Optional findCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + return Optional.empty(); + } + Object principal = authentication.getPrincipal(); + String email = principal instanceof UserDetails userDetails ? userDetails.getUsername() : authentication.getName(); + if (email == null || "anonymousUser".equals(email)) { + return Optional.empty(); + } + return userRepository.findByEmailAndDeletedAtIsNull(email); + } + public User requireCurrentUser() { String email = requireCurrentUserEmail(); return userRepository.findByEmailAndDeletedAtIsNull(email) diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 051bd9a2..d2c646bc 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -147,6 +147,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/facilities/*/view").permitAll() .requestMatchers(HttpMethod.POST, "/facilities/*/rating").authenticated() .requestMatchers("/facilities/*/rating").permitAll() + // 시설 상세·리뷰·검색·예측은 공공데이터와 공개 리뷰뿐이라 비로그인에도 연다. + // 프런트는 이 화면들을 로그인 전에도 보여 주는데, 전에는 여기서 401 이 나 빈 화면이었다. + // GET /facilities/* 는 한 세그먼트라 /facilities/bookings/{id} 같은 예약 경로에는 걸리지 않는다. + .requestMatchers(HttpMethod.GET, "/facilities/*").permitAll() + .requestMatchers(HttpMethod.GET, "/facilities/recommend/**").permitAll() + .requestMatchers(HttpMethod.GET, + "/facilities/*/reviews", + "/facilities/*/with-reviews", + "/facilities/*/admission-forecast", + "/facilities/*/popularity", + "/facilities/*/waitlist/stats").permitAll() + .requestMatchers(HttpMethod.POST, "/facilities/search", "/facilities/advanced-search").permitAll() // 돌봄시설 공공데이터 API — 조회만 공개다. // @@ -224,7 +236,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/children/**").authenticated() .requestMatchers("/chatbot/**").authenticated() .requestMatchers("/api/**").authenticated() - .requestMatchers("/facilities/search").authenticated() .requestMatchers("/facilities/*/bookings/**").authenticated() .requestMatchers("/community/comments/**").authenticated() .requestMatchers("/notifications/**").authenticated() diff --git a/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java b/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java index 4d19cbd6..8326f073 100644 --- a/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java +++ b/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java @@ -142,12 +142,20 @@ public AdminBookingDetailResponse updateBookingStatus(Long bookingId, CareFacilityBooking booking = bookingRepository.findById(bookingId) .orElseThrow(() -> new CareServiceException("예약을 찾을 수 없습니다: " + bookingId)); - CareFacilityBooking.BookingStatus newStatus = CareFacilityBooking.BookingStatus.valueOf(request.getStatus()); + // 없는 상태면 valueOf 가 IllegalArgumentException 을 던져 500 이 됐다. 400 으로 돌려준다. + CareFacilityBooking.BookingStatus newStatus; + try { + newStatus = CareFacilityBooking.BookingStatus.valueOf(request.getStatus()); + } catch (IllegalArgumentException | NullPointerException e) { + throw new com.carecode.core.exception.BusinessException( + com.carecode.core.exception.ErrorCode.INVALID_INPUT, "알 수 없는 예약 상태입니다: " + request.getStatus()); + } switch (newStatus) { case CONFIRMED -> booking.confirm(); case COMPLETED -> booking.complete(); case CANCELLED -> booking.cancel(request.getReason() != null ? request.getReason() : "관리자에 의해 취소됨"); + case REJECTED -> booking.reject(request.getReason() != null ? request.getReason() : "관리자에 의해 반려됨"); default -> booking.setStatus(newStatus); } @@ -222,7 +230,8 @@ private List getStatusDistribution() { createStatusDistribution("PENDING", "대기중", bookingRepository.countByStatus(CareFacilityBooking.BookingStatus.PENDING), total), createStatusDistribution("CONFIRMED", "확정", bookingRepository.countByStatus(CareFacilityBooking.BookingStatus.CONFIRMED), total), createStatusDistribution("COMPLETED", "완료", bookingRepository.countByStatus(CareFacilityBooking.BookingStatus.COMPLETED), total), - createStatusDistribution("CANCELLED", "취소됨", bookingRepository.countByStatus(CareFacilityBooking.BookingStatus.CANCELLED), total) + createStatusDistribution("CANCELLED", "취소됨", bookingRepository.countByStatus(CareFacilityBooking.BookingStatus.CANCELLED), total), + createStatusDistribution("REJECTED", "반려", bookingRepository.countByStatus(CareFacilityBooking.BookingStatus.REJECTED), total) ); } diff --git a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java index 3fd748bd..840110ca 100644 --- a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java +++ b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java @@ -17,7 +17,6 @@ import com.carecode.domain.careFacility.service.FacilityPopularityService; import com.carecode.domain.careFacility.service.CareFacilityService; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -98,18 +97,18 @@ public CareFacilityStatsResponse getFacilityStats() { } @Transactional - public BookingResponse createBooking(Long facilityId, CreateBookingRequest request, UserDetails userDetails) { - return bookingService.createBooking(facilityId, request, userDetails); + public BookingResponse createBooking(Long facilityId, CreateBookingRequest request, String userId) { + return bookingService.createBooking(facilityId, request, userId); } @Transactional(readOnly = true) - public BookingResponse getBookingById(Long bookingId, UserDetails userDetails) { - return bookingService.getBookingById(bookingId, userDetails); + public BookingResponse getBookingById(Long bookingId, String userId) { + return bookingService.getBookingById(bookingId, userId); } @Transactional(readOnly = true) - public List getUserBookings(UserDetails userDetails) { - return bookingService.getUserBookings(userDetails); + public List getUserBookings(String userId) { + return bookingService.getUserBookings(userId); } @Transactional(readOnly = true) @@ -118,18 +117,18 @@ public List getFacilityBookings(Long facilityId) { } @Transactional - public BookingResponse updateBookingStatus(Long bookingId, String status, UserDetails userDetails) { - return bookingService.updateBookingStatus(bookingId, status, userDetails); + public BookingResponse updateBookingStatus(Long bookingId, String status) { + return bookingService.updateBookingStatus(bookingId, status); } @Transactional - public void cancelBooking(Long bookingId, UserDetails userDetails) { - bookingService.cancelBooking(bookingId, userDetails); + public void cancelBooking(Long bookingId, String userId) { + bookingService.cancelBooking(bookingId, userId); } @Transactional - public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest request, UserDetails userDetails) { - return bookingService.updateBooking(bookingId, request, userDetails); + public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest request, String userId) { + return bookingService.updateBooking(bookingId, request, userId); } @Transactional(readOnly = true) diff --git a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java index 8692d5f5..38238079 100644 --- a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java +++ b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java @@ -4,6 +4,7 @@ import com.carecode.core.annotation.ValidateLocation; import com.carecode.core.annotation.ValidateChildAge; import com.carecode.core.controller.BaseController; +import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.util.PageRequestUtil; import com.carecode.domain.careFacility.dto.request.CareFacilitySearchRequest; import com.carecode.domain.careFacility.dto.request.CareFacilityAdvancedSearchRequest; @@ -25,8 +26,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -42,6 +42,9 @@ public class CareFacilityController extends BaseController { private final CareFacilityFacade careFacilityFacade; + // JWT 필터가 principal 로 이메일 문자열을 넣으므로 @AuthenticationPrincipal UserDetails 는 늘 null 이다. + // 현재 사용자는 반드시 이 파사드로 얻는다. + private final CurrentUserFacade currentUser; // 전체 시설 목록 조회 @GetMapping @@ -289,26 +292,23 @@ public ResponseEntity> getFacilityReviews(@PathVariable Lon @LogExecutionTime @Operation(summary = "시설 리뷰 작성") public ResponseEntity createReview(@PathVariable Long id, - @RequestBody ReviewRequest request, - @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok(careFacilityFacade.createReview(id, userDetails.getUsername(), request)); + @RequestBody ReviewRequest request) { + return ResponseEntity.ok(careFacilityFacade.createReview(id, currentUser.requireCurrentUserEmail(), request)); } @PutMapping("/reviews/{reviewId}") @LogExecutionTime @Operation(summary = "시설 리뷰 수정") public ResponseEntity updateReview(@PathVariable Long reviewId, - @RequestBody ReviewRequest request, - @AuthenticationPrincipal UserDetails userDetails) { - return ResponseEntity.ok(careFacilityFacade.updateReview(reviewId, userDetails.getUsername(), request)); + @RequestBody ReviewRequest request) { + return ResponseEntity.ok(careFacilityFacade.updateReview(reviewId, currentUser.requireCurrentUserEmail(), request)); } @DeleteMapping("/reviews/{reviewId}") @LogExecutionTime @Operation(summary = "시설 리뷰 삭제") - public ResponseEntity deleteReview(@PathVariable Long reviewId, - @AuthenticationPrincipal UserDetails userDetails) { - careFacilityFacade.deleteReview(reviewId, userDetails.getUsername()); + public ResponseEntity deleteReview(@PathVariable Long reviewId) { + careFacilityFacade.deleteReview(reviewId, currentUser.requireCurrentUserEmail()); return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("리뷰가 삭제되었습니다.").build()); } @@ -317,10 +317,9 @@ public ResponseEntity deleteReview(@PathVariable Long reviewId, @LogExecutionTime @Operation(summary = "시설 예약 생성", description = "특정 육아 시설에 예약 생성") public ResponseEntity createBooking(@Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId, - @Parameter(description = "예약 정보", required = true) @RequestBody CreateBookingRequest request, - @AuthenticationPrincipal UserDetails userDetails) { + @Parameter(description = "예약 정보", required = true) @RequestBody CreateBookingRequest request) { - BookingResponse booking = careFacilityFacade.createBooking(facilityId, request, userDetails); + BookingResponse booking = careFacilityFacade.createBooking(facilityId, request, currentUser.requireCurrentUserId()); return ResponseEntity.ok(booking); } @@ -329,10 +328,9 @@ public ResponseEntity createBooking(@Parameter(description = " @GetMapping("/bookings/{bookingId}") @LogExecutionTime @Operation(summary = "예약 상세 조회", description = "특정 예약의 상세 정보 조회") - public ResponseEntity getBookingById(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, - @AuthenticationPrincipal UserDetails userDetails) { + public ResponseEntity getBookingById(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId) { - BookingResponse booking = careFacilityFacade.getBookingById(bookingId, userDetails); + BookingResponse booking = careFacilityFacade.getBookingById(bookingId, currentUser.requireCurrentUserId()); return ResponseEntity.ok(booking); } @@ -341,15 +339,16 @@ public ResponseEntity getBookingById(@Parameter(description = " @GetMapping("/bookings/user") @LogExecutionTime @Operation(summary = "사용자별 예약 목록 조회", description = "현재 로그인한 사용자의 예약 목록 조회") - public ResponseEntity> getUserBookings(@AuthenticationPrincipal UserDetails userDetails) { + public ResponseEntity> getUserBookings() { - List bookings = careFacilityFacade.getUserBookings(userDetails); + List bookings = careFacilityFacade.getUserBookings(currentUser.requireCurrentUserId()); return ResponseEntity.ok(bookings); } // 시설별 예약 목록 조회 @GetMapping("/{facilityId}/bookings") + @PreAuthorize("hasRole('ADMIN')") // 다른 사용자의 예약(보호자 이름·연락처)이 담긴다 @LogExecutionTime @Operation(summary = "시설별 예약 목록 조회") public ResponseEntity> getFacilityBookings(@Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId) { @@ -361,13 +360,13 @@ public ResponseEntity> getFacilityBookings(@Parameter(desc // 예약 상태 업데이트 @PutMapping("/bookings/{bookingId}/status") + @PreAuthorize("hasRole('ADMIN')") // 확정·완료는 시설 측 업무. 본인 취소는 DELETE 로 한다 @LogExecutionTime @Operation(summary = "예약 상태 업데이트") public ResponseEntity updateBookingStatus(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, - @Parameter(description = "새로운 상태", required = true) @RequestParam String status, - @AuthenticationPrincipal UserDetails userDetails) { + @Parameter(description = "새로운 상태", required = true) @RequestParam String status) { - BookingResponse booking = careFacilityFacade.updateBookingStatus(bookingId, status, userDetails); + BookingResponse booking = careFacilityFacade.updateBookingStatus(bookingId, status); return ResponseEntity.ok(booking); } @@ -376,10 +375,9 @@ public ResponseEntity updateBookingStatus(@Parameter(descriptio @DeleteMapping("/bookings/{bookingId}") @LogExecutionTime @Operation(summary = "예약 취소", description = "예약을 취소") - public ResponseEntity cancelBooking(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, - @AuthenticationPrincipal UserDetails userDetails) { + public ResponseEntity cancelBooking(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId) { - careFacilityFacade.cancelBooking(bookingId, userDetails); + careFacilityFacade.cancelBooking(bookingId, currentUser.requireCurrentUserId()); return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("예약이 성공적으로 취소되었습니다.").build()); } @@ -389,16 +387,16 @@ public ResponseEntity cancelBooking(@Parameter(description = "예약 @LogExecutionTime @Operation(summary = "예약 수정", description = "기존 예약 정보 수정") public ResponseEntity updateBooking(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, - @Parameter(description = "수정할 예약 정보", required = true) @RequestBody UpdateBookingRequest request, - @AuthenticationPrincipal UserDetails userDetails) { + @Parameter(description = "수정할 예약 정보", required = true) @RequestBody UpdateBookingRequest request) { - BookingResponse booking = careFacilityFacade.updateBooking(bookingId, request, userDetails); + BookingResponse booking = careFacilityFacade.updateBooking(bookingId, request, currentUser.requireCurrentUserId()); return ResponseEntity.ok(booking); } // 오늘의 예약 조회 @GetMapping("/bookings/today") + @PreAuthorize("hasRole('ADMIN')") // 다른 사용자의 예약(보호자 이름·연락처)이 담긴다 @LogExecutionTime @Operation(summary = "오늘의 예약 조회", description = "오늘 날짜의 예약 목록 조회") public ResponseEntity> getTodayBookings() { @@ -410,6 +408,7 @@ public ResponseEntity> getTodayBookings() { // 시설별 오늘의 예약 조회 @GetMapping("/{facilityId}/bookings/today") + @PreAuthorize("hasRole('ADMIN')") // 다른 사용자의 예약(보호자 이름·연락처)이 담긴다 @LogExecutionTime @Operation(summary = "시설별 오늘의 예약 조회", description = "특정 시설의 오늘 예약 목록 조회") public ResponseEntity> getTodayBookingsByFacility(@Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId) { diff --git a/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java b/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java index e9dee9fd..035b36e5 100644 --- a/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java +++ b/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java @@ -155,6 +155,13 @@ public void cancel(String reason) { this.cancelledAt = LocalDateTime.now(); } + // 예약 반려 (시설·관리자 측). 사유는 취소 사유 칼럼을 같이 쓴다. + public void reject(String reason) { + this.status = BookingStatus.REJECTED; + this.cancellationReason = reason; + this.cancelledAt = LocalDateTime.now(); + } + // 예약 확정 public void confirm() { this.status = BookingStatus.CONFIRMED; @@ -189,7 +196,9 @@ public enum BookingStatus { PENDING("대기중"), CONFIRMED("확정"), CANCELLED("취소됨"), - COMPLETED("완료"); + COMPLETED("완료"), + // 시설 측이 받지 않은 예약. 사용자가 스스로 거둔 CANCELLED 와 구분한다. + REJECTED("반려"); private final String displayName; diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java index 919517e2..ee714810 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java @@ -35,7 +35,8 @@ List findByFacilityIdAndStartTimeBetween(@Param("facilityId @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT COUNT(cb) FROM CareFacilityBooking cb " + "WHERE cb.facility.id = :facilityId " + - "AND cb.status <> com.carecode.domain.careFacility.entity.CareFacilityBooking.BookingStatus.CANCELLED " + + "AND cb.status NOT IN (com.carecode.domain.careFacility.entity.CareFacilityBooking.BookingStatus.CANCELLED, " + + " com.carecode.domain.careFacility.entity.CareFacilityBooking.BookingStatus.REJECTED) " + "AND (:excludeBookingId IS NULL OR cb.id <> :excludeBookingId) " + "AND cb.startTime < :newEnd AND cb.endTime > :newStart") long countOverlappingBookings(@Param("facilityId") Long facilityId, diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java index d2c54262..55df12fa 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java @@ -1,7 +1,10 @@ package com.carecode.domain.careFacility.service; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.exception.BusinessException; import com.carecode.core.exception.CareServiceException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.ResourceNotFoundException; import com.carecode.domain.careFacility.dto.response.BookingResponse; import com.carecode.domain.careFacility.dto.request.CreateBookingRequest; import com.carecode.domain.careFacility.dto.request.UpdateBookingRequest; @@ -16,7 +19,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,14 +43,14 @@ public class CareFacilityBookingService { // 예약 생성 @LogExecutionTime @Transactional - public BookingResponse createBooking(Long facilityId, CreateBookingRequest request, UserDetails userDetails) { + public BookingResponse createBooking(Long facilityId, CreateBookingRequest request, String userId) { // 시설 조회 CareFacility careFacility = careFacilityRepository.findById(facilityId) - .orElseThrow(() -> new CareServiceException("시설을 찾을 수 없습니다: " + facilityId)); + .orElseThrow(() -> new ResourceNotFoundException("시설을 찾을 수 없습니다: " + facilityId)); // 사용자 조회 - User user = userRepository.findByUserId(userDetails.getUsername()) - .orElseThrow(() -> new CareServiceException("사용자를 찾을 수 없습니다: " + userDetails.getUsername())); + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.")); // 예약 시간 중복 확인 validateBookingTime(careFacility, request.getStartTime(), request.getEndTime(), null); @@ -82,23 +84,21 @@ public BookingResponse createBooking(Long facilityId, CreateBookingRequest reque // 예약 조회 @LogExecutionTime - public BookingResponse getBookingById(Long bookingId, UserDetails userDetails) { + public BookingResponse getBookingById(Long bookingId, String userId) { CareFacilityBooking booking = bookingRepository.findById(bookingId) - .orElseThrow(() -> new CareServiceException("예약을 찾을 수 없습니다: " + bookingId)); + .orElseThrow(() -> new ResourceNotFoundException("예약을 찾을 수 없습니다: " + bookingId)); // 사용자 권한 확인 (예약자 본인 또는 관리자만 조회 가능) - if (!booking.getUserId().equals(userDetails.getUsername())) { - throw new CareServiceException("예약을 조회할 권한이 없습니다."); - } + requireOwner(booking, userId, "예약을 조회할 권한이 없습니다."); return convertToDto(booking); } // 사용자별 예약 목록 조회 @LogExecutionTime - public List getUserBookings(UserDetails userDetails) { - User user = userRepository.findByUserId(userDetails.getUsername()) - .orElseThrow(() -> new CareServiceException("사용자를 찾을 수 없습니다: " + userDetails.getUsername())); + public List getUserBookings(String userId) { + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다.")); List bookings = bookingRepository.findByUserIdOrderByStartTimeDesc(user.getUserId()); @@ -120,21 +120,22 @@ public List getFacilityBookings(Long facilityId) { // 예약 상태 업데이트 @LogExecutionTime @Transactional - public BookingResponse updateBookingStatus(Long bookingId, String status, UserDetails userDetails) { + public BookingResponse updateBookingStatus(Long bookingId, String status) { CareFacilityBooking booking = bookingRepository.findById(bookingId) - .orElseThrow(() -> new CareServiceException("예약을 찾을 수 없습니다: " + bookingId)); - - // 사용자 권한 확인 (예약자 본인 또는 관리자만 상태 변경 가능) - if (!booking.getUserId().equals(userDetails.getUsername())) { - throw new CareServiceException("예약 상태를 변경할 권한이 없습니다."); + .orElseThrow(() -> new ResourceNotFoundException("예약을 찾을 수 없습니다: " + bookingId)); + // 상태 전이(확정·완료)는 시설 측 업무라 관리자 전용이다. 권한은 컨트롤러의 @PreAuthorize 가 건다. + CareFacilityBooking.BookingStatus newStatus; + try { + newStatus = CareFacilityBooking.BookingStatus.valueOf(status); + } catch (IllegalArgumentException | NullPointerException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "알 수 없는 예약 상태입니다: " + status); } - CareFacilityBooking.BookingStatus newStatus = CareFacilityBooking.BookingStatus.valueOf(status); - switch (newStatus) { case CONFIRMED -> booking.confirm(); case COMPLETED -> booking.complete(); - case CANCELLED -> booking.cancel("사용자에 의해 취소됨"); + case CANCELLED -> booking.cancel("관리자에 의해 취소됨"); + case REJECTED -> booking.reject("관리자에 의해 반려됨"); default -> booking.setStatus(newStatus); } @@ -145,17 +146,16 @@ public BookingResponse updateBookingStatus(Long bookingId, String status, UserDe // 예약 취소 @LogExecutionTime @Transactional - public void cancelBooking(Long bookingId, UserDetails userDetails) { + public void cancelBooking(Long bookingId, String userId) { CareFacilityBooking booking = bookingRepository.findById(bookingId) - .orElseThrow(() -> new CareServiceException("예약을 찾을 수 없습니다: " + bookingId)); + .orElseThrow(() -> new ResourceNotFoundException("예약을 찾을 수 없습니다: " + bookingId)); // 사용자 권한 확인 (예약자 본인 또는 관리자만 취소 가능) - if (!booking.getUserId().equals(userDetails.getUsername())) { - throw new CareServiceException("예약을 취소할 권한이 없습니다."); - } + requireOwner(booking, userId, "예약을 취소할 권한이 없습니다."); - if (booking.getStatus() == CareFacilityBooking.BookingStatus.CANCELLED) { - throw new CareServiceException("이미 취소된 예약입니다."); + if (booking.getStatus() == CareFacilityBooking.BookingStatus.CANCELLED + || booking.getStatus() == CareFacilityBooking.BookingStatus.REJECTED) { + throw new CareServiceException("이미 취소되었거나 반려된 예약입니다."); } if (booking.getStatus() == CareFacilityBooking.BookingStatus.COMPLETED) { @@ -169,17 +169,16 @@ public void cancelBooking(Long bookingId, UserDetails userDetails) { // 예약 수정 @LogExecutionTime @Transactional - public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest request, UserDetails userDetails) { + public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest request, String userId) { CareFacilityBooking booking = bookingRepository.findById(bookingId) - .orElseThrow(() -> new CareServiceException("예약을 찾을 수 없습니다: " + bookingId)); + .orElseThrow(() -> new ResourceNotFoundException("예약을 찾을 수 없습니다: " + bookingId)); // 사용자 권한 확인 (예약자 본인만 수정 가능) - if (!booking.getUserId().equals(userDetails.getUsername())) { - throw new CareServiceException("예약을 수정할 권한이 없습니다."); - } + requireOwner(booking, userId, "예약을 수정할 권한이 없습니다."); - if (booking.getStatus() == CareFacilityBooking.BookingStatus.CANCELLED) { - throw new CareServiceException("취소된 예약은 수정할 수 없습니다."); + if (booking.getStatus() == CareFacilityBooking.BookingStatus.CANCELLED + || booking.getStatus() == CareFacilityBooking.BookingStatus.REJECTED) { + throw new CareServiceException("취소되었거나 반려된 예약은 수정할 수 없습니다."); } if (booking.getStatus() == CareFacilityBooking.BookingStatus.COMPLETED) { @@ -257,6 +256,13 @@ private void validateBookingTime(CareFacility facility, } } + /** 예약은 User.userId 로 저장된다. 본인 예약이 아니면 403. */ + private void requireOwner(CareFacilityBooking booking, String userId, String message) { + if (userId == null || !userId.equals(booking.getUserId())) { + throw new BusinessException(ErrorCode.FORBIDDEN, message); + } + } + // DTO 변환 private BookingResponse convertToDto(CareFacilityBooking booking) { return BookingResponse.builder() diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java index 88287961..4b1586ac 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java @@ -251,18 +251,26 @@ public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest r "name", Sort.Direction.ASC ); - Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), sort); + // size 는 primitive 라 빠지면 0 이 되고 PageRequest.of 가 예외를 낸다. + int page = com.carecode.core.util.PageRequestUtil.normalizePage(request.getPage()); + int size = com.carecode.core.util.PageRequestUtil.normalizeSize(request.getSize() > 0 ? request.getSize() : null); + Pageable pageable = PageRequest.of(page, size, sort); + + // 전에는 facilityType 을 받기만 하고 쿼리에 null 을 넘겨, 유형 필터를 골라도 전체가 나왔다. + FacilityType facilityType = parseFacilityType(request.getFacilityType()); + String address = firstNonBlank(request.getCity(), request.getDistrict()); // 키워드만 있는 검색은 전문 검색으로 처리한다. LIKE '%키워드%' 는 인덱스를 못 탄다. - boolean keywordOnly = request.getCity() == null || request.getCity().isBlank(); + boolean keywordOnly = address == null && facilityType == null; Page facilityPage; if (keywordOnly && fullTextSearchSupport.canUseFullText(request.getKeyword())) { String normalized = fullTextSearchSupport.normalize(request.getKeyword()); - facilityPage = careFacilityRepository.searchByFullText(normalized, - PageRequest.of(request.getPage(), request.getSize())); + facilityPage = careFacilityRepository.searchByFullText(normalized, PageRequest.of(page, size)); } else { + String keyword = request.getKeyword() == null || request.getKeyword().isBlank() + ? null : request.getKeyword().trim(); facilityPage = careFacilityRepository.findBySearchCriteria( - request.getKeyword(), null, request.getCity(), pageable); + keyword, facilityType, address, pageable); } List facilities = facilityPage.getContent().stream() @@ -279,6 +287,27 @@ public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest r .build(); } + private static FacilityType parseFacilityType(String raw) { + if (raw == null || raw.isBlank() || "ALL".equalsIgnoreCase(raw.trim())) { + return null; + } + try { + return FacilityType.valueOf(raw.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new com.carecode.core.exception.BusinessException( + com.carecode.core.exception.ErrorCode.INVALID_INPUT, "알 수 없는 시설 유형입니다: " + raw); + } + } + + private static String firstNonBlank(String... values) { + for (String v : values) { + if (v != null && !v.isBlank()) { + return v.trim(); + } + } + return null; + } + // 시설 유형별 조회 @LogExecutionTime public List getCareFacilitiesByType(FacilityType facilityType) { diff --git a/src/main/java/com/carecode/domain/community/controller/CommunityController.java b/src/main/java/com/carecode/domain/community/controller/CommunityController.java index 296e2925..21024e4f 100644 --- a/src/main/java/com/carecode/domain/community/controller/CommunityController.java +++ b/src/main/java/com/carecode/domain/community/controller/CommunityController.java @@ -12,6 +12,7 @@ import com.carecode.domain.community.dto.response.CommunityTagResponse; import com.carecode.domain.community.dto.response.CommunityPageResponse; import com.carecode.domain.community.app.CommunityFacade; +import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -64,7 +65,7 @@ public ResponseEntity getPost( @LogExecutionTime @Operation(summary = "게시글 작성", description = "새로운 게시글을 작성") public ResponseEntity createPost( - @Parameter(description = "게시글 정보", required = true) @RequestBody CommunityCreatePostRequest request) { + @Parameter(description = "게시글 정보", required = true) @Valid @RequestBody CommunityCreatePostRequest request) { CommunityPostResponse post = communityFacade.createPost(request); return ResponseEntity.ok(post); } @@ -75,7 +76,7 @@ public ResponseEntity createPost( @Operation(summary = "게시글 수정") public ResponseEntity updatePost( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, - @Parameter(description = "수정할 게시글 정보", required = true) @RequestBody CommunityUpdatePostRequest request) { + @Parameter(description = "수정할 게시글 정보", required = true) @Valid @RequestBody CommunityUpdatePostRequest request) { CommunityPostResponse post = communityFacade.updatePost(postId, request); return ResponseEntity.ok(post); } @@ -106,7 +107,7 @@ public ResponseEntity> getComments( @Operation(summary = "댓글 작성", description = "게시글에 댓글을 작성") public ResponseEntity createComment( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, - @Parameter(description = "댓글 정보", required = true) @RequestBody CommunityCreateCommentRequest request) { + @Parameter(description = "댓글 정보", required = true) @Valid @RequestBody CommunityCreateCommentRequest request) { CommunityCommentResponse comment = communityFacade.createComment(postId, request); return ResponseEntity.ok(comment); } @@ -117,7 +118,7 @@ public ResponseEntity createComment( @Operation(summary = "댓글 수정") public ResponseEntity updateComment( @Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId, - @Parameter(description = "수정할 댓글 정보", required = true) @RequestBody CommunityUpdateCommentRequest request) { + @Parameter(description = "수정할 댓글 정보", required = true) @Valid @RequestBody CommunityUpdateCommentRequest request) { CommunityCommentResponse comment = communityFacade.updateComment(commentId, request); return ResponseEntity.ok(comment); } diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java index b1378f60..649bbe7b 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java @@ -2,6 +2,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -16,10 +18,14 @@ @Builder public class CommunityCreateCommentRequest { @NotBlank(message = "댓글 내용은 필수입니다") - @Size(max = 500, message = "댓글은 500자 이하여야 합니다") + @Size(max = 2000, message = "댓글은 2000자 이하여야 합니다") private String content; private Long parentCommentId; + // Lombok 이 primitive boolean isX 의 접근자를 isX()/setX() 로 만들어 JSON 키가 "anonymous" 가 된다. + // 프런트는 "isAnonymous" 를 보내므로 값이 버려져 익명으로 쓴 글이 실명으로 올라갔다. + @JsonProperty("isAnonymous") + @JsonAlias("anonymous") private boolean isAnonymous; } diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java index d8dd50e0..727acfc3 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java @@ -2,6 +2,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -18,15 +20,19 @@ @Builder public class CommunityCreatePostRequest { @NotBlank(message = "제목은 필수입니다") - @Size(max = 100, message = "제목은 100자 이하여야 합니다") + @Size(max = 200, message = "제목은 200자 이하여야 합니다") // TITLE VARCHAR(200) private String title; @NotBlank(message = "내용은 필수입니다") - @Size(max = 2000, message = "내용은 2000자 이하여야 합니다") + @Size(max = 10000, message = "내용은 10000자 이하여야 합니다") // CONTENT TEXT(64KB) 안쪽 private String content; private String category; private List tags; + // Lombok 이 primitive boolean isX 의 접근자를 isX()/setX() 로 만들어 JSON 키가 "anonymous" 가 된다. + // 프런트는 "isAnonymous" 를 보내므로 값이 버려져 익명으로 쓴 글이 실명으로 올라갔다. + @JsonProperty("isAnonymous") + @JsonAlias("anonymous") private boolean isAnonymous; } diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java index 8362bc56..eece42a0 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java @@ -16,7 +16,7 @@ @Builder public class CommunityUpdateCommentRequest { @NotBlank(message = "댓글 내용은 필수입니다") - @Size(max = 500, message = "댓글은 500자 이하여야 합니다") + @Size(max = 2000, message = "댓글은 2000자 이하여야 합니다") private String content; } diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java index 5136d794..951d5941 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java @@ -18,11 +18,11 @@ @Builder public class CommunityUpdatePostRequest { @NotBlank(message = "제목은 필수입니다") - @Size(max = 100, message = "제목은 100자 이하여야 합니다") + @Size(max = 200, message = "제목은 200자 이하여야 합니다") // TITLE VARCHAR(200) private String title; @NotBlank(message = "내용은 필수입니다") - @Size(max = 2000, message = "내용은 2000자 이하여야 합니다") + @Size(max = 10000, message = "내용은 10000자 이하여야 합니다") // CONTENT TEXT(64KB) 안쪽 private String content; private String category; diff --git a/src/main/java/com/carecode/domain/community/repository/PostRepository.java b/src/main/java/com/carecode/domain/community/repository/PostRepository.java index 0f1ef14a..f05ce6cf 100644 --- a/src/main/java/com/carecode/domain/community/repository/PostRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/PostRepository.java @@ -60,6 +60,14 @@ public interface PostRepository extends JpaRepository { long countByAuthorId(Long authorId); + /** + * 좋아요 수를 실제 좋아요 행 수로 맞춘다. 증감(+1/-1) 대신 다시 세는 이유는 + * 동시 토글이나 예전 데이터로 어긋난 값도 다음 토글에 스스로 복구되게 하기 위해서다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Post p SET p.likeCount = (SELECT COUNT(pl) FROM PostLike pl WHERE pl.post.id = :postId) WHERE p.id = :postId") + int syncLikeCount(@Param("postId") Long postId); + /** 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). 숨김 글은 세지 않는다. */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Post p SET p.viewCount = COALESCE(p.viewCount, 0) + 1 WHERE p.id = :postId AND p.isActive = true") diff --git a/src/main/java/com/carecode/domain/community/service/CommunityService.java b/src/main/java/com/carecode/domain/community/service/CommunityService.java index 6c3186c4..e6dfd3f3 100644 --- a/src/main/java/com/carecode/domain/community/service/CommunityService.java +++ b/src/main/java/com/carecode/domain/community/service/CommunityService.java @@ -4,6 +4,7 @@ import com.carecode.core.exception.CommentAccessDeniedException; import com.carecode.core.exception.PostAccessDeniedException; import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.community.dto.request.CommunityCreatePostRequest; import com.carecode.domain.community.dto.request.CommunityUpdatePostRequest; import com.carecode.domain.community.dto.request.CommunityCreateCommentRequest; @@ -33,7 +34,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -56,6 +60,9 @@ public class CommunityService { private final PostLikeRepository postLikeRepository; private final BookmarkRepository bookmarkRepository; private final CommunityMapper communityMapper; + private final CurrentUserFacade currentUserFacade; + + static final String ANONYMOUS_AUTHOR_NAME = "익명"; /** * 게시글 목록 조회 (페이징). @@ -74,7 +81,7 @@ public CommunityPageResponse getAllPosts(int page, int si Pageable pageable = PageRequest.of(page, size, sort); Page postPage = postRepository.findAllActive(pageable); - List postResponses = communityMapper.toPostResponseList(postPage.getContent()); + List postResponses = present(postPage.getContent()); return CommunityPageResponse.builder() .content(postResponses) @@ -110,7 +117,11 @@ public CommunityPostDetailResponse getPostById(Long postId) { Post post = postRepository.findActiveById(postId) .orElseThrow(() -> new ResourceNotFoundException("게시글을 찾을 수 없습니다. ID: " + postId)); - return communityMapper.toPostDetailResponse(post); + CommunityPostDetailResponse response = communityMapper.toPostDetailResponse(post); + if (response != null) { + applyViewerState(List.of(post), List.of(response)); + } + return response; } // 게시글 작성 @@ -137,7 +148,7 @@ public CommunityPostResponse createPost(CommunityCreatePostRequest request) { addTagsToPost(savedPost, request.getTags()); } - return communityMapper.toPostResponse(savedPost); + return present(List.of(savedPost)).get(0); } // 게시글 수정 @@ -153,7 +164,7 @@ public CommunityPostResponse updatePost(Long postId, CommunityUpdatePostRequest post.setCategory(mapCategory(request.getCategory())); Post updatedPost = postRepository.save(post); - return communityMapper.toPostResponse(updatedPost); + return present(List.of(updatedPost)).get(0); } // 게시글 삭제 @@ -287,6 +298,59 @@ private Tag createTagIfNotExists(String tagName) { } // 현재 인증된 사용자 가져오기 + /** + * 게시글 응답에 "보는 사람" 기준 상태를 입힌다. + * + *

+ */ + private List present(List posts) { + List responses = communityMapper.toPostResponseList(posts); + applyViewerState(posts, responses); + return responses; + } + + private void applyViewerState(List posts, List responses) { + User viewer = currentUserFacade.findCurrentUser().orElse(null); + Set liked = Set.of(); + Set bookmarked = Set.of(); + if (viewer != null && !posts.isEmpty()) { + List ids = posts.stream().map(Post::getId).toList(); + liked = new HashSet<>(postLikeRepository.findLikedPostIdsByUserAndPostIds(viewer, ids)); + bookmarked = new HashSet<>(bookmarkRepository.findBookmarkedPostIdsByUserAndPostIds(viewer, ids)); + } + for (int i = 0; i < Math.min(posts.size(), responses.size()); i++) { + Post post = posts.get(i); + CommunityPostResponse response = responses.get(i); + if (response == null) { + continue; + } + response.setIsLiked(liked.contains(post.getId())); + response.setIsBookmarked(bookmarked.contains(post.getId())); + maskAnonymousAuthor(post, response, viewer); + } + } + + /** + * 익명 글이면 이름을 가린다. 작성자 ID 는 본인에게만 준다(수정·삭제 버튼 판단용). + * 남에게 ID 를 주면 같은 사용자의 실명 글과 이어 붙여 누구인지 알아낼 수 있다. + * 프런트 스키마가 authorId 를 필수 문자열로 받으므로 null 대신 빈 문자열을 준다. + */ + static void maskAnonymousAuthor(Post post, CommunityPostResponse response, User viewer) { + if (!Boolean.TRUE.equals(post.getIsAnonymous())) { + return; + } + response.setAuthorName(ANONYMOUS_AUTHOR_NAME); + boolean viewerIsAuthor = viewer != null && post.getAuthor() != null + && Objects.equals(viewer.getId(), post.getAuthor().getId()); + if (!viewerIsAuthor) { + response.setAuthorId(""); + } + } + private User getCurrentUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -381,7 +445,7 @@ public CommunityPageResponse searchPosts(String keyword, Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); Page postPage = postRepository.findByKeyword(keyword, pageable); - List postResponses = communityMapper.toPostResponseList(postPage.getContent()); + List postResponses = present(postPage.getContent()); return CommunityPageResponse.builder() .content(postResponses) @@ -403,7 +467,7 @@ public CommunityPageResponse getPopularPosts(int page, in Pageable pageable = PageRequest.of(page, size); Page postPage = postRepository.findPopularPosts(pageable); - List postResponses = communityMapper.toPostResponseList(postPage.getContent()); + List postResponses = present(postPage.getContent()); return CommunityPageResponse.builder() .content(postResponses) @@ -425,7 +489,7 @@ public CommunityPageResponse getLatestPosts(int page, int Pageable pageable = PageRequest.of(page, size); Page postPage = postRepository.findLatestPosts(pageable); - List postResponses = communityMapper.toPostResponseList(postPage.getContent()); + List postResponses = present(postPage.getContent()); return CommunityPageResponse.builder() .content(postResponses) @@ -455,6 +519,7 @@ public boolean toggleLike(Long postId, Long userId) { // 좋아요 취소 postLikeRepository.deleteByPostAndUser(post, user); log.info("좋아요 취소됨 - 게시글 ID: {}, 사용자 ID: {}", postId, userId); + postRepository.syncLikeCount(postId); return false; } else { // 좋아요 추가 @@ -464,6 +529,7 @@ public boolean toggleLike(Long postId, Long userId) { .build(); postLikeRepository.save(postLike); log.info("좋아요 추가됨 - 게시글 ID: {}, 사용자 ID: {}", postId, userId); + postRepository.syncLikeCount(postId); return true; } } @@ -527,7 +593,7 @@ public List getLikedPosts(Long userId) { .map(PostLike::getPost) .collect(Collectors.toList()); - return communityMapper.toPostResponseList(posts); + return present(posts); } // 사용자가 북마크한 게시글 목록 조회 @@ -541,7 +607,7 @@ public List getBookmarkedPosts(Long userId) { .map(Bookmark::getPost) .collect(Collectors.toList()); - return communityMapper.toPostResponseList(posts); + return present(posts); } @Transactional(readOnly = true) diff --git a/src/main/java/com/carecode/domain/health/controller/ChildController.java b/src/main/java/com/carecode/domain/health/controller/ChildController.java index 7446f3c6..c3a33f04 100644 --- a/src/main/java/com/carecode/domain/health/controller/ChildController.java +++ b/src/main/java/com/carecode/domain/health/controller/ChildController.java @@ -103,7 +103,7 @@ public ResponseEntity completeVaccination( @Parameter(description = "실제 접종일 (미지정 시 오늘)") @RequestParam(required = false) LocalDate completedDate) { childService.getChild(childId); - return ResponseEntity.ok(vaccinationScheduleService.markCompleted(scheduleId, completedDate)); + return ResponseEntity.ok(vaccinationScheduleService.markCompleted(childId, scheduleId, completedDate)); } // ==================== diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index 1d24f1b2..18e45732 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -395,7 +395,7 @@ public ResponseEntity> getHealthRecordsByDateRangeAsc @Operation(summary = "타입별 건강 기록 조회") public ResponseEntity> getHealthRecordsByType(@Parameter(description = "아동 ID", required = true) @RequestParam Long childId, @Parameter(description = "기록 타입 (VACCINATION, CHECKUP, MEDICATION, SYMPTOM, OTHER)", required = true) @RequestParam String recordType) { - List records = healthFacade.getHealthRecordsByType(childId, HealthRecord.RecordType.valueOf(recordType), getAuthenticatedUserPk()); + List records = healthFacade.getHealthRecordsByType(childId, HealthRecord.RecordType.parse(recordType), getAuthenticatedUserPk()); return ResponseEntity.ok(records); } diff --git a/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java b/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java index 5502141d..714a61fc 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java @@ -24,6 +24,12 @@ public class GrowthPointResponse { private final String interpretation; private final Boolean needsAttention; + /** Lombok 의 getZScore() 를 Jackson 은 "zscore" 로 읽는다. 프런트가 읽는 이름으로 고정한다. */ + @com.fasterxml.jackson.annotation.JsonProperty("zScore") + public Double getZScore() { + return zScore; + } + public static GrowthPointResponse of(LocalDate recordDate, int ageMonths, double value, diff --git a/src/main/java/com/carecode/domain/health/entity/HealthRecord.java b/src/main/java/com/carecode/domain/health/entity/HealthRecord.java index a3eea728..4c91ba2f 100644 --- a/src/main/java/com/carecode/domain/health/entity/HealthRecord.java +++ b/src/main/java/com/carecode/domain/health/entity/HealthRecord.java @@ -171,6 +171,9 @@ public enum RecordType { DENTAL("치과"), EYE("안과"), EMERGENCY("응급"), + // 프런트 기록 폼이 처음부터 보여 주던 유형. 서버에 없어 이 둘을 고르면 저장이 실패했다. + MEDICATION("투약"), + SYMPTOM("증상"), OTHER("기타"); private final String displayName; @@ -178,6 +181,20 @@ public enum RecordType { RecordType(String displayName) { this.displayName = displayName; } + + /** 모르는 값은 IllegalArgumentException(→500) 대신 400 으로 돌려준다. */ + public static RecordType parse(String raw) { + if (raw == null || raw.isBlank()) { + throw new com.carecode.core.exception.BusinessException( + com.carecode.core.exception.ErrorCode.INVALID_INPUT, "기록 유형은 필수입니다."); + } + try { + return valueOf(raw.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new com.carecode.core.exception.BusinessException( + com.carecode.core.exception.ErrorCode.INVALID_INPUT, "알 수 없는 기록 유형입니다: " + raw); + } + } public String getDisplayName() { return displayName; diff --git a/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java b/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java index 29d774f2..b1a78aa7 100644 --- a/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java +++ b/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java @@ -14,7 +14,7 @@ public class HealthRecordMapper implements RequestMapper getOverdue(Long childId) { } @Transactional - public VaccinationScheduleResponse markCompleted(Long scheduleId, LocalDate completedDate) { + public VaccinationScheduleResponse markCompleted(Long childId, Long scheduleId, LocalDate completedDate) { + // 일정이 경로의 아이 것인지 확인한다. 컨트롤러는 "그 아이가 내 아이인가" 만 보므로, + // 이 확인이 없으면 내 아이 ID 에 남의 일정 ID 를 붙여 남의 접종 기록을 바꿀 수 있었다. + // 남의 일정이어도 403 이 아니라 404 로 답해 일정 ID 의 존재 여부를 흘리지 않는다. VaccinationSchedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new IllegalArgumentException("접종 일정을 찾을 수 없습니다: " + scheduleId)); + .filter(s -> s.getChild() != null && java.util.Objects.equals(s.getChild().getId(), childId)) + .orElseThrow(() -> new com.carecode.core.exception.ResourceNotFoundException( + "접종 일정을 찾을 수 없습니다: " + scheduleId)); schedule.markCompleted(completedDate != null ? completedDate : LocalDate.now()); return VaccinationScheduleResponse.from(scheduleRepository.save(schedule)); diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java index f051b062..43a824f0 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java @@ -1,5 +1,6 @@ package com.carecode.domain.notification.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -21,6 +22,8 @@ public class NotificationInfoResponse { private String title; private String message; private String priority; + // 없으면 JSON 키가 "read" 가 되어 프런트가 모든 알림을 안 읽음으로 표시했다. + @JsonProperty("isRead") private boolean isRead; private LocalDateTime createdAt; private LocalDateTime readAt; diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java index b64603ec..08971d34 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java @@ -8,16 +8,26 @@ import java.util.List; -/** 정책 목록 응답 */ +/** + * 정책 검색 응답. + * + *

항목은 다른 정책 목록 API 와 같은 {@link PolicyDto} 다. 전에는 검색만 PolicyInfoResponse 로 + * 옮겨 담아(id 가 문자열, 지역·금액 필드 이름이 다름) 프런트 스키마 파싱이 실패했고, + * 검색 결과 화면은 늘 오류였다. 페이지 필드도 프런트가 읽는 이름(totalElements, pageSize)으로 준다. + */ @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder public class PolicyListResponse { - private List policies; + private List policies; + private long totalElements; + /** @deprecated totalElements 와 같다. 예전 이름을 쓰던 클라이언트를 위해 남긴다. */ + @Deprecated private long totalCount; private int currentPage; + private int pageSize; private int totalPages; private boolean hasNext; private boolean hasPrevious; diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyService.java b/src/main/java/com/carecode/domain/policy/service/PolicyService.java index 924c72b8..865e65c0 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyService.java @@ -9,7 +9,6 @@ import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.policy.mapper.PolicyMapper; import com.carecode.domain.policy.dto.response.PolicyListResponse; -import com.carecode.domain.policy.dto.response.PolicyInfoResponse; import com.carecode.domain.policy.dto.response.PolicyStatsSimpleResponse; import com.carecode.domain.policy.dto.response.PolicyCategoryStatsResponse; import com.carecode.domain.policy.dto.response.PolicyDto; @@ -79,12 +78,21 @@ public PolicyListResponse searchPolicies(PolicySearchRequest request) { "createdAt", Sort.Direction.DESC ); - Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), sort); + // size 는 primitive 라 빠지면 0 이 되고 PageRequest.of 가 예외를 낸다. + int page = com.carecode.core.util.PageRequestUtil.normalizePage(request.getPage()); + int size = com.carecode.core.util.PageRequestUtil.normalizeSize(request.getSize() > 0 ? request.getSize() : null); + Pageable pageable = PageRequest.of(page, size, sort); + // 프런트는 지역을 location 으로 보낸다. city 만 보던 탓에 지역 조건이 무시됐다. + String region = request.getLocation() != null && !request.getLocation().isBlank() + ? request.getLocation().trim() + : (request.getCity() != null && !request.getCity().isBlank() ? request.getCity().trim() : null); + String keyword = request.getKeyword() != null && !request.getKeyword().isBlank() ? request.getKeyword().trim() : null; + String category = request.getCategory() != null && !request.getCategory().isBlank() ? request.getCategory().trim() : null; Page policyPage = policyRepository.findBySearchCriteria( - request.getKeyword(), - request.getCategory(), - request.getCity(), + keyword, + category, + region, null, null, pageable @@ -94,39 +102,18 @@ public PolicyListResponse searchPolicies(PolicySearchRequest request) { .map(policyMapper::toResponse) .collect(Collectors.toList()); - List policiesResponse = policies.stream() - .map(dto -> PolicyInfoResponse.builder() - .id(dto.getId() != null ? dto.getId().toString() : null) - .title(dto.getTitle()) - .description(dto.getDescription()) - .category(dto.getCategory()) - .subCategory(null) - .city(null) - .district(dto.getLocation()) - .targetAge(null) - .incomeLevel(null) - .benefitAmount(dto.getSupportAmount() != null ? dto.getSupportAmount().toString() : null) - .applicationMethod(dto.getApplicationMethod()) - .requiredDocuments(dto.getRequiredDocuments()) - .contactInfo(dto.getContactInfo()) - .startDate(null) - .endDate(null) - .status(Boolean.TRUE.equals(dto.getIsActive()) ? "ACTIVE" : "INACTIVE") - .viewCount(dto.getViewCount() != null ? dto.getViewCount() : 0) - .createdAt(dto.getCreatedAt()) - .updatedAt(dto.getUpdatedAt()) - .build()) - .collect(Collectors.toList()); return PolicyListResponse.builder() - .policies(policiesResponse) + .policies(policies) + .totalElements(policyPage.getTotalElements()) .totalCount(policyPage.getTotalElements()) .currentPage(policyPage.getNumber()) + .pageSize(policyPage.getSize()) .totalPages(policyPage.getTotalPages()) .hasNext(policyPage.hasNext()) .hasPrevious(policyPage.hasPrevious()) .category(request.getCategory()) - .city(request.getCity()) + .city(region) .district(request.getDistrict()) .build(); } diff --git a/src/main/java/com/carecode/domain/user/controller/UserController.java b/src/main/java/com/carecode/domain/user/controller/UserController.java index 8afa64bd..13743352 100644 --- a/src/main/java/com/carecode/domain/user/controller/UserController.java +++ b/src/main/java/com/carecode/domain/user/controller/UserController.java @@ -85,6 +85,10 @@ public ResponseEntity updateProfile( @Valid @RequestBody UserUpdateRequestDto updateDto) { User user = userService.getUserEntityByEmail(getCurrentUserEmail()); userMapper.updateUserFromRequest(updateDto, user); + // 매퍼는 null 을 무시(IGNORE)하므로 "지우기" 는 빈 문자열로 들어온다. 저장은 null 로 한다. + if (updateDto.getPhoneNumber() != null && updateDto.getPhoneNumber().isEmpty()) { + user.setPhoneNumber(null); + } user.setUpdatedAt(LocalDateTime.now()); return ResponseEntity.ok(userMapper.toDto(userService.saveUser(user))); } diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java index 786e0a27..08af99d4 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java @@ -25,9 +25,30 @@ public class UserUpdateRequestDto { @Size(min = 2, max = 10, message = "이름은 2-10자 사이여야 합니다") private String name; - @Pattern(regexp = "^01[0-9]-[0-9]{3,4}-[0-9]{4}$", message = "올바른 휴대폰 번호 형식이 아닙니다 (예: 010-1234-5678)") + /** + * 빈 문자열은 "번호 지우기" 로 받는다. 하이픈 없이 숫자만 넣어도 받아서 하이픈을 붙여 저장한다. + * 전에는 둘 다 400 이었다. 프런트는 번호를 비워 두면 '' 를 보내므로, 번호 없는 사용자는 + * 이름이나 주소만 고치려 해도 프로필을 저장할 수 없었다. + */ + @Pattern(regexp = "^$|^01[0-9]-[0-9]{3,4}-[0-9]{4}$", message = "올바른 휴대폰 번호 형식이 아닙니다 (예: 010-1234-5678)") private String phoneNumber; + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = normalizePhoneNumber(phoneNumber); + } + + static String normalizePhoneNumber(String raw) { + if (raw == null) { + return null; + } + String trimmed = raw.trim(); + if (trimmed.matches("^01[0-9]{8,9}$")) { + int middleEnd = trimmed.length() - 4; + return trimmed.substring(0, 3) + "-" + trimmed.substring(3, middleEnd) + "-" + trimmed.substring(middleEnd); + } + return trimmed; + } + private LocalDate birthDate; private Gender gender; diff --git a/src/test/java/com/carecode/contract/JsonFieldNameContractTest.java b/src/test/java/com/carecode/contract/JsonFieldNameContractTest.java new file mode 100644 index 00000000..3fea1970 --- /dev/null +++ b/src/test/java/com/carecode/contract/JsonFieldNameContractTest.java @@ -0,0 +1,51 @@ +package com.carecode.contract; + +import com.carecode.domain.community.dto.request.CommunityCreateCommentRequest; +import com.carecode.domain.community.dto.request.CommunityCreatePostRequest; +import com.carecode.domain.health.dto.response.GrowthPointResponse; +import com.carecode.domain.notification.dto.response.NotificationInfoResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Lombok 이 primitive {@code boolean isX} 의 접근자를 {@code isX()}/{@code setX()} 로 만들면 + * Jackson 은 JSON 키를 {@code x} 로 쓴다. 프런트는 {@code isX} 를 주고받으므로 값이 조용히 사라진다. + * {@code zScore} 도 {@code getZScore()} 때문에 {@code zscore} 가 된다. 키 이름을 여기서 고정한다. + */ +@DisplayName("JSON 필드 이름 계약") +class JsonFieldNameContractTest { + + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + @DisplayName("게시글·댓글 작성 요청은 isAnonymous 를 읽는다 (예전 키 anonymous 도 받는다)") + void anonymousFlagIsRead() throws Exception { + assertThat(mapper.readValue("{\"title\":\"t\",\"content\":\"c\",\"isAnonymous\":true}", + CommunityCreatePostRequest.class).isAnonymous()).isTrue(); + assertThat(mapper.readValue("{\"content\":\"c\",\"isAnonymous\":true}", + CommunityCreateCommentRequest.class).isAnonymous()).isTrue(); + assertThat(mapper.readValue("{\"title\":\"t\",\"content\":\"c\",\"anonymous\":true}", + CommunityCreatePostRequest.class).isAnonymous()).isTrue(); + } + + @Test + @DisplayName("알림 응답은 isRead 키 하나로 나간다") + void notificationReadFlag() throws Exception { + JsonNode json = mapper.valueToTree(NotificationInfoResponse.builder().isRead(true).build()); + assertThat(json.path("isRead").asBoolean()).isTrue(); + assertThat(json.has("read")).isFalse(); + } + + @Test + @DisplayName("성장 곡선 응답은 zScore 키로 나간다") + void growthZScore() throws Exception { + JsonNode json = mapper.valueToTree(GrowthPointResponse.builder().zScore(1.5).build()); + assertThat(json.path("zScore").asDouble()).isEqualTo(1.5); + assertThat(json.has("zscore")).isFalse(); + } +} diff --git a/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java index 1aff7936..57965a6b 100644 --- a/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java +++ b/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java @@ -19,7 +19,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import org.springframework.security.core.userdetails.UserDetails; import java.time.LocalDateTime; import java.util.Optional; @@ -39,12 +38,12 @@ @DisplayName("시설 예약 - 겹침 검증") class CareFacilityBookingServiceTest { + private static final String USER_ID = "u-1"; private static final Long FACILITY_ID = 10L; @Mock private CareFacilityBookingRepository bookingRepository; @Mock private CareFacilityRepository careFacilityRepository; @Mock private UserRepository userRepository; - @Mock private UserDetails userDetails; @InjectMocks private CareFacilityBookingService bookingService; @@ -63,8 +62,7 @@ void setUp() { .name("보호자").role(UserRole.PARENT) .build(); - when(userDetails.getUsername()).thenReturn("u-1"); - when(userRepository.findByUserId("u-1")).thenReturn(Optional.of(user)); + when(userRepository.findByUserId(USER_ID)).thenReturn(Optional.of(user)); when(careFacilityRepository.findById(FACILITY_ID)).thenReturn(Optional.of(facility)); when(bookingRepository.save(any(CareFacilityBooking.class))).thenAnswer(inv -> inv.getArgument(0)); } @@ -75,7 +73,7 @@ void allowsBookingWhenBelowCapacity() { when(bookingRepository.countOverlappingBookings(eq(FACILITY_ID), any(), any(), isNull())) .thenReturn(1L); // 정원 2 중 1건 사용 - assertThatCode(() -> bookingService.createBooking(FACILITY_ID, futureRequest(), userDetails)) + assertThatCode(() -> bookingService.createBooking(FACILITY_ID, futureRequest(), USER_ID)) .doesNotThrowAnyException(); } @@ -85,7 +83,7 @@ void rejectsBookingWhenCapacityReached() { when(bookingRepository.countOverlappingBookings(eq(FACILITY_ID), any(), any(), isNull())) .thenReturn(2L); // 정원 2 모두 사용 - assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, futureRequest(), userDetails)) + assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, futureRequest(), USER_ID)) .isInstanceOf(CareServiceException.class) .hasMessageContaining("예약 가능한 자리가 없습니다"); @@ -99,7 +97,7 @@ void passesRequestedIntervalToOverlapQuery() { .thenReturn(0L); CreateBookingRequest request = futureRequest(); - bookingService.createBooking(FACILITY_ID, request, userDetails); + bookingService.createBooking(FACILITY_ID, request, USER_ID); ArgumentCaptor start = ArgumentCaptor.forClass(LocalDateTime.class); ArgumentCaptor end = ArgumentCaptor.forClass(LocalDateTime.class); @@ -117,7 +115,7 @@ void rejectsInvertedInterval() { CreateBookingRequest request = futureRequest(); request.setEndTime(request.getStartTime().minusHours(1)); - assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, userDetails)) + assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, USER_ID)) .isInstanceOf(CareServiceException.class) .hasMessageContaining("종료 시간은 시작 시간보다"); } @@ -129,7 +127,7 @@ void rejectsPastBooking() { request.setStartTime(LocalDateTime.now().minusDays(1)); request.setEndTime(LocalDateTime.now().minusDays(1).plusHours(2)); - assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, userDetails)) + assertThatThrownBy(() -> bookingService.createBooking(FACILITY_ID, request, USER_ID)) .isInstanceOf(CareServiceException.class) .hasMessageContaining("과거 시간"); } diff --git a/src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java b/src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java index 617e3684..5198a3f2 100644 --- a/src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java +++ b/src/test/java/com/carecode/domain/community/service/CommunityServiceHiddenPostTest.java @@ -53,6 +53,7 @@ class CommunityServiceHiddenPostTest { @Mock private PostLikeRepository postLikeRepository; @Mock private BookmarkRepository bookmarkRepository; @Mock private CommunityMapper communityMapper; + @Mock private com.carecode.core.security.CurrentUserFacade currentUserFacade; @InjectMocks private CommunityService communityService; diff --git a/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java b/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java index a93ed6ad..00c7a18c 100644 --- a/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java +++ b/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java @@ -56,6 +56,7 @@ class CommunityServiceOwnershipTest { @Mock private PostLikeRepository postLikeRepository; @Mock private BookmarkRepository bookmarkRepository; @Mock private CommunityMapper communityMapper; + @Mock private com.carecode.core.security.CurrentUserFacade currentUserFacade; @InjectMocks private CommunityService communityService; diff --git a/src/test/java/com/carecode/domain/health/service/VaccinationScheduleServiceOwnershipTest.java b/src/test/java/com/carecode/domain/health/service/VaccinationScheduleServiceOwnershipTest.java new file mode 100644 index 00000000..0c9262ad --- /dev/null +++ b/src/test/java/com/carecode/domain/health/service/VaccinationScheduleServiceOwnershipTest.java @@ -0,0 +1,71 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.exception.ResourceNotFoundException; +import com.carecode.domain.health.entity.VaccinationSchedule; +import com.carecode.domain.health.entity.VaccineType; +import com.carecode.domain.health.repository.VaccinationScheduleRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.repository.ChildRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 컨트롤러는 "경로의 아이가 내 아이인가" 만 확인한다. 일정이 그 아이 것인지 서비스가 보지 않으면 + * 내 아이 ID 에 남의 일정 ID 를 붙여 남의 접종 기록을 완료로 바꿀 수 있었다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("접종 완료 처리 — 일정 소유 확인") +class VaccinationScheduleServiceOwnershipTest { + + @Mock VaccinationScheduleRepository scheduleRepository; + @Mock ChildRepository childRepository; + @InjectMocks VaccinationScheduleService service; + + @Test + @DisplayName("다른 아이의 일정이면 404 이고 저장하지 않는다") + void rejectsScheduleOfAnotherChild() { + VaccinationSchedule othersSchedule = schedule(99L); + when(scheduleRepository.findById(5L)).thenReturn(Optional.of(othersSchedule)); + + assertThatThrownBy(() -> service.markCompleted(1L, 5L, LocalDate.now())) + .isInstanceOf(ResourceNotFoundException.class); + verify(scheduleRepository, never()).save(any()); + assertThat(othersSchedule.getCompletedDate()).isNull(); + } + + @Test + @DisplayName("같은 아이의 일정이면 완료 처리된다") + void completesOwnSchedule() { + VaccinationSchedule mine = schedule(1L); + when(scheduleRepository.findById(5L)).thenReturn(Optional.of(mine)); + when(scheduleRepository.save(mine)).thenReturn(mine); + + service.markCompleted(1L, 5L, LocalDate.of(2026, 1, 2)); + + assertThat(mine.getCompletedDate()).isEqualTo(LocalDate.of(2026, 1, 2)); + } + + private static VaccinationSchedule schedule(Long childId) { + return VaccinationSchedule.builder() + .id(5L) + .child(Child.builder().id(childId).build()) + .vaccineType(VaccineType.HEP_B) + .doseNumber(1) + .dueDate(LocalDate.now()) + .build(); + } +} diff --git a/src/test/java/com/carecode/domain/user/dto/request/UserUpdateRequestDtoTest.java b/src/test/java/com/carecode/domain/user/dto/request/UserUpdateRequestDtoTest.java new file mode 100644 index 00000000..4193df14 --- /dev/null +++ b/src/test/java/com/carecode/domain/user/dto/request/UserUpdateRequestDtoTest.java @@ -0,0 +1,48 @@ +package com.carecode.domain.user.dto.request; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 프로필 수정은 번호를 비우면 '' 를 보낸다. 예전 패턴은 이것도, 하이픈 없는 번호도 400 으로 막아 + * 번호가 없는 사용자는 이름·주소만 고치려 해도 저장할 수 없었다. + */ +@DisplayName("프로필 수정 — 휴대폰 번호") +class UserUpdateRequestDtoTest { + + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @ParameterizedTest(name = "\"{0}\" → \"{1}\"") + @CsvSource(value = { + "010-1234-5678|010-1234-5678", + "01012345678|010-1234-5678", + "0111234567|011-123-4567", + "' 010-1234-5678 '|010-1234-5678", + "''|''" + }, delimiter = '|') + void acceptsAndNormalizes(String input, String expected) throws Exception { + UserUpdateRequestDto dto = read(input); + assertThat(dto.getPhoneNumber()).isEqualTo(expected); + assertThat(validator.validate(dto)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"02-123-4567", "010-12-5678", "abc", "0101234567890"}) + void rejectsInvalid(String input) throws Exception { + assertThat(validator.validate(read(input))).isNotEmpty(); + } + + private UserUpdateRequestDto read(String phone) throws Exception { + return mapper.readValue("{\"name\":\"홍길동\",\"phoneNumber\":" + mapper.writeValueAsString(phone) + "}", + UserUpdateRequestDto.class); + } +} diff --git a/src/test/java/com/carecode/integration/CommunityPolicyContractTest.java b/src/test/java/com/carecode/integration/CommunityPolicyContractTest.java new file mode 100644 index 00000000..c15908c8 --- /dev/null +++ b/src/test/java/com/carecode/integration/CommunityPolicyContractTest.java @@ -0,0 +1,230 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +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; + +/** + * 프런트가 실제로 보내고 읽는 JSON 모양 그대로 커뮤니티·정책 API 를 호출해 본다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_content_contract;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("커뮤니티·정책 응답 계약") +class CommunityPolicyContractTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired PolicyRepository policyRepository; + @Autowired ObjectMapper objectMapper; + + private User author; + private User reader; + + @BeforeEach + void setUp() { + author = saveUser(); + reader = saveUser(); + } + + /** + * 프런트는 {@code isAnonymous} 로 보낸다. 서버 DTO 의 primitive {@code boolean isAnonymous} 는 + * JSON 키가 {@code anonymous} 라 값이 버려졌고, 익명으로 쓴 글이 실명으로 올라갔다. + * 게다가 익명 글도 응답에 실명과 작성자 ID 가 그대로 실려 있었다. + */ + @Test + @DisplayName("익명 글은 익명으로 저장되고, 남에게는 이름과 작성자 ID 가 가려진다") + void anonymousPostHidesAuthor() throws Exception { + long postId = createPost(true); + + JsonNode asReader = json(mockMvc.perform(as(reader, get("/community/posts/{id}", postId))).andReturn()); + assertThat(asReader.path("isAnonymous").asBoolean()).isTrue(); + assertThat(asReader.path("authorName").asText()).isEqualTo("익명"); + assertThat(asReader.path("authorId").asText()).isEmpty(); + + JsonNode anonymousViewer = json(mockMvc.perform(get("/community/posts/{id}", postId)).andReturn()); + assertThat(anonymousViewer.path("authorName").asText()).isEqualTo("익명"); + assertThat(anonymousViewer.path("authorId").asText()).isEmpty(); + + // 작성자 본인은 수정·삭제 버튼을 띄워야 하므로 ID 를 받는다. + JsonNode asAuthor = json(mockMvc.perform(as(author, get("/community/posts/{id}", postId))).andReturn()); + assertThat(asAuthor.path("authorId").asText()).isEqualTo(author.getId().toString()); + + JsonNode list = json(mockMvc.perform(get("/community/posts")).andReturn()); + for (JsonNode post : list.path("content")) { + if (post.path("postId").asLong() == postId) { + assertThat(post.path("authorName").asText()).isEqualTo("익명"); + assertThat(post.path("authorId").asText()).isEmpty(); + } + } + } + + @Test + @DisplayName("실명 글은 이름이 그대로 나간다") + void namedPostShowsAuthor() throws Exception { + long postId = createPost(false); + + JsonNode post = json(mockMvc.perform(get("/community/posts/{id}", postId)).andReturn()); + assertThat(post.path("isAnonymous").asBoolean()).isFalse(); + assertThat(post.path("authorName").asText()).isEqualTo(author.getName()); + assertThat(post.path("authorId").asText()).isEqualTo(author.getId().toString()); + } + + @Test + @DisplayName("좋아요를 누르면 좋아요 수와 내 좋아요 여부가 응답에 반영된다") + void likeIsReflected() throws Exception { + long postId = createPost(false); + + MvcResult liked = mockMvc.perform(as(reader, post("/community/posts/{id}/like", postId))).andReturn(); + assertThat(liked.getResponse().getStatus()).isEqualTo(200); + mockMvc.perform(as(reader, post("/community/posts/{id}/bookmark", postId))).andReturn(); + + JsonNode asReader = json(mockMvc.perform(as(reader, get("/community/posts/{id}", postId))).andReturn()); + assertThat(asReader.path("likeCount").asInt()).isEqualTo(1); + assertThat(asReader.path("isLiked").asBoolean()).isTrue(); + assertThat(asReader.path("isBookmarked").asBoolean()).isTrue(); + + JsonNode asAuthor = json(mockMvc.perform(as(author, get("/community/posts/{id}", postId))).andReturn()); + assertThat(asAuthor.path("likeCount").asInt()).isEqualTo(1); + assertThat(asAuthor.path("isLiked").asBoolean()).isFalse(); + + // 취소하면 다시 0 + mockMvc.perform(as(reader, post("/community/posts/{id}/like", postId))).andReturn(); + JsonNode after = json(mockMvc.perform(get("/community/posts/{id}", postId)).andReturn()); + assertThat(after.path("likeCount").asInt()).isZero(); + } + + @Test + @DisplayName("본문 검증이 동작한다 — 빈 제목은 400") + void postBodyIsValidated() throws Exception { + MvcResult result = mockMvc.perform(as(author, post("/community/posts")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"\",\"content\":\"내용\"}")) + .andReturn(); + assertThat(result.getResponse().getStatus()).isEqualTo(400); + } + + /** + * 검색만 항목을 PolicyInfoResponse(문자열 id, 다른 필드 이름)로 옮겨 담고 페이지 필드 이름도 달라서 + * 프런트 스키마 파싱이 실패했고 검색 결과 화면은 늘 오류였다. + */ + @Test + @DisplayName("정책 검색은 다른 정책 목록과 같은 항목 모양과 프런트가 읽는 페이지 필드를 준다") + void policySearchShape() throws Exception { + policyRepository.save(Policy.builder() + .policyCode("P-" + UUID.randomUUID()) + .title("양육수당 지원") + .description("가정양육 아동에게 지급") + .targetRegion("서울특별시") + .isActive(true) + .build()); + + MvcResult result = mockMvc.perform(post("/policies/search") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"keyword\":\"양육\",\"location\":\"서울\"}")) + .andReturn(); + assertThat(result.getResponse().getStatus()).as(result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8)).isEqualTo(200); + + JsonNode body = json(result); + assertThat(body.path("totalElements").asLong()).isGreaterThanOrEqualTo(1); + assertThat(body.has("pageSize")).isTrue(); + assertThat(body.has("totalPages")).isTrue(); + assertThat(body.has("currentPage")).isTrue(); + JsonNode first = body.path("policies").get(0); + assertThat(first.path("id").isNumber()).isTrue(); + assertThat(first.path("title").asText()).contains("양육"); + } + + private long createPost(boolean anonymous) throws Exception { + MvcResult created = mockMvc.perform(as(author, post("/community/posts")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"제목\",\"content\":\"내용\",\"category\":\"PARENTING\",\"isAnonymous\":%s}" + .formatted(anonymous))) + .andReturn(); + assertThat(created.getResponse().getStatus()) + .as(created.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8)).isEqualTo(200); + JsonNode body = json(created); + assertThat(body.path("isAnonymous").asBoolean()).isEqualTo(anonymous); + return body.path("postId").asLong(); + } + + private User saveUser() { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("사용자" + id) + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } + + private MockHttpServletRequestBuilder as(User user, MockHttpServletRequestBuilder request) { + String token = jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + return request.header("Authorization", "Bearer " + token); + } + + private JsonNode json(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java index ae41614a..ee096cfd 100644 --- a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java +++ b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java @@ -122,7 +122,12 @@ class EndpointAuthorizationCoverageTest { // 조회수 증가. 쓰기지만 비로그인 방문자의 조회도 세야 하므로 공개다. // 다만 이 값이 /facilities/popular 순위에 쓰이므로 부풀릴 수 있다. // 인기 순위를 조작에 민감하게 다뤄야 한다면 여기부터 손봐야 한다. - "POST /facilities/1/view" + "POST /facilities/1/view", + + // 시설 검색. 조건을 본문으로 받아 POST 지만 읽기다. 공공데이터만 돌려준다. + // 프런트가 로그인 전 탐색 화면에서 부른다. + "POST /facilities/search", + "POST /facilities/advanced-search" )); /** diff --git a/src/test/java/com/carecode/integration/FacilityBookingContractTest.java b/src/test/java/com/carecode/integration/FacilityBookingContractTest.java new file mode 100644 index 00000000..61dc9ee4 --- /dev/null +++ b/src/test/java/com/carecode/integration/FacilityBookingContractTest.java @@ -0,0 +1,237 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.request.MockMvcRequestBuilders.put; + +/** + * 시설 리뷰·예약 API 를 실제 JWT 로 호출해 본다. + * + *

JWT 필터는 principal 로 이메일 문자열을 넣는다. 컨트롤러가 {@code @AuthenticationPrincipal UserDetails} + * 를 받고 있어 값이 늘 null 이었고, 리뷰 작성·수정·삭제와 예약 생성·조회·취소가 전부 500 이었다. + * {@code @WithMockUser} 는 principal 을 UserDetails 로 넣어 주므로 이 결함을 재현하지 못한다. + * 그래서 여기서는 실제 토큰을 발급해 필터를 통과시킨다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_facility_contract;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("시설 리뷰·예약 계약") +class FacilityBookingContractTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired CareFacilityRepository careFacilityRepository; + @Autowired ObjectMapper objectMapper; + + private User owner; + private User stranger; + private Long facilityId; + + @BeforeEach + void setUp() { + owner = saveUser(); + stranger = saveUser(); + facilityId = careFacilityRepository.save(CareFacility.builder() + .facilityCode("F-" + UUID.randomUUID()) + .name("행복 어린이집") + .facilityType(FacilityType.DAYCARE) + .address("서울특별시 강남구") + .capacity(10) + .isActive(true) + .build()).getId(); + } + + @Test + @DisplayName("리뷰 작성·수정·삭제가 500 없이 동작한다") + void reviewLifecycle() throws Exception { + MvcResult created = mockMvc.perform(as(owner, post("/facilities/{id}/reviews", facilityId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":5,\"content\":\"선생님들이 친절해요\"}")) + .andReturn(); + assertThat(created.getResponse().getStatus()).as(body(created)).isEqualTo(200); + long reviewId = json(created).path("reviewId").asLong(); + + MvcResult updated = mockMvc.perform(as(owner, put("/facilities/reviews/{id}", reviewId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":4,\"content\":\"수정\"}")) + .andReturn(); + assertThat(updated.getResponse().getStatus()).as(body(updated)).isEqualTo(200); + + MvcResult deleted = mockMvc.perform(as(owner, delete("/facilities/reviews/{id}", reviewId))).andReturn(); + assertThat(deleted.getResponse().getStatus()).as(body(deleted)).isEqualTo(200); + } + + @Test + @DisplayName("예약은 본인만 조회·취소할 수 있고, 남의 예약은 403 이다") + void bookingOwnership() throws Exception { + LocalDateTime start = LocalDateTime.now().plusDays(3).truncatedTo(ChronoUnit.HOURS); + String request = """ + {"childName":"아이","childAge":3,"parentName":"보호자","parentPhone":"010-1234-5678", + "bookingType":"VISIT","startTime":"%s","endTime":"%s"} + """.formatted(start, start.plusHours(1)); + + MvcResult created = mockMvc.perform(as(owner, post("/facilities/{id}/bookings", facilityId)) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andReturn(); + assertThat(created.getResponse().getStatus()).as(body(created)).isEqualTo(200); + long bookingId = json(created).path("id").asLong(); + + MvcResult mine = mockMvc.perform(as(owner, get("/facilities/bookings/user"))).andReturn(); + assertThat(mine.getResponse().getStatus()).as(body(mine)).isEqualTo(200); + assertThat(json(mine)).hasSize(1); + + assertThat(status(as(owner, get("/facilities/bookings/{id}", bookingId)))).isEqualTo(200); + assertThat(status(as(stranger, get("/facilities/bookings/{id}", bookingId)))).isEqualTo(403); + assertThat(status(as(stranger, delete("/facilities/bookings/{id}", bookingId)))).isEqualTo(403); + assertThat(status(as(owner, delete("/facilities/bookings/{id}", bookingId)))).isEqualTo(200); + } + + @Test + @DisplayName("다른 사람의 예약(보호자 이름·연락처)이 담긴 목록과 상태 변경은 일반 회원에게 403 이다") + void facilityWideBookingViewsAreAdminOnly() throws Exception { + assertThat(status(as(owner, get("/facilities/{id}/bookings", facilityId)))).isEqualTo(403); + assertThat(status(as(owner, get("/facilities/{id}/bookings/today", facilityId)))).isEqualTo(403); + assertThat(status(as(owner, get("/facilities/bookings/today")))).isEqualTo(403); + assertThat(status(as(owner, put("/facilities/bookings/1/status").param("status", "CONFIRMED")))).isEqualTo(403); + } + + @Test + @DisplayName("시설 상세·리뷰·검색은 로그인 없이 볼 수 있다") + void facilityReadsArePublic() throws Exception { + assertThat(status(get("/facilities/{id}", facilityId))).isEqualTo(200); + assertThat(status(get("/facilities/{id}/reviews", facilityId))).isEqualTo(200); + assertThat(status(get("/facilities/{id}/with-reviews", facilityId))).isEqualTo(200); + assertThat(status(post("/facilities/search").contentType(MediaType.APPLICATION_JSON).content("{}"))) + .isEqualTo(200); + } + + @Test + @DisplayName("시설 검색의 유형 필터가 실제로 적용된다") + void searchAppliesFacilityType() throws Exception { + MvcResult daycare = mockMvc.perform(post("/facilities/search") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"facilityType\":\"DAYCARE\",\"size\":50}")) + .andReturn(); + assertThat(daycare.getResponse().getStatus()).as(body(daycare)).isEqualTo(200); + assertThat(json(daycare).path("facilities")).isNotEmpty(); + + MvcResult kindergarten = mockMvc.perform(post("/facilities/search") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"facilityType\":\"KINDERGARTEN\",\"size\":50}")) + .andReturn(); + assertThat(json(kindergarten).path("facilities").findValuesAsText("facilityType")) + .doesNotContain("DAYCARE"); + + assertThat(status(post("/facilities/search").contentType(MediaType.APPLICATION_JSON) + .content("{\"facilityType\":\"NOPE\"}"))).isEqualTo(400); + } + + /** + * {@code @ValidateLocation} 은 인자 toString 에 "latitude" 글자가 있는지를 봐서, + * 붙은 API 가 입력과 무관하게 전부 400 이었다. 접근제어 테스트는 401·403 만 아니면 통과라 놓쳤다. + */ + @Test + @DisplayName("위치 검증이 붙은 조회 API 가 정상 입력에 200 을 준다") + void locationValidatedEndpointsAcceptValidInput() throws Exception { + assertThat(status(get("/facilities/radius") + .param("latitude", "37.5").param("longitude", "127.0").param("radius", "5"))).isEqualTo(200); + assertThat(status(get("/facilities/location/{location}", "강남구"))).isEqualTo(200); + assertThat(status(as(owner, get("/policies/location/{location}", "서울")))).isEqualTo(200); + + assertThat(status(get("/facilities/radius") + .param("latitude", "999").param("longitude", "127.0").param("radius", "5"))).isEqualTo(400); + } + + private User saveUser() { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("사용자" + id) + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } + + private MockHttpServletRequestBuilder as(User user, MockHttpServletRequestBuilder request) { + String token = jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + return request.header("Authorization", "Bearer " + token); + } + + private int status(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request).andReturn().getResponse().getStatus(); + } + + private JsonNode json(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8)); + } + + private static String body(MvcResult result) throws Exception { + return result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8); + } +}