diff --git a/Hyeonu/build.gradle b/Hyeonu/build.gradle index 4c422380..2b6a1a72 100644 --- a/Hyeonu/build.gradle +++ b/Hyeonu/build.gradle @@ -33,6 +33,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/Hyeonu/keyword_summary/ch07.md b/Hyeonu/keyword_summary/ch07.md new file mode 100644 index 00000000..906d4da9 --- /dev/null +++ b/Hyeonu/keyword_summary/ch07.md @@ -0,0 +1,152 @@ +- Page와 Slice + + **Page 란?** + + Spring Data JPA에서 제공하는 페이지네이션 객체 중 하나로, 전체 페이지 정보까지 포함하는 페이징 방식이다. + + 예를 들어 게시글이 총 100개 있고, 한 페이지에 10개씩 보여준다면 + + - 현재 페이지 번호 + - 전체 데이터 개수 + - 전체 페이지 수 + - 다음 페이지 여부 + + 이런 정보들을 모두 알 수 있다. + + ```java + Page missions=missionRepository.findByStoreId(storeId,pageable); + ``` + + **장점** + + - 프론트엔드에서 페이지 UI 만들기 편함 ("총 15페이지 중 현재 3페이지”) + - 전체 데이터 규모 파악 가능 + + **단점** + + - COUNT 쿼리 비용 발생 (데이터가 많아질수록 성능 부담 발생 가능) + - Offset 기반의 한계 (뒤 페이지로 갈수록 느려질 수 있다. + + **Slice 란?** + + Page보다 가벼운 페이지네이션 방식이다. + + 전체 개수는 계산하지 않음 + + ```java + Slice missions = missionRepository.findByStoreId(storeId, pageable); + ``` + + Spring 공식 문서에서도 `Slice`는 COUNT 쿼리 없이 **다음 페이지 존재 여부만 확인**한다고 설명한다 + **장점** + + - 성능이 더 좋음 (COUNT 쿼리가 없음) + - 무한스크롤 구현에 적합 + + **단점** + + - 전체 페이지 수를 알 수 없다. + + | 항목 | Page | Slice | + | --- | --- | --- | + | 전체 개수 | O | X | + | Count 쿼리 | O | X | + | 성능 | 상대적으로 느림 | 상대적으로 빠름 | + | 사용처 | 게시판, 관리자 페이지 | 무한스크롤, 커서 페이징 | +- Java stream API + + Java 8부터 추가된 기능으로, 컬렉션 데이터를 함수형 방식으로 처리할 수 있게 해주는 API이다. + + 예: + + ```java + List dtoList = + missions.stream() + .map(MissionConverter::toDTO) + .toList(); + ``` + + **주요 메서드** + + map() + + 데이터 변환 + + `mission -> dto` + + filter() + + 조건 필터림 + + `point>100` + + forEach() + + 반복 처리 + + collect()/toList() + + 결과 수집 + + **장점** + + - 코드가 간결해짐 + - 가독성이 좋음 (데이터 흐름이 잘 보임) + + **단점** + + - 익숙하지 않으면 오히려 어려움 (람다식) + - 디버깅 불편 (중간 상태 확인 어려움) +- 객체 그래프 탐색 + + JPA에서 객체 간 연관관계를 통해 다른 엔티티에 접근하는 방식 + + 예: + + ```java + mission.getStore().getName(); + ``` + + **장점** + + - SQL Join을 직접 안 써도 됨 + - 코드가 직관적 + + **단점** + + - Lazy Loading 문제 (필요할 때 추가 쿼리가 발생할 수 있음 (N+1문제) +- @Valid vs @Validated + + **@Valid** + + Java Bean Validation 표준 어노테이션 + + 주로 `Request Body` 검증 + + ```java + @PostMapping + public void create( + @RequestBody @Valid MissionRequest request + ) + ``` + + 검증 실패 시 `MethodArgumentNotValidException` 발생 + + **@Validated** + + Spring에서 제공하는 확장 검증 기능 + + 예: + + ```java + @Validated + @RestController + ``` + + **차이점** + + | 항목 | @Valid | @Validated | + | --- | --- | --- | + | 제공 | Java 표준 | Spring | + | 그룹 검증 | X | O | + | 메서드 파라미터 검증 | 제한적 | O | \ No newline at end of file diff --git a/Hyeonu/keyword_summary/ch08.md b/Hyeonu/keyword_summary/ch08.md new file mode 100644 index 00000000..7c18d5eb --- /dev/null +++ b/Hyeonu/keyword_summary/ch08.md @@ -0,0 +1,115 @@ +- Spring Security가 무엇인가? + + 자바 기반 웹 애플리케이션의 보안을 담당하는 프레임워크이다. + + 개발자가 보안 로직을 직접 구현하지않고 몇가지 설정만으로 아래 3가지를 처리할 수 있음 + + - 인증(Authentication)- 이 사용자가 누구인지 식별(로그인) + - 인가(Authorization)- 이 사용자가 해당 리소스에 접근할 권한이 있는지 식별(권한 확인) + - 보안 위협 방어- CSRF, XSS 등 각종 공격으로부터 보호 + + Filter Chain을 통해 HTTP요청을 순차적으로 필터링하며, SecurityConfig를 통해 커스텀 보안 설정을 적용할 수 있다. + + + +- 인증(Authentication)vs 인가(Authorization) + + + + **인증(Authentication)** + + 목적 + + 사용자 신원 확인 + + 실패 시 + + 401 Unauthorized + + 예시 + + 로그인, 회원가입 + + Spring Security + + `AuthenticationEntryPoint` + + + + **인가(Authorization)** + + 목적 + + 리소스 접근 권한 확인 + + 실패 시 + + 403 Forbidden + + 예시 + + 관리자 페이지 접근 + + Spring Security + + `AccessDeniedHandler` + + 인증 먼저 그 다음 인가 + +- Stateful vs Stateless + + + + **Stateful** + + 상태 저장 + + 서버가 저장 + + 방식 + + 세션 + + 동작 + + 로그인 시 서버에 세션 저장 → 요청마다 세션 확인 + + 장점 + + 구현 간단 + 즉시 로그아웃 가능 + + 단점 + + 서버 부하 + 확장성 낮음 + + 예시 + + 폼 로그인 + + + + **Stateless** + + 상태 저장 + + 서버가 저장 안함 + + 방식 + + JWT 토큰 + + 동작 + + 토큰에 사용자 정보 담아서 → 요청마다 토큰 검증 + + 장점 + + 토큰 탈취 시 만료 전까지 막기 어려움 + + 예시 + + JWT + + 이번 주차는 Stateful 방식 \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/Umc10thApplication.java b/Hyeonu/src/main/java/com/example/umc10th/Umc10thApplication.java index 9983f43a..6e64fac9 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/Umc10thApplication.java +++ b/Hyeonu/src/main/java/com/example/umc10th/Umc10thApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing // BaseEntity 동작 @SpringBootApplication public class Umc10thApplication { diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index 27f8def2..1a4135b9 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -4,9 +4,9 @@ import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.service.MemberService; import com.example.umc10th.global.apiPayload.ApiResponse; -import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; -import com.example.umc10th.global.apiPayload.code.MemberSuccessCode; +import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -20,13 +20,23 @@ public class MemberController { private final MemberService memberService; // 마이페이지 - @PostMapping("/v1/users/me") - public ApiResponse getInfo( + @PostMapping("/v1/members/me") + public ResponseEntity> getInfo( // 받은 JSON 데이터를 자바 객체(dto)로 변환해서 씀 @RequestBody MemberReqDTO.GetInfo dto ){ - BaseSuccessCode code= MemberSuccessCode.OK; - return ApiResponse.onSuccess(code,memberService.getInfo(dto)); + return ResponseEntity + .status(MemberSuccessCode.OK.getStatus()) + .body(ApiResponse.onSuccess(MemberSuccessCode.OK,memberService.getInfo(dto))); } + // 회원가입 + @PostMapping("/auth/sign-up") + public ResponseEntity> signUp( + @RequestBody MemberReqDTO.SignUpReqDTO dto + ){ + return ResponseEntity + .status(MemberSuccessCode.SIGN_UP.getStatus()) + .body(ApiResponse.onSuccess(MemberSuccessCode.SIGN_UP,memberService.signUp(dto))); + } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index 96452757..25ab43da 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -1,5 +1,6 @@ package com.example.umc10th.domain.member.converter; +import com.example.umc10th.domain.member.dto.MemberReqDTO; import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.entity.Member; @@ -17,4 +18,22 @@ public static MemberResDTO.GetInfo toGetInfo(Member member) { .status(member.getStatus()) .build(); } + + // DTO -> Entity 변환 + public static Member toMember(MemberReqDTO.SignUpReqDTO dto, String encodedPassword) { + return Member.builder() + .name(dto.name()) + .gender(dto.gender()) + .birth(dto.birth()) + .address(dto.address()) + .detailAddress(dto.detailAddress()) + .phoneNumber(dto.phoneNumber()) + .email(dto.email()) + .password(encodedPassword) // BCrypt 암호화된 비밀번호 + .build(); + } + + public static MemberResDTO.SignUpResDTO toSignUpResDTO(Member member) { + return new MemberResDTO.SignUpResDTO(member.getId()); + } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java index e937272d..d28130cf 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDTO.java @@ -1,9 +1,26 @@ package com.example.umc10th.domain.member.dto; +import com.example.umc10th.domain.member.enums.Address; +import com.example.umc10th.domain.member.enums.Gender; + +import java.time.LocalDate; + public class MemberReqDTO { // 마이페이지 public record GetInfo( Long id ){} + + // 회원가입 + public record SignUpReqDTO( + String name, + Gender gender, + LocalDate birth, + Address address, + String detailAddress, + String phoneNumber, + String email, + String password + ){} } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java index e6334334..88bf982d 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/dto/MemberResDTO.java @@ -1,5 +1,6 @@ package com.example.umc10th.domain.member.dto; +import com.example.umc10th.domain.member.service.MemberService; import lombok.Builder; import java.time.LocalDate; @@ -18,4 +19,10 @@ public record GetInfo( Integer point, Enum status ){} + + // 회원가입 + @Builder + public record SignUpResDTO( + Long id + ){} } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java index c50dc9cc..0f390e54 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -21,7 +21,7 @@ public class Member extends BaseEntity { @Id - @GeneratedValue(strategy= GenerationType.SEQUENCE) + @GeneratedValue(strategy= GenerationType.IDENTITY) private Long id; @Column(name="name", nullable=false) @@ -47,6 +47,9 @@ public class Member extends BaseEntity { @Column(name="email", nullable=false) private String email; + @Column(name="password") + private String password; + @Column(name="point", nullable=false) @Builder.Default private int point=0; diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Address.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Address.java index cba8a4a5..ae9d6dac 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Address.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Address.java @@ -1,4 +1,7 @@ package com.example.umc10th.domain.member.enums; public enum Address { + SEOUL, + GYEONGGI, + INCHEON } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/FoodName.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/FoodName.java index 3f347f58..316a4468 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/FoodName.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/FoodName.java @@ -1,4 +1,8 @@ package com.example.umc10th.domain.member.enums; public enum FoodName { + KOREAN, + JAPANESE, + CHINESE, + WESTERN } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Gender.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Gender.java index 729751df..a719d9f2 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Gender.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/Gender.java @@ -1,4 +1,7 @@ package com.example.umc10th.domain.member.enums; public enum Gender { + MALE, + FEMALE, + NONE } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/TermName.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/TermName.java index 5fca61ac..a9bea686 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/TermName.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/enums/TermName.java @@ -1,4 +1,7 @@ package com.example.umc10th.domain.member.enums; public enum TermName { + SERVICE_AGREEMENT, + PRIVACY_POLICY, + LOCATION_TERMS } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java index f42e80f8..5720f48a 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java @@ -1,4 +1,23 @@ package com.example.umc10th.domain.member.exception.code; -public enum MemberSuccessCode { +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MemberSuccessCode implements BaseSuccessCode { + + OK(HttpStatus.OK, + "MEMBER200_1", + "성공적으로 유저를 조회했습니다."), + SIGN_UP(HttpStatus.OK, + "MEMBER200_2", + "회원가입에 성공했습니다."); + + + private final HttpStatus status; + private final String code; + private final String message; } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index aa14436c..8d9fa19f 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -9,4 +9,6 @@ public interface MemberRepository extends JpaRepository { @Query("SELECT m FROM Member m WHERE m.name=:name AND m.deletedAt IS NULL") Optional findActiveMember(String name); + + Optional findByEmail(String username); } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 93e9be65..76ea6d08 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -8,12 +8,14 @@ import com.example.umc10th.domain.member.exception.code.MemberErrorCode; import com.example.umc10th.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; // 마이 페이지 public MemberResDTO.GetInfo getInfo(MemberReqDTO.GetInfo dto) { // DTO에서 유저 ID를 추출 @@ -24,4 +26,21 @@ public MemberResDTO.GetInfo getInfo(MemberReqDTO.GetInfo dto) { // 컨버터를 이용해서 응답 DTO 생성 & return return MemberConverter.toGetInfo(member); } + + // 회원가입 + public MemberResDTO.SignUpResDTO signUp(MemberReqDTO.SignUpReqDTO dto) { + // 비밀번호 BCrypt 암호화 + String encodedPassword=passwordEncoder.encode(dto.password()); + + // DTO -> Entity 변환 + Member member=MemberConverter.toMember(dto, encodedPassword); + + // DB저장 + Member savedMember=memberRepository.save(member); + + // Entity -> ResponseDTO 변환 + return MemberConverter.toSignUpResDTO(savedMember); + + + } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java index 7323f9be..b0b5d9eb 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java @@ -2,49 +2,63 @@ import com.example.umc10th.domain.mission.dto.MissionReqDTO; import com.example.umc10th.domain.mission.dto.MissionResDTO; -import com.example.umc10th.domain.mission.entity.Mission; -import com.example.umc10th.domain.mission.entity.mapping.MemberMission; -import com.example.umc10th.domain.mission.enums.MissionStatus; +import com.example.umc10th.domain.mission.exception.code.MissionSuccessCode; import com.example.umc10th.domain.mission.service.MissionService; import com.example.umc10th.global.apiPayload.ApiResponse; -import com.example.umc10th.global.apiPayload.code.MemberSuccessCode; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1/missions") +@RequestMapping("/api/v1") public class MissionController { private final MissionService missionService; - // 미션 도전하기 API - @PostMapping("/{missionId}/challenge") - public ApiResponse challengeMission( - @PathVariable Long missionId, - @RequestBody MissionReqDTO.ChallengeDTO request + // 내가 진행중이거나 완료한 미션 목록 조회(페이징 포함) + @PostMapping("/members/missions") + public ResponseEntity> getMemberMissions( + @RequestBody MissionReqDTO.MissionListReqDTO dto ){ - return ApiResponse.onSuccess(MemberSuccessCode.OK,missionService.challengeMission(missionId, request)); + // 서비스에서 데이터 가져오기 + return ResponseEntity + .status(MissionSuccessCode.OK.getStatus()) + .body(ApiResponse.onSuccess(MissionSuccessCode.OK, missionService.getMemberMissionList(dto))); } - // 내가 진행중이거나 완료한 미션 목록 조회(페이징 포함) - @GetMapping("/members/{memberId}") - public ApiResponse> getMemberMissions( - @PathVariable Long memberId, - @RequestParam(name="status") MissionStatus status, - @RequestParam(name="page",defaultValue="0")Integer page + // 현재 선택된 지역에서 도전 가능한 미션 목록(페이징 포함) + @GetMapping("/regions/{regionId}/missions") + public ResponseEntity>getRegionMissions( + @PathVariable Long regionId, + @ModelAttribute MissionReqDTO.MyMissionReqDTO dto ){ - return ApiResponse.onSuccess(MemberSuccessCode.OK, missionService.getMemberMissionList(memberId, status, page)); + return ResponseEntity + .status(MissionSuccessCode.OK.getStatus()) + .body(ApiResponse.onSuccess(MissionSuccessCode.OK,missionService.getRegionMissionList(regionId,dto))); } - // 특정 지역의 도전 가능한 미션 목록 조회(페이징 포함) - @GetMapping("/regions/{regionId}") - public ApiResponse> getRegionMissions( - @PathVariable Long regionId, - @RequestParam(name = "page", defaultValue = "0") Integer page - ) { - return ApiResponse.onSuccess(MemberSuccessCode.OK, missionService.getMissionListByRegion(regionId, page)); + // 가게 미션 생성 + @PostMapping("/stores/{storeId}/missions") + public ResponseEntity> createMission( + @PathVariable Long storeId, + @RequestBody @Valid MissionReqDTO.CreateMission dto // Valid 검증 어노테이션떄매 사용 + ){ + return ResponseEntity + .status(MissionSuccessCode.CREATED.getStatus()) + .body(ApiResponse.onSuccess(MissionSuccessCode.CREATED,missionService.createMission(storeId,dto))); + } + + // 가게 내 미션들 조회 + @GetMapping("/stores/{storeId}/missions") + public ResponseEntity>>getMissions( + @PathVariable Long storeId, + @RequestParam Integer pageSize, // 한 페이지에 몇개 보여줄지 + @RequestParam String cursor, + @RequestParam String query + ){ + return ResponseEntity + .status(MissionSuccessCode.OK.getStatus()) + .body(ApiResponse.onSuccess(MissionSuccessCode.OK,missionService.getMissions(storeId,pageSize,cursor,query))); } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java index 33997715..fbab0a4d 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java @@ -1,45 +1,110 @@ package com.example.umc10th.domain.mission.converter; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.mission.dto.MissionReqDTO; import com.example.umc10th.domain.mission.dto.MissionResDTO; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.enums.MissionStatus; +import com.example.umc10th.domain.store.entity.Store; +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.stream.Collectors; public class MissionConverter { - public static MemberMission toMemberMission(Member member, Mission mission) { - return MemberMission.builder() - .member(member) - .mission(mission) - .status(MissionStatus.CHALLENGING) + + // 개별 미션 엔티티 -> 상세 DTO + public static MissionResDTO.MissionDetailDTO toMissionDetailDTO(MemberMission memberMission){ + return MissionResDTO.MissionDetailDTO.builder() + .storeName(memberMission.getMission().getStore().getName()) + .rewardPoint(memberMission.getMission().getRewardPoint()) + .content(memberMission.getMission().getContent()) + .status(memberMission.getStatus()) .build(); } - public static MissionResDTO.MissionChallengeResult toMissionChallengeResult(MemberMission memberMission) { - return MissionResDTO.MissionChallengeResult.builder() - .memberMissionId(memberMission.getId()) - .createdAt(memberMission.getCreatedAt()) + // Page 전체 목록 DTO + public static MissionResDTO.MissionListResDTO toMissionListDTO(Page memberMissionPage) { + // 상세 리스트로 변환 + List missionDetailDTOList= memberMissionPage.getContent().stream() + .map(MissionConverter::toMissionDetailDTO) + .collect(Collectors.toList()); + + // 페이징 정보와 함께 응답 객체 생성 + return MissionResDTO.MissionListResDTO.builder() + .missionList(missionDetailDTOList) + .listSize(missionDetailDTOList.size()) + .totalPage(memberMissionPage.getTotalPages()) + .totalElements(memberMissionPage.getTotalElements()) + .isFirst(memberMissionPage.isFirst()) + .isLast(memberMissionPage.isLast()) .build(); } - // 내가 진행 중인 미션 변환 (MemberMission -> DTO) - public static MissionResDTO.MissionSummaryDTO toMissionSummaryDTO(MemberMission mm) { - return MissionResDTO.MissionSummaryDTO.builder() - .missionId(mm.getMission().getId()) - .storeName(mm.getMission().getStore().getName()) - .rewardPoint(mm.getMission().getRewardPoint()) - .content(mm.getMission().getContent()) - .status(mm.getStatus().name()) + // Mission(가게 원본 미션) -> DTO + public static MissionResDTO.MissionDetailDTO toMissionDetailDTO(Mission mission){ + return MissionResDTO.MissionDetailDTO.builder() + .storeName(mission.getStore().getName()) + .rewardPoint(mission.getRewardPoint()) + .content(mission.getContent()) + .status(mission.getStatus()) .build(); } - // 지역별 도전 가능 미션 변환 (Mission -> DTO) - public static MissionResDTO.MissionSummaryDTO toMissionSummaryDTO(Mission mission) { - return MissionResDTO.MissionSummaryDTO.builder() - .missionId(mission.getId()) - .storeName(mission.getStore().getName()) + + // 지역별 미션 목록 변환 + public static MissionResDTO.RegionMissionListDTO toRegionMissionListDTO(Page missionPage) { + List missionDetailDTOList=missionPage.getContent().stream() + .map(MissionConverter::toMissionDetailDTO) + .collect(Collectors.toList()); + + return MissionResDTO.RegionMissionListDTO.builder() + .missionList(missionDetailDTOList) + .listSize(missionDetailDTOList.size()) + .totalPage(missionPage.getTotalPages()) + .totalElements(missionPage.getTotalElements()) + .isFirst(missionPage.isFirst()) + .isLast(missionPage.isLast()) + .build(); + } + + //가게 미션 생성 + public static Mission toMission( + Store store, + MissionReqDTO.CreateMission dto + ){ + return Mission.builder() + .store(store) + .status(MissionStatus.READY) + .rewardPoint(dto.rewardPoint()) + .deadline(dto.deadline()) + .content(dto.content()) + .build(); + } + + // 가게 내 미션 조회 + public static MissionResDTO.GetMission toGetMission(Mission mission) { + + return MissionResDTO.GetMission.builder() + .status(mission.getStatus()) .rewardPoint(mission.getRewardPoint()) + .missionId((mission.getId())) .content(mission.getContent()) - .status("CHALLENGE_AVAILABLE") // 도전 가능 상태를 나타내는 임의의 문자열 + .build(); + } + // 페이지네이션 틀 생성 + public static MissionResDTO.PaginationtoPagination( + List data, + Boolean hasNext, + String nextCursor, + Integer pageSize + + ){ + return MissionResDTO.Pagination.builder() + .data(data) + .hasNext(hasNext) + .nextCursor(nextCursor) + .pageSize(pageSize) .build(); } } \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java index 0f593309..5ab9e76c 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDTO.java @@ -1,8 +1,43 @@ package com.example.umc10th.domain.mission.dto; +import com.example.umc10th.domain.mission.enums.MissionStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + + public class MissionReqDTO { - // 미션 도전하기 요청 - public record ChallengeDTO( - Long memberId - ){} + // 미션 목록 조회 + @Builder + public record MissionListReqDTO( + Long memberId, + MissionStatus status, + Integer page + ) { + } + + // 홈 화면 가능 미션 목록 + @Builder + public record MyMissionReqDTO( + Long regionId, + Integer page + ) { + } + + // 가게 미션 생성 + @Builder + public record CreateMission( + @NotBlank(message = "조건은 빈칸일 수 없습니다.") + String content, + @NotNull(message = "마감기한은 필수입니다.") + LocalDate deadline, + @NotNull(message = "미션 성공 포인트는 필수입니다.") + Integer rewardPoint + ) { + } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java index a7f80454..6dae5271 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDTO.java @@ -1,22 +1,61 @@ package com.example.umc10th.domain.mission.dto; +import com.example.umc10th.domain.mission.enums.MissionStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; public class MissionResDTO { + // 미션 목록 @Builder - public record MissionChallengeResult( - Long memberMissionId, - LocalDateTime createdAt + public record MissionListResDTO( + List missionList, + Integer listSize, // 데이터 몇개 불러오는지 + Integer totalPage, // 전체 페이지 + Long totalElements, // 전체 미션 개수 + Boolean isFirst, // 첫 페이지인지 + Boolean isLast // 마지막 페이지인지 + ){} + // 미션 정보 + @Builder + public record MissionDetailDTO( + String storeName, // 가게 이름 + Integer rewardPoint, // 보상 + String content, // 미션 내용 + MissionStatus status // 미션 상태 + ){} + + @Builder + public record RegionMissionListDTO( + List missionList, + Integer listSize, + Integer totalPage, + Long totalElements, + Boolean isFirst, + Boolean isLast ){} + // 가게 내 미션 조회 @Builder - public record MissionSummaryDTO( + public record GetMission( Long missionId, - String storeName, // 어떤 가게의 미션인지 - Integer rewardPoint, // 보상 포인트 - String content, // 미션 내용 - String status // 현재 상태 (CHALLENGING 등) + Integer rewardPoint, + MissionStatus status, + String content + ){} + + // 페이지네이션 틀 + @Builder + public record Pagination( + List data, + Boolean hasNext, + String nextCursor, + Integer pageSize ){} } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java index c76560bb..24a9784f 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java @@ -1,10 +1,13 @@ package com.example.umc10th.domain.mission.entity; import com.example.umc10th.domain.BaseEntity; +import com.example.umc10th.domain.mission.enums.MissionStatus; import com.example.umc10th.domain.store.entity.Store; import jakarta.persistence.*; import lombok.*; +import java.time.LocalDate; + @Entity @Getter @Builder @@ -18,10 +21,17 @@ public class Mission extends BaseEntity { @Column(name="rewardPoint",nullable = false) private Integer rewardPoint; - @Column(name="name",nullable = false) + @Column(name="content",nullable = false) private String content; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "store_id") private Store store; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(15) DEFAULT 'READY'") + private MissionStatus status; + + @Column(name="deadline",nullable = false) + private LocalDate deadline; } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/mapping/MemberMission.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/mapping/MemberMission.java index e9ab0d03..86e2bc97 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/mapping/MemberMission.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/entity/mapping/MemberMission.java @@ -18,7 +18,7 @@ public class MemberMission extends BaseEntity { private Long id; @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(15) DEFAULT 'CHALLENGING'") + @Column(columnDefinition = "VARCHAR(15) DEFAULT 'READY'") private MissionStatus status; @ManyToOne(fetch = FetchType.LAZY) diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/enums/MissionStatus.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/enums/MissionStatus.java index 35678025..ab8c2515 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/enums/MissionStatus.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/enums/MissionStatus.java @@ -1,6 +1,7 @@ package com.example.umc10th.domain.mission.enums; public enum MissionStatus { + READY, // 시작 전 CHALLENGING, // 도전 중 COMPLETE, // 완료 FAILED // 실패 diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java index d01099f5..b1e6b504 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java @@ -1,7 +1,19 @@ package com.example.umc10th.domain.mission.exception; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.mission.exception.code.MissionErrorCode; +import lombok.Getter; + +@Getter public class MissionException extends RuntimeException { - public MissionException(String message) { - super(message); + + // 에러 정보를 저장할 필드 추가 + private final MissionErrorCode errorCode; + + // MemberError를 인자로 받는 생성자 추가 + public MissionException(MissionErrorCode errorCode) { + // 부모 클래스(RuntimeException)에게 에러 메시지를 전달 + super(errorCode.getMessage()); + this.errorCode=errorCode; } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java index ee791914..1362257c 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java @@ -1,4 +1,24 @@ package com.example.umc10th.domain.mission.exception.code; -public enum MissionErrorCode { -} +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + + +@Getter +@RequiredArgsConstructor +public enum MissionErrorCode implements BaseErrorCode { + + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, + "MISSION404_1", + "미션 조회에 실패했습니다."), + QUERY_NOT_VALID(HttpStatus.BAD_REQUEST, + "MISSION400_2", + "유효하지 않은 쿼리 파라미터입니다.") + ; + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionSuccessCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionSuccessCode.java index a30e4ea2..e6289484 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionSuccessCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionSuccessCode.java @@ -1,4 +1,22 @@ package com.example.umc10th.domain.mission.exception.code; -public enum MissionSuccessCode { -} +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MissionSuccessCode implements BaseSuccessCode { + + OK(HttpStatus.OK, + "MISSION200_1", + "성공적으로 미션이 조회되었습니다."), + CREATED(HttpStatus.CREATED, + "MISSION201_1", + "성공적으로 미션을 생성했습니다.") + ; + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java index 5674ec57..0a0f15e3 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java @@ -1,14 +1,22 @@ package com.example.umc10th.domain.mission.repository; +import com.example.umc10th.domain.member.entity.Member; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.enums.MissionStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MemberMissionRepository extends JpaRepository { // 특정 유저의 상태별 미션 목록 조회 (페이징 포함) - @Query("SELECT mm FROM MemberMission mm JOIN FETCH mm.mission WHERE mm.member.id = :memberId AND mm.status = :status") - Page findAllByMemberIdAndStatus(Long memberId, MissionStatus status, Pageable pageable); + @Query("SELECT mm FROM MemberMission mm " + + "JOIN FETCH mm.mission m "+ + "JOIN FETCH m.store s "+ + "WHERE mm.member=:member AND mm.status = :status") + Page findAllByMemberIdAndStatus( + @Param("member")Member member, + @Param("status")MissionStatus status, + Pageable pageable); } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java index b49e0b6a..91c9487d 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java @@ -2,12 +2,25 @@ import com.example.umc10th.domain.mission.entity.Mission; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; 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; + +import java.util.List; public interface MissionRepository extends JpaRepository { - // 특정 지역(Region)의 가게들에 걸린 미션 목록 조회 - @Query("SELECT m FROM Mission m JOIN m.store s WHERE s.region.id = :regionId") - Page findAllByRegionId(Long regionId, Pageable pageable); + + @Query("SELECT m From Mission m "+ + "JOIN FETCH m.store s "+ + "JOIN s.region r "+ + "WHERE r.id=:regionId "+ + "AND m.status='READY'") + Page findAllByRegionId(@Param("regionId")Long regionId, Pageable pageable); + + Slice findMissionByStore_IdOrderByIdDesc(Long storeId, PageRequest pageRequest); + + Slice findMissionByStore_IdAndIdLessThanOrderByIdDesc(Long storeId, long idCursor, PageRequest pageRequest); } \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java index c56dfdef..6bb8d79f 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java @@ -1,6 +1,9 @@ package com.example.umc10th.domain.mission.service; +import com.example.umc10th.domain.member.converter.MemberConverter; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; import com.example.umc10th.domain.member.repository.MemberRepository; import com.example.umc10th.domain.mission.converter.MissionConverter; import com.example.umc10th.domain.mission.dto.MissionReqDTO; @@ -8,39 +11,138 @@ import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.enums.MissionStatus; +import com.example.umc10th.domain.mission.exception.MissionException; +import com.example.umc10th.domain.mission.exception.code.MissionErrorCode; import com.example.umc10th.domain.mission.repository.MemberMissionRepository; import com.example.umc10th.domain.mission.repository.MissionRepository; +import com.example.umc10th.domain.store.entity.Region; +import com.example.umc10th.domain.store.entity.Store; +import com.example.umc10th.domain.store.exception.StoreException; +import com.example.umc10th.domain.store.exception.code.StoreErrorCode; +import com.example.umc10th.domain.store.repository.RegionRepository; +import com.example.umc10th.domain.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +import static org.springframework.data.jpa.domain.AbstractPersistable_.id; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class MissionService { - private final MissionRepository missionRepository; private final MemberMissionRepository memberMissionRepository; private final MemberRepository memberRepository; + private final RegionRepository regionRepository; + private final MissionRepository missionRepository; + private final StoreRepository storeRepository; + + public MissionResDTO.MissionListResDTO getMemberMissionList(MissionReqDTO.MissionListReqDTO dto) { + + // DB에서 해당 유저 ID로 데이터 조회 + Member member=memberRepository.findById(dto.memberId()) + .orElseThrow(()-> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 몇 번재 페이지를, 몇 개씩 가져올지 + PageRequest pageRequest=PageRequest.of(dto.page(),3); + + // DB에서 해당 멤버와 상태가 일치하는 데이터를 페이징해서 가져옴 + Page memberMissionPage=memberMissionRepository.findAllByMemberIdAndStatus( + member, + dto.status(), // 진행중/완료 선택한 데이터 + pageRequest + ); + // 컨버터를 이용해서 응답 DTO 생성 & return + return MissionConverter.toMissionListDTO(memberMissionPage); + } + + public MissionResDTO.RegionMissionListDTO getRegionMissionList(Long regionId, MissionReqDTO.MyMissionReqDTO dto) { + + // 지역 존재 확인 + Region region=regionRepository.findById(regionId) + .orElseThrow(()->new StoreException(StoreErrorCode.REGION_NOT_FOUND)); + + // 페이징 + PageRequest pageRequest=PageRequest.of(dto.page(),3); + + // 리포지토리에서 지역 id에 해당하는 미션 조회 + Page missionPage=missionRepository.findAllByRegionId(regionId,pageRequest); + + // 컨버터를 이용해서 응답 dto로 변환 후 반환 + return MissionConverter.toRegionMissionListDTO(missionPage); + } @Transactional - public MissionResDTO.MissionChallengeResult challengeMission(Long missionId, MissionReqDTO.ChallengeDTO request) { - Member member = memberRepository.findById(request.memberId()).orElseThrow(); - Mission mission = missionRepository.findById(missionId).orElseThrow(); + public Void createMission(Long storeId, MissionReqDTO.CreateMission dto) { - // 도전 테이블 데이터 생성 - MemberMission memberMission = MissionConverter.toMemberMission(member, mission); - return MissionConverter.toMissionChallengeResult(memberMissionRepository.save(memberMission)); + // 가게 찾기 + Store store=storeRepository.findById(storeId) + .orElseThrow(()->new StoreException(StoreErrorCode.STORE_NOT_FOUND)); + + // 미션 생성 + Mission mission= MissionConverter.toMission(store,dto); + + // 미션 DB 저장 + missionRepository.save(mission); + return null; } - public Page getMemberMissionList(Long memberId, MissionStatus status, Integer page) { - Page memberMissions = memberMissionRepository.findAllByMemberIdAndStatus(memberId, status, PageRequest.of(page, 10)); - return memberMissions.map(MissionConverter::toMissionSummaryDTO); // 변환 적용 + // 가게 내 미션들 조회 + public MissionResDTO.Pagination getMissions( + Long storeId, + Integer pageSize, + String cursor, + String query + ) { + // 페이지 정보들을 PageRequest로 만들기 + PageRequest pageRequest=PageRequest.of(0,pageSize); + + long idCursor; + Slice missionList; + String nextCursor; + + // 커서가 있는 경우 + if(!cursor.equals("-1")) { + + // 커서 분리 + String[] cursorSplit = cursor.split(":"); + switch (query.toLowerCase()) { + case "id": + + // 커서 타입 변환 + Long prevCursor = Long.parseLong(cursorSplit[0]); + idCursor = Long.parseLong(cursorSplit[1]); + + // 가게 내 미션들 조회& where절에 커서값 기입 + missionList = missionRepository.findMissionByStore_IdAndIdLessThanOrderByIdDesc( + storeId, + idCursor, + pageRequest + ); + break; + default: + throw new MissionException(MissionErrorCode.QUERY_NOT_VALID); + } + }else{ + // 커서 없이 조회 + missionList=missionRepository.findMissionByStore_IdOrderByIdDesc(storeId,pageRequest); } + // 다음 커서 계산 + nextCursor=missionList.getContent().getLast().getId()+":"+missionList.getContent().getLast().getId(); - public Page getMissionListByRegion(Long regionId, Integer page) { - Page missions = missionRepository.findAllByRegionId(regionId, PageRequest.of(page, 10)); - return missions.map(MissionConverter::toMissionSummaryDTO); // 변환 적용 + // 미션들 응답 DTO로 포장하기 + return MissionConverter.toPagination( + missionList.map(MissionConverter::toGetMission).toList(), + missionList.hasNext(), + nextCursor, + missionList.getSize() + ); } } + diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java index bb7ae77d..c12932b5 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java @@ -2,37 +2,41 @@ import com.example.umc10th.domain.review.dto.ReviewReqDTO; import com.example.umc10th.domain.review.dto.ReviewResDTO; -import com.example.umc10th.domain.review.entity.Review; +import com.example.umc10th.domain.review.exception.code.ReviewSuccessCode; import com.example.umc10th.domain.review.service.ReviewService; import com.example.umc10th.global.apiPayload.ApiResponse; -import com.example.umc10th.global.apiPayload.code.MemberSuccessCode; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/reviews") +@RequiredArgsConstructor // final이 붙은거 생성자 대신 작성 +@RequestMapping("/api/v1") public class ReviewController { private final ReviewService reviewService; // 특정 가게에 리뷰 등록 - @PostMapping("/stores/{storeId}") - public ApiResponse createReview( - @PathVariable Long storeId, - @RequestBody ReviewReqDTO.CreateReview request + @PostMapping("/stores/{storeId}/reviews") + public ResponseEntity> createReview( + @PathVariable Long storeId, // {storeId}에 적힌 숫자를 storeId라는 변수에 담음 + @RequestBody ReviewReqDTO.CreateReview dto // 사용자가 작성한 리뷰 정보 가져옴 ) { - return ApiResponse.onSuccess(MemberSuccessCode.OK, reviewService.createReview(storeId, request)); + return ResponseEntity + .status(ReviewSuccessCode.CREATED.getStatus()) + .body(ApiResponse.onSuccess(ReviewSuccessCode.CREATED, reviewService.createReviewResult(storeId,dto))); } - // 특정 가게의 리뷰 목록 조회(페이징 포함) - @GetMapping("/stores/{storeId}") - public ApiResponse>getStoreReviews( - @PathVariable Long storeId, - @RequestParam(name="page",defaultValue = "0")Integer page + + // 내가 작성한 리뷰들 조회 + @GetMapping("/members/{memberId}/reviews") + public ResponseEntity>> getReviews( + @PathVariable Long memberId, + @RequestParam Integer pageSize, + @RequestParam String cursor, + @RequestParam String query ){ - return ApiResponse.onSuccess(MemberSuccessCode.OK, reviewService.getReviewList(storeId, page)); + return ResponseEntity + .status(ReviewSuccessCode.OK.getStatus()) + .body(ApiResponse.onSuccess(ReviewSuccessCode.OK,reviewService.getReviews(memberId,pageSize,cursor,query))); } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java index 63a5f1dc..56d2b79c 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java @@ -1,35 +1,58 @@ package com.example.umc10th.domain.review.converter; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.mission.dto.MissionResDTO; import com.example.umc10th.domain.review.dto.ReviewReqDTO; import com.example.umc10th.domain.review.dto.ReviewResDTO; import com.example.umc10th.domain.review.entity.Review; import com.example.umc10th.domain.store.entity.Store; +import java.util.List; + public class ReviewConverter { - public static Review toReview(ReviewReqDTO.CreateReview request, Member member, Store store) { + + // 사용자가 보낸 DTO를 DB에 저장할 엔티티로 (조회가 아닌 생성(작성)이라 추가됨) + public static Review toReview(ReviewReqDTO.CreateReview request,Member member,Store store){ return Review.builder() .rating(request.rating()) .content(request.content()) - .member(member) - .store(store) + .member(member) // 연관관계 매핑 + .store(store) // 연관관계 매핑 .build(); } + // 저장된 결과를 DTO로 포장 public static ReviewResDTO.CreateReviewResult toCreateReviewResult(Review review) { return ReviewResDTO.CreateReviewResult.builder() - .reviewId(review.getId()) + .id(review.getId()) .createdAt(review.getCreatedAt()) .build(); } - public static ReviewResDTO.ReviewDetail toReviewSummaryDTO(Review review) { - return ReviewResDTO.ReviewDetail.builder() + // 페이지네이션 틀 생성 + public static ReviewResDTO.PaginationtoPagination( + List data, + Boolean hasNext, + String nextCursor, + Integer pageSize + ){ + return ReviewResDTO.Pagination.builder() + .data(data) + .hasNext(hasNext) + .nextCursor(nextCursor) + .pageSize(pageSize) + .build(); + } + + + public static ReviewResDTO.MyReviewDetailDTO toGetReview(Review review) { + return ReviewResDTO.MyReviewDetailDTO.builder() .reviewId(review.getId()) - .writerName(review.getMember().getName()) // 엔티티 그래프에서 이름만 추출 + .storeName(review.getStore().getName()) + .memberName(review.getMember().getName()) .rating(review.getRating()) .content(review.getContent()) - .createdAt(review.getCreatedAt().toLocalDate()) // 날짜 형식 정제 + .createdAt(review.getCreatedAt().toLocalDate()) .build(); } } \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDTO.java index af04ced1..178c6b17 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDTO.java @@ -1,12 +1,14 @@ package com.example.umc10th.domain.review.dto; +import lombok.Builder; + import java.util.List; public class ReviewReqDTO { + @Builder public record CreateReview( Long memberId, Float rating, - String content, - List imageUrls + String content ){} } \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java index 4d949b26..64081eff 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDTO.java @@ -4,20 +4,32 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; public class ReviewResDTO { @Builder public record CreateReviewResult( - Long reviewId, + Long id, LocalDateTime createdAt ){} + // 전체 목록 응답 @Builder - public record ReviewDetail( + public record MyReviewDetailDTO( Long reviewId, - String writerName, + String storeName, + String memberName, Float rating, String content, LocalDate createdAt ){} + + // 페이지네이션 틀 + @Builder + public record Pagination( + List data, + Boolean hasNext, + String nextCursor, + Integer pageSize + ){} } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java index 1664f68a..2560e32c 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java @@ -1,7 +1,15 @@ package com.example.umc10th.domain.review.exception; +import com.example.umc10th.domain.review.exception.code.ReviewErrorCode; +import lombok.Getter; + +@Getter public class ReviewException extends RuntimeException { - public ReviewException(String message) { - super(message); + // 에러 정보를 저장할 필드 추가 + private final ReviewErrorCode errorCode; + public ReviewException(ReviewErrorCode errorCode) { + // 부모 RuntimeException에 에러 메세지 전달 + super(errorCode.getMessage()); + this.errorCode=errorCode; } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java index 84ca45f9..25398ff9 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java @@ -7,7 +7,8 @@ @Getter @RequiredArgsConstructor public enum ReviewErrorCode { - REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW404_1", "해당 리뷰를 찾을 수 없습니다."); + REVIEW_NOT_CREATED(HttpStatus.NOT_FOUND, "REVIEW404_1", "해당 리뷰를 찾을 수 없습니다."), + QUERY_NOT_VALID(HttpStatus.BAD_REQUEST, "REVIEW400_1", "유효하지 않은 쿼리 파라미터입니다."); private final HttpStatus status; private final String code; diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java index 4dd5f9a2..e37d742b 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java @@ -8,11 +8,13 @@ @Getter @RequiredArgsConstructor public enum ReviewSuccessCode implements BaseSuccessCode { - - OK(HttpStatus.OK, + CREATED(HttpStatus.CREATED, "REVIEW200_1", - "성공적으로 리뷰가 작성되었습니다."), - ; + "성공적으로 리뷰가 생성되었습니다."), + OK(HttpStatus.OK, + "REVIEW200_2", + "성공적으로 리뷰가 조회되었습니다."), + ; private final HttpStatus status; private final String code; private final String message; diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java index c6a8bdaa..4bb85b25 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java @@ -1,13 +1,63 @@ package com.example.umc10th.domain.review.repository; import com.example.umc10th.domain.review.entity.Review; -import org.springframework.data.domain.Page; 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 { - // 특정 가게의 리뷰 목록 조회 - @Query("SELECT r FROM Review r WHERE r.store.id = :storeId") - Page findAllByStoreId(Long storeId, Pageable pageable); -} + + // ── ID 순 ──────────────────────────────────────────────────────────────── + + // 첫 페이지: ID 내림차순 전체 조회 + @Query(""" + SELECT r FROM Review r + WHERE r.member.id = :memberId + ORDER BY r.id DESC + """) + Slice findReviewsByMember_IdOrderByIdDesc( + @Param("memberId") Long memberId, + Pageable pageable); + + // 이후 페이지: 커서(ID)보다 작은 것만 조회 + @Query(""" + SELECT r FROM Review r + WHERE r.member.id = :memberId + AND r.id < :idCursor + ORDER BY r.id DESC + """) + Slice findReviewsByMember_IdAndIdLessThanOrderByIdDesc( + @Param("memberId") Long memberId, + @Param("idCursor") Long idCursor, + Pageable pageable); + + // ── RATING 순 ──────────────────────────────────────────────────────────── + + // 첫 페이지: rating 내림차순, 동률이면 ID 내림차순 + @Query(""" + SELECT r FROM Review r + WHERE r.member.id = :memberId + ORDER BY r.rating DESC, r.id DESC + """) + Slice findReviewsByMember_IdOrderByRatingDescIdDesc( + @Param("memberId") Long memberId, + Pageable pageable); + + // 이후 페이지: (rating < cursor) OR (rating = cursor AND id < idCursor) + @Query(""" + SELECT r FROM Review r + WHERE r.member.id = :memberId + AND ( + r.rating < :ratingCursor + OR (r.rating = :ratingCursor AND r.id < :idCursor) + ) + ORDER BY r.rating DESC, r.id DESC + """) + Slice findReviewsByMember_IdWithRatingCursor( + @Param("memberId") Long memberId, + @Param("ratingCursor") Float ratingCursor, + @Param("idCursor") Long idCursor, + Pageable pageable); +} \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java b/Hyeonu/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java index 33a910ed..c04cf2c4 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java @@ -1,17 +1,27 @@ package com.example.umc10th.domain.review.service; +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.dto.MemberReqDTO; +import com.example.umc10th.domain.member.dto.MemberResDTO; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; import com.example.umc10th.domain.member.repository.MemberRepository; import com.example.umc10th.domain.review.converter.ReviewConverter; import com.example.umc10th.domain.review.dto.ReviewReqDTO; import com.example.umc10th.domain.review.dto.ReviewResDTO; import com.example.umc10th.domain.review.entity.Review; +import com.example.umc10th.domain.review.exception.ReviewException; +import com.example.umc10th.domain.review.exception.code.ReviewErrorCode; import com.example.umc10th.domain.review.repository.ReviewRepository; import com.example.umc10th.domain.store.entity.Store; +import com.example.umc10th.domain.store.exception.StoreException; +import com.example.umc10th.domain.store.exception.code.StoreErrorCode; import com.example.umc10th.domain.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,22 +35,122 @@ public class ReviewService { private final MemberRepository memberRepository; private final StoreRepository storeRepository; + // 리뷰 작성 @Transactional - public ReviewResDTO.CreateReviewResult createReview(Long storeId, ReviewReqDTO.CreateReview request) { - Member member = memberRepository.findById(request.memberId()).orElseThrow(); - Store store = storeRepository.findById(storeId).orElseThrow(); + public ReviewResDTO.CreateReviewResult createReviewResult(Long storeId, ReviewReqDTO.CreateReview dto) { + // 리뷰를 달 가게가 있는지 확인 + Store store=storeRepository.findById(storeId) + .orElseThrow(()->new StoreException(StoreErrorCode.STORE_NOT_FOUND)); - Review review = ReviewConverter.toReview(request, member, store); - return ReviewConverter.toCreateReviewResult(reviewRepository.save(review)); + // 리뷰를 쓰는 멤버가 있는지 확인 + Member member=memberRepository.findById(dto.memberId()) + .orElseThrow(()->new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 찾아온 가게와 멤버 정보를 엮어서 리뷰 객체 생성 + Review review=ReviewConverter.toReview(dto,member,store); + + // db저장 + Review savedReview=reviewRepository.save(review); + + return ReviewConverter.toCreateReviewResult(savedReview); } - // 특정 가게의 리뷰 목록 조회(페이징) - public List getReviewList(Long storeId, Integer page) { - Store store = storeRepository.findById(storeId).orElseThrow(); - // 1. 일단 페이지로 가져옵니다. - Page reviewPage = reviewRepository.findAllByStoreId(storeId, PageRequest.of(page, 10)); - // 2. DTO로 변환한 뒤, .toList()를 써서 리스트로만 뽑아냅니다. - return reviewPage.map(ReviewConverter::toReviewSummaryDTO).getContent(); + // 리뷰 조회 + public ReviewResDTO.Pagination getReviews( + Long memberId, + Integer pageSize, + String cursor, + String query + ) { + PageRequest pageRequest = PageRequest.of(0, pageSize); + + Slice reviewList; + String nextCursor = null; + + // 커서가 존재하는 경우 (첫 페이지가 아닌 경우) + if (cursor != null && !cursor.equals("-1")) { + + // 커서 파싱: "firstId:lastId" 또는 "firstRating:lastId" 형태 + String[] cursorSplit = cursor.split(":"); + + switch (query.toLowerCase()) { + + // ── ID 순 정렬 ────────────────────────────────────────────────── + case "id": + // cursorSplit[1]: 이전 페이지 마지막 리뷰의 ID + long idCursor = Long.parseLong(cursorSplit[1]); + + // 이전 페이지 마지막 ID보다 작은 리뷰들을 ID 내림차순으로 조회 + reviewList = reviewRepository + .findReviewsByMember_IdAndIdLessThanOrderByIdDesc( + memberId, idCursor, pageRequest); + break; + + // ── RATING 순 정렬 ─────────────────────────────────────────────── + case "rating": + // cursorSplit[0]: 이전 페이지 마지막 리뷰의 Rating + // cursorSplit[1]: 이전 페이지 마지막 리뷰의 ID (동일 rating 내 순서 보장용) + float ratingCursor = Float.parseFloat(cursorSplit[0]); + long ratingIdCursor = Long.parseLong(cursorSplit[1]); + + // rating 내림차순, 같은 rating이면 ID 내림차순으로 조회 + // WHERE (rating < ratingCursor) OR (rating = ratingCursor AND id < ratingIdCursor) + reviewList = reviewRepository + .findReviewsByMember_IdWithRatingCursor( + memberId, ratingCursor, ratingIdCursor, pageRequest); + break; + + default: + throw new ReviewException(ReviewErrorCode.QUERY_NOT_VALID); + } + + } else { + // 커서가 없는 경우 (첫 페이지) + switch (query.toLowerCase()) { + + // ── ID 순 첫 페이지 ────────────────────────────────────────────── + case "id": + reviewList = reviewRepository + .findReviewsByMember_IdOrderByIdDesc(memberId, pageRequest); + break; + + // ── RATING 순 첫 페이지 ────────────────────────────────────────── + case "rating": + reviewList = reviewRepository + .findReviewsByMember_IdOrderByRatingDescIdDesc(memberId, pageRequest); + break; + + default: + throw new ReviewException(ReviewErrorCode.QUERY_NOT_VALID); + } + } + + // 다음 페이지가 존재하고 현재 페이지 데이터가 있을 때만 커서 생성 + if (reviewList.hasNext() && !reviewList.getContent().isEmpty()) { + Review lastReview = reviewList.getContent().getLast(); + + switch (query.toLowerCase()) { + + // ID 순: "firstId:lastId" + case "id": + nextCursor = reviewList.getContent().getFirst().getId() + + ":" + lastReview.getId(); + break; + + // RATING 순: "lastRating:lastId" + case "rating": + nextCursor = lastReview.getRating() + + ":" + lastReview.getId(); + break; + } + } + + return ReviewConverter.toPagination( + reviewList.map(ReviewConverter::toGetReview).toList(), + reviewList.hasNext(), + nextCursor, + reviewList.getSize() + ); } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java index 742adc0d..c7906d26 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java @@ -1,10 +1,5 @@ package com.example.umc10th.domain.store.controller; -import com.example.umc10th.domain.store.dto.StoreResDTO; -import com.example.umc10th.domain.store.service.StoreService; -import com.example.umc10th.global.apiPayload.ApiResponse; -import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; -import com.example.umc10th.global.apiPayload.code.MemberSuccessCode; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -13,10 +8,5 @@ @RequestMapping("/api/v1/stores") public class StoreController { - private final StoreService storeService; - @GetMapping("/{storeId}") - public ApiResponse getStoreInfo(@PathVariable Long storeId) { - return ApiResponse.onSuccess(MemberSuccessCode.OK, storeService.getStoreInfo(storeId)); - } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/converter/StoreConverter.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/converter/StoreConverter.java index fd360437..18054bd9 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/store/converter/StoreConverter.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/converter/StoreConverter.java @@ -4,19 +4,5 @@ import com.example.umc10th.domain.store.entity.Store; public class StoreConverter { - public static StoreResDTO.StoreInfo toStoreInfo(Store store) { - return StoreResDTO.StoreInfo.builder() - .id(store.getId()) - .name(store.getName()) - .address(store.getAddress()) - .regionName(store.getRegion().getName()) - .build(); - } - public static StoreResDTO.RegisterResult toRegisterResult(Store store) { - return StoreResDTO.RegisterResult.builder() - .storeId(store.getId()) - .createdAt(store.getCreatedAt()) - .build(); - } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreReqDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreReqDTO.java index 40d91800..8f91b230 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreReqDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreReqDTO.java @@ -3,10 +3,5 @@ public class StoreReqDTO { - // 가게 등록 요청 - public record JoinDTO( - String name, // 가게 이름 - String address, // 각 주소 - Long regionId // 지역ID - ){} + } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreResDTO.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreResDTO.java index 794d9e1a..f599da6c 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreResDTO.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/dto/StoreResDTO.java @@ -4,17 +4,5 @@ import java.time.LocalDateTime; public class StoreResDTO { - @Builder - public record StoreInfo( - Long id, - String name, - String address, - String regionName - ){} - @Builder - public record RegisterResult( - Long storeId, - LocalDateTime createdAt - ){} } \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java index d5143b74..35101c67 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java @@ -1,14 +1,15 @@ package com.example.umc10th.domain.store.exception; import com.example.umc10th.domain.store.exception.code.StoreErrorCode; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.exception.ProjectException; import lombok.Getter; @Getter -public class StoreException extends RuntimeException { - private final StoreErrorCode errorCode; +public class StoreException extends ProjectException { + + public StoreException(BaseErrorCode code) { + super(code); - public StoreException(StoreErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java index 60d8c64a..c2373704 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java @@ -1,12 +1,14 @@ package com.example.umc10th.domain.store.exception.code; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor -public enum StoreErrorCode { +public enum StoreErrorCode implements BaseErrorCode { STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404_1", "해당 가게를 찾을 수 없습니다."), REGION_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404_2", "해당 지역이 존재하지 않습니다."); diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/repository/RegionRepository.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/repository/RegionRepository.java new file mode 100644 index 00000000..83f12fbc --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/repository/RegionRepository.java @@ -0,0 +1,9 @@ +package com.example.umc10th.domain.store.repository; + +import com.example.umc10th.domain.mission.entity.Mission; +import com.example.umc10th.domain.store.entity.Region; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionRepository extends JpaRepository { + +} \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/domain/store/service/StoreService.java b/Hyeonu/src/main/java/com/example/umc10th/domain/store/service/StoreService.java index 263997e6..016f7a50 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/domain/store/service/StoreService.java +++ b/Hyeonu/src/main/java/com/example/umc10th/domain/store/service/StoreService.java @@ -14,11 +14,5 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class StoreService { - private final StoreRepository storeRepository; - public StoreResDTO.StoreInfo getStoreInfo(Long storeId) { - Store store = storeRepository.findById(storeId) - .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); - return StoreConverter.toStoreInfo(store); - } } \ No newline at end of file diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/apiPayload/code/MemberSuccessCode.java b/Hyeonu/src/main/java/com/example/umc10th/global/apiPayload/code/MemberSuccessCode.java deleted file mode 100644 index 0d337cca..00000000 --- a/Hyeonu/src/main/java/com/example/umc10th/global/apiPayload/code/MemberSuccessCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.umc10th.global.apiPayload.code; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum MemberSuccessCode implements BaseSuccessCode{ - - OK(HttpStatus.OK, - "MEMBER200_1", - "성공적으로 유저를 조회했습니다."), - ; - - private final HttpStatus status; - private final String code; - private final String message; -} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java b/Hyeonu/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java index 3de86d0d..1eeb0942 100644 --- a/Hyeonu/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/Hyeonu/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -5,9 +5,13 @@ import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; import com.example.umc10th.global.apiPayload.exception.ProjectException; 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 { @@ -33,4 +37,19 @@ public ResponseEntity> handleException( ex.getMessage() )); } + // @Valid 어노테이션 검증 실패 예외 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleMethodArgumentNotValidException( + MethodArgumentNotValidException e + ){ + // 검증 실패한 변수명과 실패 이유를 담을 Map + 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)); + } } diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/Hyeonu/src/main/java/com/example/umc10th/global/config/SecurityConfig.java new file mode 100644 index 00000000..16900ba4 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -0,0 +1,75 @@ +package com.example.umc10th.global.config; + +import com.example.umc10th.global.security.handler.CustomAccessDenied; +import com.example.umc10th.global.security.handler.CustomEntryPoint; +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 // Spring Security 활성화 +@Configuration // 스프링 설정 파일임을 선언 +public class SecurityConfig { + + // 허용 URI 목록 + private final String[] allowUris = { + // Swagger 허용 + "/swagger-ui/**", // API 문서 UI + "/swagger-resources/**", // Swagger 리소스 + "/v3/api-docs/**", // Openapi 스펙 문서 + "/api/auth/**" // 인증 관련 엔드 포인트(로그인, 회원가입 등) + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // RestAPI는 보통 CSRF 공격에 덜 취약하므로 비활성화 + .csrf(AbstractHttpConfigurer::disable) + // 요청 권한 설정 + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() // allowUris는 누구나 접근 가능 + .anyRequest().authenticated() // 그 외 모든 요청은 로그인 필요 + ) + // 폼 로그인 설정 + .formLogin(form -> form + .defaultSuccessUrl("/swagger-ui/index.html", true) // 로그인 성공 시 이동 + .permitAll() // 로그인 페이지는 모든 사용자가 접근 가능 + ) + // 로그아웃 설정 + .logout(logout -> logout + .logoutUrl("/logout") // 이 URL로 POST 요청 시 로그아웃 + .logoutSuccessUrl("/login?logout") // 로그아웃 후 로그인 페이지로 이동 + .permitAll() + ) + // 예외 상황 핸들러 + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDenied()) // 403 발생 + .authenticationEntryPoint(customEntryPoint())) //401 발생 + ; + + + return http.build(); + } + + // PasswordEncoder- 비밀번호 암호화 + // BCrypt 알고리즘으로 해시 암호화(같은 비밀번호도 매번 다른 해시값이 생성되어 암호화) + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + // + @Bean + public CustomAccessDenied customAccessDenied(){ + return new CustomAccessDenied(); + } + + @Bean + public CustomEntryPoint customEntryPoint(){ + return new CustomEntryPoint(); + } +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java new file mode 100644 index 00000000..1e3d3da4 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java @@ -0,0 +1,36 @@ +package com.example.umc10th.global.security.entity; + +import com.example.umc10th.domain.member.entity.Member; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter // 모든 필드의 getter 자동 생성 +@RequiredArgsConstructor // final 필드를 받는 생성자 자동 생성 +// Member를 Security용으로 포장한 Wrapper 역할 +public class AuthMember implements UserDetails { + + private final Member member; + + // 자신의 역할 반환(유저, 관리자) + @Override + public Collection getAuthorities(){ + return List.of(); // 권한 구분 미구현 상태(빈 리스트 반환) + } + + @Override + public @Nullable String getPassword(){ // @Nullable은 비밀번호가 없을 수도 있음(소셜 로그인 등) + return member.getPassword(); // Member 엔티티의 비밀번호 반환 + } + + @Override + public String getUsername(){ + return member.getEmail(); // 이메일을 username으로 사용(식별자로) + } +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java new file mode 100644 index 00000000..929cdce2 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java @@ -0,0 +1,39 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +// 권한이 없는 사용자가 접근할 떄 JSON으로 응답해주는 핸들러 +// AccessDeniedHandler는 로그인은 했지만 권한이 없을 때 어떻게 처리할지 +// 401 Unauthorized 로그인 자체를 안함/ 403 Forbidden 로그인은 했지만 권한 없음 (이 핸들러가 처리) +public class CustomAccessDenied implements AccessDeniedHandler { + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + )throws IOException { + // Java 객체->JSON문자열로 변환해주는 도구 + ObjectMapper objectMapper=new ObjectMapper(); + BaseErrorCode code= GeneralErrorCode.FORBIDDEN; // 403 Forbidden 에러코드 가져옴 + + // 응답 Content-Type, HTTP 상태코드 정의 + response.setContentType("application/json;charset=UTF-8"); // JSON 형식으로 응답 + response.setStatus(code.getStatus().value()); // HTTP 상태코드 403 설정 + + // Response Body에 응답통일한 객체를 넣기 + ApiResponse errorResponse=ApiResponse.onFailure(code,null); + + // 실제 Response로 덮어쓰기 (만든 객체를 실제 HTTP 응답 바디에 JSON으로 작성) + objectMapper.writeValue(response.getOutputStream(),errorResponse); + } +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java new file mode 100644 index 00000000..bfe138b2 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java @@ -0,0 +1,35 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; +// 로그인 안했을때 +public class CustomEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + )throws IOException { + ObjectMapper objectMapper=new ObjectMapper(); + BaseErrorCode code= GeneralErrorCode.UNAUTHORIZED; + + // 응답 Content-Type, HTTP 상태코드 정의 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // Response Body에 응답통일한 객체를 넣기 + ApiResponse errorResponse= ApiResponse.onFailure(code,null); + + // 실제 Response로 덮어쓰기 + objectMapper.writeValue(response.getOutputStream(),errorResponse); + } +} diff --git a/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..dc1306d2 --- /dev/null +++ b/Hyeonu/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,32 @@ +package com.example.umc10th.global.security.service; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.entity.AuthMember; +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 // final 필드 생성자 자동 생성 +// Spring Security가 "이 인터페이스를 구현한 클래스로 사용자를 조회해라" +public class CustomUserDetailsService implements UserDetailsService { + + // DB에서 회원을 조회하기 위한 Repository + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername( + String username + )throws UsernameNotFoundException{ + // 이메일로 DB에서 회원 조회 + Member member=memberRepository.findByEmail(username) + .orElseThrow(()->new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + // AuthMember로 감싸서 반환 + return new AuthMember(member); + } +}