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) {
}
// 현재 인증된 사용자 가져오기
+ /**
+ * 게시글 응답에 "보는 사람" 기준 상태를 입힌다.
+ *
+ *
+ * - 좋아요·북마크 여부 — 매퍼는 사용자를 모르므로 늘 false 였다. 목록 전체를 쿼리 두 번으로 채운다.
+ * - 익명 글 작성자 가리기 — 전에는 익명 글에도 실명과 작성자 ID 가 그대로 나갔다.
+ * 화면만 "익명" 으로 바꿔 보여 줬을 뿐 응답을 보면 누구 글인지 알 수 있었다.
+ *
+ */
+ private List present(List posts) {
+ List responses = communityMapper.toPostResponseList(posts);
+ applyViewerState(posts, responses);
+ return responses;
+ }
+
+ private void applyViewerState(List posts, List extends CommunityPostResponse> 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);
+ }
+}