diff --git a/Seohui/build.gradle b/Seohui/build.gradle index 6a7f11f8..676e3fd5 100644 --- a/Seohui/build.gradle +++ b/Seohui/build.gradle @@ -32,6 +32,10 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { diff --git a/Seohui/keyword_summary/ch07.md b/Seohui/keyword_summary/ch07.md new file mode 100644 index 00000000..3f576205 --- /dev/null +++ b/Seohui/keyword_summary/ch07.md @@ -0,0 +1,246 @@ +# Page와 Slice + +> **목적 / 용도** +> - **Page:** 게시판처럼 정확한 페이지 번호가 필요한 경우 +> - **Slice:** 무한 스크롤이나 더 보기 버튼이 필요한 경우 + +| 구분 | Page | Slice | +| --- | --- | --- | +| **특징** | 전체 데이터 건수, 전체 페이지 수를 알 수 있음 | 전체 건수는 모르고 다음 페이지가 있는지의 여부만 알 수 있음 | +| **동작 방식** | 데이터 조회 쿼리 + 전체 개수를 세는 `COUNT` 쿼리가 같이 실행됨 | • `COUNT` 쿼리 실행 안 함
• 요청한 개수보다 **1개 더 (Limit + 1)** 가져와서 다음 페이지 여부 확인 | +| **성능** | 데이터가 많아질수록 `COUNT` 쿼리 때문에 성능이 저하될 수 있음 | `COUNT` 쿼리가 없어서 대용량 데이터 조회 시 성능이 훨씬 빠름 | +| **상속 구조** | `Slice` 인터페이스를 상속받음 (Slice의 모든 기능 + COUNT 기능) | 부모 인터페이스 | +| **UI** | [1] [2] [3] [4] [5][다음] | 스크롤을 맨 아래로 내리면 자동 로딩 | + +**e.g.** +```java +@Repository +public interface ReviewRepository extends JpaRepository { + + // Page 타입 반환 + Page findPageBy(Pageable pageable); + + // Slice 타입 반환 + Slice findSliceBy(Pageable pageable); +} + +@RestController +// ... + /** + Page 방식 + /reviews/page?page=0&size=5 + **/ + @GetMapping("/reviews/page") + public Page getReviewsByPage( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size + ) { + // id 기준 최신순 정렬 Pageable 객체 생성 + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + + // 반환 시 전체 개수 정보가 응답됨 + return reviewRepository.findPageBy(pageable); + } + + /** + Slice 방식 + /reviews/slice?page=0&size=5 + **/ + @GetMapping("/reviews/slice") + public Slice getReviewsBySlice( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + + // 전체 개수는 안 나옴 + // 데이터 내용, 다음 페이지 여부만 응답됨 + return reviewRepository.findSliceBy(pageable); + } +``` + +# Java Stream API + +> **Stream API** +> 자바 8에 추가된 기능으로 컬렉션(List, Set, Map)이나 배열에 저장된 데이터를 반복문 없이 깔끔하게 처리하는 기술. +> 데이터의 흐름을 만듦 → 람다식과 함께 데이터를 필터링, 변환, 수집 작업을 연결해서 수행. + +### Stream API 동작 흐름 +1. **생성(Creation)** → 컬렉션이나 배열을 스트림 객체로 만듦 (`.stream()`) +2. **중간 연산(Intermediate)** → 데이터를 가공함 (여러 번 연결 가능) +3. **최종 연산(Terminal)** → 가공된 데이터를 결과로 만듦 (한 번만 사용 가능하며, 이때 실제 연산이 시작됨) + +--- + +### 중간 연산 + +* **특징:** + * 데이터를 가공하는 단계 + * 연산 결과로 또 다른 `Stream`을 반환함 → 메서드 체이닝 가능 + +| 메서드 | 설명 | 예시 / 비고 | +| --- | --- | --- | +| `filter(조건)` | 조건에 맞는 데이터만 걸러냄 | 짝수만 추출 | +| `map(변환 규칙)` | 데이터를 다른 형태로 변환 | User 객체에서 이름만 추출 | +| `sorted()` | 데이터 정렬 | 기본 오름차순, 사용자 지정 정렬 | +| `distinct()` | 중복된 데이터 제거 | | +| `limit(n)` | 데이터의 흐름에서 앞에서부터 n개의 데이터만 잘라냄 | | + +--- + +### 최종 연산 + +* **특징:** + * 가공된 데이터에서 최종 결과를 도출하는 마지막 단계 + * 실제 값이나 컬렉션을 반환하며, 최종 연산이 호출되어야 비로소 전체 스트림 연산이 실행됨 (지연 연산) + * 한 번 실행되면 스트림이 닫힘 + +| 메서드 | 설명 | 예시 / 비고 | +| --- | --- | --- | +| `collect()` | 가공된 스트림 데이터를 List, Set, Map 등으로 묶어서 반환 | | +| `forEach()` | 스트림의 각 요소를 순회하며 출력하거나 작업을 수행함 | 반환값 없음 | +| `count()` | 스트림에 남은 데이터의 **총 개수** 반환 | | +| `reduce()` | 데이터를 하나씩 누적 계산 | 모든 숫자 합 계산 | +| `anyMatch(조건)` | 조건을 만족하는 데이터가 **하나라도 존재하는지** 확인 | boolean 반환 | + +--- + +### 주의 사항 +* **스트림은 일회용임** → 한 번 최종 연산(`collect`, `count` 등)을 수행해서 결과를 얻으면 스트림은 닫혀서 다시 쓸 수 없다! 필요 시 재생성해야 함. +* **지연 연산(Lazy Evaluation)을 함** → 중간 연산(`filter`, `map`)을 많이 적어두어도 맨 끝에 최종 연산(`collect` 등)을 호출하지 않으면 계산이 아예 실행되지 않음. + +# 객체 그래프 탐색 + +> **객체 그래프 탐색** +> JPA나 ORM을 사용할 때 자주 등장하는 개념으로, 객체들 사이의 **연관 관계를 연쇄적으로 따라가며 조회하는 것** → 객체 안에 또 다른 객체가 있을 때 점(`.`)을 통해 타고 들어가면 된다. +> 실제 DB 조회 시점과 관련이 있기 때문에 중요함. +> 참조 변수를 통해 연관된 객체로 이동하는 행위를 말함. + +**e.g.** +`comment.getUser().getName()` +* `comment`: 댓글 객체 +* `comment.getUser()`: 댓글을 작성한 사용자 (User) +* `comment.getUser().getName()`: 사용자의 이름 (Name) + +--- + +### N+1 문제 +객체 그래프 탐색은 필요할 때 가져온다는 지연 로딩(Lazy Loading) 특성을 가지기에 **N+1 문제**가 발생될 우려가 있음 → `fetch join`으로 해결 (한 번에 가져와서 객체 그래프 탐색 시 추가 쿼리 X) + +* **현상:** 최초에 목록을 조회하는 쿼리를 한 번 날림 +* **문제:** 그 목록에 담긴 객체들을 하나씩 탐색할 때마다 연관된 데이터를 가져오기 위해 추가적인 쿼리가 N번 더 발생 +* **원인:** JPA 입장에서는 어디까지 탐색할지 미리 알 수 없음 → 처음에는 딱 요청한 것만 가져오고(프록시) 나중에 탐색을 시도할 때마다 쿼리를 날리기 때문에 발생하는 문제 + +# @Valid vs @Validated + +### 차이점 + +| 구분 | @Valid (자바 표준) | @Validated (스프링용) | +| --- | --- | --- | +| **소속** | Java/Jakarta EE 표준 규격(JSR-380) | Spring Framework | +| **동작 위치** | 주로 Controller의 파라미터 변환 시 동작 | Controller, Service, Repository 등 스프링 빈 어디서나 동작 | +| **동작 원리** | 스프링의 ArgumentResolver가 개입하여 검증 | 스프링의 AOP를 기반으로 메서드 호출을 가로채서 검증 | +| **그룹 지정** | 불가능 (무조건 전체 검증) | 가능 (상황에 따라 원하는 조건만 묶어서 검증) | +| **적용 대상** | 메서드 파라미터, 필드(객체 내부의 객체 검증 시) | 클래스 레벨, 메서드 파라미터 | +| **발생 예외 (Exception)** | `MethodArgumentNotValidException` | AOP 동작 시 `ConstraintViolationException` 발생 | + +### 공통점 +객체에 설정된 제약 조건(`@NotNull`, `@Size` 등)을 확인하여 옳지 않은 데이터가 서버 내부로 들어오는 것을 막아줌 + +--- + +## @Validated + +> **@Validated** +> Spring 프레임워크에서 전용으로 제공하는 유효성 검증 어노테이션. +> 자바 표준인 `@Valid`의 모든 기능을 포함하면서, 추가적으로 **그룹화(Grouping)** 기능을 제공하여 특정 상황에 맞는 검증만 실행하라고 알려주는 신호. +> `@Valid`가 주로 웹(Controller) 계층에서만 동작한다면, `@Validated`는 스프링 빈으로 등록된 클래스라면 어디서든 유효성 검증이 가능해짐. + +* **주 기능 : 그룹 유효성 검사 (Validation Groups)** + * ex) 회원 가입할 때와 회원 정보를 수정할 때 요구하는 데이터의 조건이 다를 경우 → 가입할 때는 비밀번호가 필수지만, 프로필 수정 시에는 비밀번호를 바꾸지 않을 수 있음 + +### @Validated 동작 흐름 +1. 클라이언트 → Controller/Service 호출 +2. Spring AOP 동작 (메서드 가로채기) +3. 지정된 그룹 또는 파라미터 검증 + * **실패 :** `ConstraintViolationException` 또는 `MethodArgumentNotValidException` 발생 + * **성공 :** 대상 메서드의 비즈니스 로직 정상 수행 + +### 어노테이션 설명 (예시) +* `@NotBlank(groups = CreateGroup.class)` : 가입(Create)할 때만 필수 값으로 검증 +* `@NotNull(groups = UpdateGroup.class)` : 수정(Update)할 때만 null이 아닌지 검증 +* `@Validated(CreateGroup.class)` : 해당 그룹(Create)의 규칙만 검사하도록 지시함 + +### 클래스 레벨 검증 (Method Validation) +클래스 위에 붙여 해당 클래스의 모든 메서드 파라미터에 대한 유효성 검사를 활성화할 수 있음 + +### 자바 @Valid와 비교했을 때 주의할 점 +`@Validated`를 클래스 레벨에 적용하여 발생하는 예외(`ConstraintViolationException`)와 컨트롤러 DTO 검증 시 발생하는 예외(`MethodArgumentNotValidException`)가 다름 +→ 글로벌 예외 처리를 구현할 때 이 두 가지를 모두 처리해주어야 한다. + +--- + +## @Valid + +> **@Valid** +> Java Bean Validation의 기본 어노테이션. +> DTO, Entity, Method Parameter 등에서 검증을 시작하라고 Spring/Hibernate Validator에게 알려주는 신호. +> `@Valid`를 사용하면 객체 안에서 들어오는 값에 대해 검증이 가능해짐. + +### @Valid 동작 흐름 +1. 클라이언트 → Controller 호출 +2. Valid → Hibernate Validator 호출 +3. DTO 필드 검증 + * **실패 :** `MethodArgumentNotValidException` 발생 + * **성공 :** Controller 비즈니스 로직 수행 + +### 검증 어노테이션 종류 + +#### 문자열 검증 +| 어노테이션 | 설명 | +| --- | --- | +| `@NotBlank` | null X, 공백 제외 길이 > 0 | +| `@NotEmpty` | null X, 빈 문자열("") X | +| `@NotNull` | null X (타입 상관 없음) | +| `@Null` | 반드시 null 값 | + +#### 최대/최솟값 검증 +| 어노테이션 | 설명 | +| --- | --- | +| `@DecimalMax` | 지정값 이하 (String 기반) | +| `@DecimalMin` | 지정값 이상 (String 기반) | +| `@Max` | 지정값 이하 (숫자 기반) | +| `@Min` | 지정값 이상 (숫자 기반) | + +#### 범위 검증 +| 어노테이션 | 설명 | +| --- | --- | +| `@Positive` | 양수만 허용 | +| `@PositiveOrZero` | 0 이상 | +| `@Negative` | 음수만 허용 | +| `@NegativeOrZero` | 0 이하 | + +*참고: `DecimalMax/Min`은 BigDecimal, BigInteger, String 등 정밀 수치용 / `Max/Min`은 기본 숫자형(int, long 등) 용* + +#### 시간 값 검증 +| 어노테이션 | 설명 | +| --- | --- | +| `@Future` | 현재 시간보다 미래 | +| `@FutureOrPresent` | 현재 or 미래 | +| `@Past` | 현재 시간보다 과거 | +| `@PastOrPresent` | 현재 or 과거 | + +#### Boolean 검증 +| 어노테이션 | 설명 | +| --- | --- | +| `@AssertTrue` | true 값만 허용 | +| `@AssertFalse` | false 값만 허용 | + +#### 크기 검증 +| 어노테이션 | 설명 | +| --- | --- | +| `@Size` | 값의 길이가 min 이상 max 이하
→ `String`, `Collection`, `Map`, `Array` 에 적용 | + +### 자바 Valid 가 제공하지 않는 기능 +`@Valid`는 `javax.validation` 표준이지만, 그룹 유효성 검사를 지원하지 않음. +→ 필요한 경우 `@Validated` 사용 \ No newline at end of file diff --git a/Seohui/keyword_summary/ch08.md b/Seohui/keyword_summary/ch08.md new file mode 100644 index 00000000..c63e5e30 --- /dev/null +++ b/Seohui/keyword_summary/ch08.md @@ -0,0 +1,80 @@ +# Spring Security가 무엇인가? + +Java 서블릿 기반 애플리케이션에서 **인증(Authentication)**과 인가(Authorization), 보안 공격 방어 기능을 제공하는 프레임워크 +→ 서블릿 필터의 체인 구조를 통해 요청을 가로채고 보안 로직을 처리함 +요청이 DispatcherServlet(Controller)에 도달하기 전에 가로채서 보안 로직을 수행함 + +* **DelegatingFilterProxy** + * 서블릿 컨테이너와 스프링 컨테이너(IoC 컨테이너)를 연결하는 역할을 하는 표준 서블릿 필터 + * 서블릿 필터는 스프링 빈을 직접 알지 못하므로 이 프록시 필터가 요청을 받아 스프링 빈으로 등록된 보안 필터(FilterChainProxy)에 위임 +* **FilterChainProxy** + * Spring Security의 실질적인 진입점으로 DelegatingFilterProxy로부터 요청을 받아 현재 요청에 적용되어야 할 SecurityFilterChain을 찾아 실행시키는 역할을 함 +* **SecurityFilterChain** + * 보안 처리를 위한 필터 묶음 + * SecurityConfig에서 `.authorizeHttpRequests()`, `.formLogin()` 등을 설정하면 이에 맞는 필터들이 체인 형태로 구성 + +--- + +# 인증(Authentication) vs 인가(Authorization) + +## 인증(Authentication) +해당 사용자가 누구인지 확인하는 과정에서 필요한 주요 객체 + +* **Authentication** + * 인증 전/ 인증 후의 사용자 정보를 담는 객체 + * 구성 요소: + * Principal: 사용자 식별자 (로그인 전: ID / 로그인 후: UserDetails 객체) + * Credentials: 자격 증명 (로그인 전: 비밀번호 / 로그인 후: 보안을 위해 삭제됨) + * Authorities: 사용자가 가진 권한 목록 (ROLE_USER, ROLE_ADMIN 등) +* **SecurityContext** + * 현재 인증된 사용자의 Authentication 객체를 보관하는 객체 +* **SecurityContextHolder** + * SecurityContext 저장하는 저장소 + * 기본적으로 ThreadLocal 전략으로 같은 쓰레드 내에서는 어디서든 인증 정보에 접근할 수 있음 + * 요청이 끝나면 초기화되지만 JWT 환경에서는 매 요청마다 필터가 이곳에 다시 값을 채워둠 +* **AuthenticationManager** + * 인증을 처리하는 최상위 인터페이스로 해당 인증 요청을 처리하라는 `authenticate()` 메서드 하나를 가지고있음 + * 실질적인 구현체는 대부분 ProviderManager 사용 +* **ProviderManager** + * AuthenticationManager의 구현체.. 스스로 인증을 하지 않고 여러 개의 AuthenticationProvider 목록을 순회하며 인증 처리를 위임 +* **AuthenticationProvider** + * 인증 로직을 수행하는 위치 + * `authenticate()`: 실제 검증 로직 수행 + * `supports()`: 해당 Provider가 특정 Authentication 타입을 처리할 수 있는지 확인 +* **UserDetailsService** + * DB나 메모리 등에서 유저 정보를 가져오는 역할 + * username으로 DB를 조회 → UserDetails 객체 반환 +* **UserDetails** + * Spring Security가 내부적으로 사용하는 유저 정보 표준 인터페이스 + * User 엔티티를 이 인터페이스에 맞춰 감싸거나(CustomUserDetails)/ 엔티티가 직접 구현하도록 하여 Spring Security에 전달 + +## 인가 (Authorization) +해당 사용자가 이 리소스에 접근할 권한이 있는지 확인 + +* **GrantedAuthority** + * 사용자 권한 + * SimpleGrantedAuthority 구현체 사용, ROLE_ADMIN 와 같은 문자열 형태로 저장 + * Authentication 객체 안에 리스트 형태로 저장 +* **AuthorizationFilter** + * 필터 체인의 마지막부에 위치하고 URL 별로 필요한 권한이 있는지 검사 + * SecurityConfig에서 작성한 `requestMatchers("/admin/**").hasRole("ADMIN")` 정보를 바탕으로 판단 + +--- + +# Stateful vs Stateless + +* **Stateful (상태 유지)** + * 서버가 클라이언트와의 통신 상태나 이전 요청 데이터를 메모리나 디스크에 계속 저장하고 기억하는 네트워크 통신 방식 + * 클라이언트가 첫 번째 요청에서 인증을 완료하면 서버가 이를 기억하고 있으므로 두 번째 요청부터는 추가 인증 없이 작업을 처리할 수 있다 +* **Stateless (무상태)** + * 서버가 클라이언트의 상태를 전혀 저장하지 않는 네트워크 통신 방식임 + * 서버는 이전 요청이 무엇이었는지 전혀 알지 못하므로 클라이언트는 매 요청마다 서버가 작업을 처리하는 데 필요한 모든 데이터를 온전히 담아서 보내야 함 + +| 구분 | Stateful (상태 유지) | Stateless (무상태) | +| :--- | :--- | :--- | +| **공통점** | 클라이언트와 서버 간의 네트워크 데이터 통신을 위한 설계 방식 | 클라이언트와 서버 간의 네트워크 데이터 통신을 위한 설계 방식 | +| **상태 저장** | 서버가 클라이언트의 이전 상태와 컨텍스트를 저장함 | 서버는 클라이언트의 상태를 저장하지 않음 | +| **요청 데이터** | 서버가 정보를 기억함 → 클라이언트는 최소한의 데이터만 전송 | 서버가 기억하지 않으므로 요청할 때마다 모든 데이터를 전송해야 함 | +| **서버** | 접속자가 많아질수록 서버 메모리 사용량이 급격히 증가 | 상태 저장을 하지 않음 → 서버 메모리 부담이 적음 | +| **확장성** | 여러 서버로 트래픽을 분산하는 Scale-out이 어려움 | 서버 간 상태 공유가 필요 없어 서버 확장에 자유로움 | +| **장애 대응** | 연결된 서버가 다운되면 해당 서버에 저장된 상태 정보가 날아감 → 다시 재작업 | 특정 서버가 다운되어도 다른 서버가 동일하게 요청을 처리할 수 있어 장애 대응에 유연 | \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java index e56ec820..d2acaf42 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/controller/MissionController.java @@ -1,6 +1,7 @@ package com.study.UMC10.domain.mission.controller; import com.study.UMC10.domain.mission.code.MissionSuccessCode; +import com.study.UMC10.domain.mission.dto.request.MissionRequestDto; import com.study.UMC10.domain.mission.dto.response.MissionResponseDto; import com.study.UMC10.domain.mission.service.MissionService; import com.study.UMC10.global.apiPayload.ApiResponse; @@ -10,12 +11,7 @@ import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequiredArgsConstructor @@ -25,18 +21,21 @@ public class MissionController { private final MissionService missionService; - @Operation(summary = "미션 목록 조회 API", description = "상태값에 따른 미션 목록을 조회하는 API입니다.") + @Operation(summary = "미션 목록 조회 API", description = "나의 미션 목록을 조회합니다.") @Parameters({ @Parameter(name = "MissionStatus", description = "조회할 미션 상태", example = "IN_PROGRESS, SUCCESS, FAILED"), - @Parameter(name = "page", description = "페이지 번호", example = "0") + @Parameter(name = "pageSize", description = "가져올 데이터 수", example = "10"), + @Parameter(name = "pageNumber", description = "페이지 번호", example = "0") }) @GetMapping("/v1/missions") - public ApiResponse getMissions( + public ApiResponse> getMissions( + @RequestBody MissionRequestDto.GetMyMissionsDto requestDto, @RequestParam("MissionStatus") String status, - @RequestParam(name = "page", defaultValue = "0") Integer page + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + @RequestParam(name = "pageNumber", defaultValue = "0") Integer pageNumber ) { BaseSuccessCode code = MissionSuccessCode.OK; - return ApiResponse.onSuccess(code, missionService.getMissions(status, page)); + return ApiResponse.onSuccess(code, missionService.getMissions(requestDto.userId(), status, pageSize, pageNumber)); } @Operation(summary = "미션 완료 처리 API", description = "진행 중인 미션을 완료 상태로 변경하는 API입니다.") diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java index 8584fa2b..58fe9a35 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/converter/MissionConverter.java @@ -1,4 +1,19 @@ package com.study.UMC10.domain.mission.converter; +import com.study.UMC10.domain.mission.dto.response.MissionResponseDto; + +import java.util.List; + public class MissionConverter { + public static MissionResponseDto.Pagination toPagination( + List data, + Integer pageNumber, + Integer pageSize + ){ + return MissionResponseDto.Pagination.builder() + .data(data) + .pageNumber(pageNumber) + .pageSize(pageSize) + .build(); + } } diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java index fc6eeee9..01f321a5 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/request/MissionRequestDto.java @@ -1,4 +1,17 @@ package com.study.UMC10.domain.mission.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; + public class MissionRequestDto { -} + + /* + * NOTE: + * GET 요청에 Request Body를 사용하지 않지만 미션 요구사항에 따라 Request Body로 userId를 전달받음 + */ + @Schema(description = "진행 중인 미션 조회 요청") + public record GetMyMissionsDto( + @Schema(description = "조회할 유저 ID", example = "1") + Long userId + ) { + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java index dae8104f..77dce4dc 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/dto/response/MissionResponseDto.java @@ -44,4 +44,11 @@ public record MissionCompleteResultDto( String status ) { } + + @Builder + public record Pagination( + List data, + Integer pageNumber, + Integer pageSize + ) {} } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java b/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java index 09accced..ac89d900 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/mission/service/MissionService.java @@ -1,5 +1,6 @@ package com.study.UMC10.domain.mission.service; +import com.study.UMC10.domain.mission.converter.MissionConverter; import com.study.UMC10.domain.mission.dto.response.MissionResponseDto; import com.study.UMC10.domain.mission.entity.UserMission; import com.study.UMC10.domain.mission.enums.MissionStatus; @@ -20,16 +21,14 @@ public class MissionService { private final UserMissionRepository userMissionRepository; @Transactional(readOnly = true) - public MissionResponseDto.MissionListDto getMissions(String status, Integer page) { - - // 임시 유저 - Long dummyUserId = 1L; + public MissionResponseDto.Pagination getMissions( + Long userId, String status, Integer pageSize, Integer pageNumber) { MissionStatus missionStatus = MissionStatus.valueOf(status.toUpperCase()); - // 페이징 - PageRequest pageRequest = PageRequest.of(page, 10); - Page userMissionPage = userMissionRepository.findMyMissions(dummyUserId, missionStatus, pageRequest); + PageRequest pageRequest = PageRequest.of(pageNumber, pageSize); + + Page userMissionPage = userMissionRepository.findMyMissions(userId, missionStatus, pageRequest); List missionDetailDtoList = userMissionPage.stream() .map(userMission -> MissionResponseDto.MissionDetailDto.builder() @@ -41,12 +40,17 @@ public MissionResponseDto.MissionListDto getMissions(String status, Integer page .build()) .collect(Collectors.toList()); - return MissionResponseDto.MissionListDto.builder() - .missions(missionDetailDtoList) - .build(); + return MissionConverter.toPagination( + missionDetailDtoList, + userMissionPage.getNumber(), + userMissionPage.getSize() + ); } - // 아직 구현Xx + /* + * NOTE: + * 아직 구현 안함 + */ public MissionResponseDto.MissionCompleteResultDto completeMission(Long missionId) { return null; } diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java index ea33545e..f5f60866 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/controller/ReviewController.java @@ -6,14 +6,20 @@ import com.study.UMC10.domain.review.service.ReviewService; import com.study.UMC10.global.apiPayload.ApiResponse; import com.study.UMC10.global.apiPayload.code.BaseSuccessCode; +import com.study.UMC10.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -29,10 +35,29 @@ public class ReviewController { @PostMapping("/v1/stores/{storeId}/reviews") public ApiResponse createReview( @PathVariable("storeId") Long storeId, - @RequestBody ReviewRequestDto.CreateReviewDto requestDto + @RequestBody @Valid ReviewRequestDto.CreateReviewDto requestDto ) { BaseSuccessCode code = ReviewSuccessCode.REVIEW_CREATED; return ApiResponse.onSuccess(code, reviewService.createReview(storeId, requestDto)); } + + @Operation(summary = "내가 작성한 리뷰 목록 조회 API", description = "커서 기반 페이징으로 내가 작성한 리뷰를 최신순/별점순으로 조회합니다.") + @Parameters({ + @Parameter(name = "userId", description = "조회할 유저 ID", example = "1"), + @Parameter(name = "query", description = "정렬 기준(최신순: id, 별점순: rate)", example = "id"), + @Parameter(name = "cursor", description = "다음 페이지 커서/ 최초 요청 시 -1 입력", example = "-1"), + @Parameter(name = "pageSize", description = "가져올 데이터 수", example = "10") + }) + @GetMapping("/v1/reviews/me") + public ApiResponse> getMyReviews( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "query", defaultValue = "id") String query, + @RequestParam(name = "cursor", defaultValue = "-1") String cursor, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize + ) { + Long userId = customUserDetails.getUser().getId(); + BaseSuccessCode code = com.study.UMC10.global.apiPayload.code.GeneralSuccessCode.OK; + return ApiResponse.onSuccess(code, reviewService.getMyReviews(userId, query, cursor, pageSize)); + } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java index 9ff77d70..b7159e16 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/request/ReviewRequestDto.java @@ -1,15 +1,24 @@ package com.study.UMC10.domain.review.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public class ReviewRequestDto { @Schema(description = "리뷰 생성") public record CreateReviewDto( + + @NotNull(message = "별점은 필수 입력 항목입니다.") + @DecimalMin(value = "0.0", message = "별점은 0.0 이상이어야 합니다.") + @DecimalMax(value = "5.0", message = "별점은 5.0 이하이어야 합니다.") @Schema(description = "가게 별점", example = "4.5") Double rate, - @Schema(description = "리뷰 내용", example = "음식이 맛있습니다!") + @NotBlank(message = "리뷰 내용은 비어있을 수 없습니다.") + @Schema(description = "리뷰 내용", example = "음식이 맛있어요!") String content ) { } diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java index 1e32ccfc..f2d064c5 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/dto/response/ReviewResponseDto.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; +import java.time.LocalDate; import java.util.List; public class ReviewResponseDto { @@ -26,4 +27,45 @@ public record CreateReviewResultDto( List images ) { } + + @Builder + @Schema(description = "내가 작성한 리뷰 상세 정보") + public record MyReviewDto( + @Schema(description = "리뷰 ID", example = "1") + Long reviewId, + + @Schema(description = "가게 이름", example = "반이학생마라탕마라반") + String storeName, + + @Schema(description = "유저 닉네임", example = "닉네임1234") + String nickname, + + @Schema(description = "별점", example = "4.5") + Double score, + + @Schema(description = "작성 날짜", example = "2022.05.14") + LocalDate createdAt, + + @Schema(description = "사장님 답글 내용 (없으면 null)", example = "감사합니다.") + String ownerComment, + + @Schema(description = "사장님 답글 작성 날짜 (없으면 null)", example = "2022.05.15") + LocalDate ownerCommentCreatedAt + ) {} + + @Builder + @Schema(description = "커서 기반 페이징") + public record CursorPagination( + @Schema(description = "데이터 리스트") + List data, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + Boolean hasNext, + + @Schema(description = "다음 페이지 조회를 위한 커서 값", example = "383:383") + String nextCursor, + + @Schema(description = "요청한 데이터 수", example = "10") + Integer pageSize + ) {} } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java b/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java index ad412495..1b0e9ef5 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/entity/Review.java @@ -3,17 +3,7 @@ import com.study.UMC10.domain.store.entity.Store; import com.study.UMC10.domain.user.entity.User; import com.study.UMC10.global.apiPayload.code.BaseEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -51,4 +41,7 @@ public class Review extends BaseEntity { @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) private List reviewPhotoList = new ArrayList<>(); + + @OneToOne(mappedBy = "review", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private OwnerComment ownerComment; } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java index 21d2d2f2..7e4065ea 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/repository/ReviewRepository.java @@ -1,7 +1,30 @@ package com.study.UMC10.domain.review.repository; import com.study.UMC10.domain.review.entity.Review; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ReviewRepository extends JpaRepository { + + // 리뷰 ID 기준 내림차순 + // 커서 없는 최초 조회 + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId ORDER BY r.id DESC") + Slice findMyReviewsOrderByIdDesc(@Param("userId") Long userId, Pageable pageable); + + // 커서 조회 + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId AND r.id < :cursorId ORDER BY r.id DESC") + Slice findMyReviewsByCursorId(@Param("userId") Long userId, @Param("cursorId") Long cursorId, Pageable pageable); + + // 별점 순 + // 최초 조회 + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId ORDER BY r.score DESC, r.id DESC") + Slice findMyReviewsOrderByScoreDesc(@Param("userId") Long userId, Pageable pageable); + + // 커서 조회 + @Query("SELECT r FROM Review r JOIN FETCH r.user JOIN FETCH r.store LEFT JOIN FETCH r.ownerComment WHERE r.user.id = :userId AND (r.score < :cursorScore OR (r.score = :cursorScore AND r.id < :cursorId)) ORDER BY r.score DESC, r.id DESC") + Slice findMyReviewsByCursorScoreAndId(@Param("userId") Long userId, @Param("cursorScore") Double cursorScore, @Param("cursorId") Long cursorId, Pageable pageable); } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java b/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java index 8b2859ab..625fc69d 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/review/service/ReviewService.java @@ -9,10 +9,14 @@ import com.study.UMC10.domain.user.entity.User; import com.study.UMC10.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -25,7 +29,11 @@ public class ReviewService { @Transactional public ReviewResponseDto.CreateReviewResultDto createReview(Long storeId, ReviewRequestDto.CreateReviewDto requestDto) { - // 임시 유저 (로그인 구현 X) + /* + * NOTE: + * 임시 유저로 하드 코딩 + * 추후 로그인 구현 후 수정 예정 + */ Long dummyUserId = 1L; User user = userRepository.findById(dummyUserId) .orElseThrow(() -> new RuntimeException("해당 유저를 찾을 수 없습니다.")); @@ -51,4 +59,65 @@ public ReviewResponseDto.CreateReviewResultDto createReview(Long storeId, Review .images(new ArrayList<>()) .build(); } + + // 내 리뷰 목록 조회 + @Transactional(readOnly = true) + public ReviewResponseDto.CursorPagination getMyReviews( + Long userId, String query, String cursor, Integer pageSize) { + + PageRequest pageRequest = PageRequest.of(0, pageSize); + Slice reviewSlice; + + if (query.equalsIgnoreCase("rate")) { + // 별점 순 + if (cursor.equals("-1")) { + reviewSlice = reviewRepository.findMyReviewsOrderByScoreDesc(userId, pageRequest); + } else { + String[] cursorSplit = cursor.split(":"); + Double scoreCursor = Double.parseDouble(cursorSplit[0]); + Long idCursor = Long.parseLong(cursorSplit[1]); + reviewSlice = reviewRepository.findMyReviewsByCursorScoreAndId(userId, scoreCursor, idCursor, pageRequest); + } + } else { + // 리뷰 ID 순 + if (cursor.equals("-1")) { + reviewSlice = reviewRepository.findMyReviewsOrderByIdDesc(userId, pageRequest); + } else { + Long idCursor = Long.parseLong(cursor); + reviewSlice = reviewRepository.findMyReviewsByCursorId(userId, idCursor, pageRequest); + } + } + + List dtoList = reviewSlice.getContent().stream() + .map(review -> ReviewResponseDto.MyReviewDto.builder() + .reviewId(review.getId()) + .storeName(review.getStore().getStoreName()) + .nickname(review.getUser().getNickname()) + .score(review.getScore()) + .createdAt(review.getCreatedAt() != null ? review.getCreatedAt().toLocalDate() : null) + .ownerComment(review.getOwnerComment() != null ? review.getOwnerComment().getOwnerCommentContent() : null) + .ownerCommentCreatedAt(review.getOwnerComment() != null && review.getOwnerComment().getCreatedAt() != null ? review.getOwnerComment().getCreatedAt().toLocalDate() : null) + .build()) + .collect(Collectors.toList()); + + // 다음 커서 계산 + String nextCursor = "-1"; + if (reviewSlice.hasNext() && !reviewSlice.getContent().isEmpty()) { + Review lastReview = reviewSlice.getContent().get(reviewSlice.getContent().size() - 1); + if (query.equalsIgnoreCase("rate")) { + // 별점 순 커서: (별점:리뷰ID) .. 별점 같을 시 리뷰 ID순 + nextCursor = lastReview.getScore() + ":" + lastReview.getId(); + } else { + // 최신순 커서: 리뷰ID + nextCursor = String.valueOf(lastReview.getId()); + } + } + + return ReviewResponseDto.CursorPagination.builder() + .data(dtoList) + .hasNext(reviewSlice.hasNext()) + .nextCursor(nextCursor) + .pageSize(reviewSlice.getSize()) + .build(); + } } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java index f5a24083..2157e3c7 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/code/UserErrorCode.java @@ -9,13 +9,12 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseErrorCode { - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, - "MEMBER404_1", - "해당 사용자를 찾을 수 없습니다.") + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾을 수 없습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER400_1", "이미 사용 중인 이메일입니다."), + INVALID_GENDER(HttpStatus.BAD_REQUEST, "USER400_2", "올바르지 않은 성별 형식입니다.") ; private final HttpStatus status; private final String code; private final String message; - -} +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java b/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java index e87eefe9..022ca303 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/entity/User.java @@ -46,6 +46,9 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false, length = 40) private String email; + @Column(name = "password", nullable = false) + private String password; + @Column(name = "nickname", length = 20) private String nickname; diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java index 8980ff3e..a2e5f834 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/repository/UserRepository.java @@ -3,5 +3,9 @@ import com.study.UMC10.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); } \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java index 7a1c0496..d9aa3f93 100644 --- a/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java +++ b/Seohui/src/main/java/com/study/UMC10/domain/user/service/UserService.java @@ -4,7 +4,6 @@ import com.study.UMC10.domain.mission.repository.MissionRepository; import com.study.UMC10.domain.user.code.UserErrorCode; import com.study.UMC10.domain.user.code.UserException; -import com.study.UMC10.domain.user.converter.UserConverter; import com.study.UMC10.domain.user.dto.request.UserRequestDto; import com.study.UMC10.domain.user.dto.response.UserResponseDto; import com.study.UMC10.domain.user.entity.User; @@ -12,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,12 +24,12 @@ public class UserService { private final UserRepository userRepository; private final MissionRepository missionRepository; + private final PasswordEncoder passwordEncoder; // 마이페이지 @Transactional(readOnly = true) public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) { Long userId = dto.id(); - User user = userRepository.findById(userId) .orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND)); @@ -43,8 +43,48 @@ public UserResponseDto.GetInfo getInfo(UserRequestDto.GetInfo dto) { .build(); } + // 회원가입 + @Transactional public UserResponseDto.SignUpResultDto signUp(UserRequestDto.SignUpDto requestDto) { - return null; + + // 이메일 중복 체크 + if (userRepository.existsByEmail(requestDto.email())) { + throw new UserException(UserErrorCode.EMAIL_ALREADY_EXISTS); + } + + String encodedPassword = passwordEncoder.encode(requestDto.password()); + + com.study.UMC10.domain.user.enums.Gender userGender; + try { + userGender = com.study.UMC10.domain.user.enums.Gender.valueOf(requestDto.gender().toUpperCase()); + } catch (Exception e) { + throw new UserException(UserErrorCode.INVALID_GENDER); + } + + java.time.LocalDate userBirth = null; + if (requestDto.birth() != null && !requestDto.birth().isBlank()) { + userBirth = java.time.LocalDate.parse(requestDto.birth()); + } + + User newUser = User.builder() + .email(requestDto.email()) + .password(encodedPassword) + .name(requestDto.name()) + .nickname(requestDto.nickname()) + .address(requestDto.address()) + .gender(userGender) + .birth(userBirth) + .totalPoint(0) + .finMission(0) + .status(com.study.UMC10.domain.user.enums.UserStatus.ACTIVE) + .build(); + + User savedUser = userRepository.save(newUser); + + return UserResponseDto.SignUpResultDto.builder() + .userId(savedUser.getId()) + .name(savedUser.getName()) + .build(); } // 홈 화면 (지역별 미션 조회 + 페이징) @@ -56,7 +96,6 @@ public UserResponseDto.HomeResultDto getHome(String region, Integer page) { .orElseThrow(() -> new UserException(UserErrorCode.MEMBER_NOT_FOUND)); PageRequest pageRequest = PageRequest.of(page, 10); - Page missionPage = missionRepository.findMissionsByRegion(region, pageRequest); List missionDtoList = missionPage.stream() diff --git a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java index 66e7a667..a3b078db 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java +++ b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/code/BaseEntity.java @@ -2,6 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; import lombok.Getter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; @@ -9,6 +10,7 @@ import java.time.LocalDateTime; +@MappedSuperclass @EntityListeners(AuditingEntityListener.class) @Getter public abstract class BaseEntity { diff --git a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java index dc0fe1b3..7dff42f9 100644 --- a/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/Seohui/src/main/java/com/study/UMC10/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -5,9 +5,13 @@ import com.study.UMC10.global.apiPayload.code.GeneralErrorCode; import com.study.UMC10.global.apiPayload.exception.GeneralException; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.HashMap; +import java.util.Map; + @RestControllerAdvice public class GeneralExceptionAdvice { @@ -21,6 +25,22 @@ public ResponseEntity> handleMemberException( .body(ApiResponse.onFailure(errorCode, null)); } + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleMethodArgumentNotValidException( + MethodArgumentNotValidException e + ) { + Map errors = new HashMap<>(); + + e.getBindingResult().getFieldErrors().forEach(error -> { + errors.put(error.getField(), error.getDefaultMessage()); + }); + + BaseErrorCode code = GeneralErrorCode.BAD_REQUEST; + + return ResponseEntity.status(code.getStatus()) + .body(ApiResponse.onFailure(code, errors)); + } + // 그 외의 정의되지 않은 모든 예외 처리 @ExceptionHandler(Exception.class) public ResponseEntity> handleException( diff --git a/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java new file mode 100644 index 00000000..a2a90ab6 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/config/SecurityConfig.java @@ -0,0 +1,68 @@ +package com.study.UMC10.global.config; + +import com.study.UMC10.global.security.CustomAccessDeniedHandler; +import com.study.UMC10.global.security.CustomAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + // 인증x 접근 가능한 Public API + private final String[] allowUris = { + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/api/auth/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() // Public API는 허용 + .anyRequest().authenticated() // 그 외의 Private API는 인증 필요 + ) + + // 폼 로그인 설정 + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) + .permitAll() + ) + + // 로그아웃 설정 + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + + // 예외 핸들러 + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDeniedHandler) + .authenticationEntryPoint(customAuthenticationEntryPoint) + ); + + return http.build(); + } + + // BCrypt 인코더 빈 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..b7e74feb --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAccessDeniedHandler.java @@ -0,0 +1,30 @@ +package com.study.UMC10.global.security; +// 로그인 후 권한 없을 경우 + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.study.UMC10.global.apiPayload.ApiResponse; +import com.study.UMC10.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + GeneralErrorCode code = GeneralErrorCode.FORBIDDEN; + + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..dcf05571 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,30 @@ +package com.study.UMC10.global.security; +//인증되지 않은 사용자가 Private API 접근 시 + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.study.UMC10.global.apiPayload.ApiResponse; +import com.study.UMC10.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + GeneralErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java new file mode 100644 index 00000000..a7b00361 --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetails.java @@ -0,0 +1,53 @@ +package com.study.UMC10.global.security; + +import com.study.UMC10.domain.user.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java new file mode 100644 index 00000000..9957bd8a --- /dev/null +++ b/Seohui/src/main/java/com/study/UMC10/global/security/CustomUserDetailsService.java @@ -0,0 +1,24 @@ +package com.study.UMC10.global.security; + +import com.study.UMC10.domain.user.entity.User; +import com.study.UMC10.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("해당 이메일의 사용자를 찾을 수 없습니다: " + username)); + + return new CustomUserDetails(user); + } +} \ No newline at end of file