Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/reference/access-control-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/**` | 공공데이터 조회 |

### 병원
Expand Down Expand Up @@ -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` | 평점 등록 |
Expand Down Expand Up @@ -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` 에서 이관)

Expand Down
50 changes: 40 additions & 10 deletions src/main/java/com/carecode/core/aspect/ValidationAspect.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,62 @@
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
@Component
@Slf4j
public class ValidationAspect {

/**
* 좌표를 받는 메서드면 좌표가 있고 범위 안인지 확인한다.
*
* <p>예전 구현은 인자의 {@code toString()} 에 "latitude" 라는 글자가 있는지를 봤다.
* 좌표 인자는 {@code 37.5} 같은 숫자라 그 글자가 나올 수 없고, 검색 DTO 는 toString 이 없고,
* 지역명은 그냥 문자열이라, 이 어노테이션이 붙은 API(반경 검색·지역별 조회·시설 검색·지역별 정책)가
* 입력과 무관하게 전부 400 이었다.
*
* <p>이제 파라미터 이름이 {@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, "위도·경도 범위가 올바르지 않습니다.");
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/carecode/core/security/CurrentUserFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,6 +39,25 @@ public String requireCurrentUserEmail() {
return authentication.getName();
}

/**
* 로그인했으면 사용자, 아니면 빈 값. 공개 API 가 "로그인한 사람에게만 덧붙일 정보"
* (좋아요 여부 등)를 계산할 때 쓴다. 비로그인을 예외로 다루지 않는다.
*/
public Optional<User> 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)
Expand Down
13 changes: 12 additions & 1 deletion src/main/java/com/carecode/core/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 — 조회만 공개다.
//
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -222,7 +230,8 @@ private List<StatusDistribution> 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)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<BookingResponse> getUserBookings(UserDetails userDetails) {
return bookingService.getUserBookings(userDetails);
public List<BookingResponse> getUserBookings(String userId) {
return bookingService.getUserBookings(userId);
}

@Transactional(readOnly = true)
Expand All @@ -118,18 +117,18 @@ public List<BookingResponse> 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)
Expand Down
Loading
Loading