diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 9d22750..1abebca 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -6,6 +6,7 @@ on: branches: [ "main" ] # main 브랜치에 push될 때 실행 pull_request: branches: [ "main" ] # main 브랜치에 pull_request될 때 실행 + workflow_dispatch: # Actions 탭에서 수동으로 실행 가능하도록 설정 jobs: build: diff --git a/build.gradle b/build.gradle index ee064e0..6e48c05 100644 --- a/build.gradle +++ b/build.gradle @@ -25,11 +25,23 @@ repositories { } dependencies { + // Spring Data JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring Web implementation 'org.springframework.boot:spring-boot-starter-web' + + // Lombok compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + + // MySQL + runtimeOnly 'com.mysql:mysql-connector-j' + + // SpringDoc (OpenAPI/Swagger for Spring Boot 3.x) + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/example/umc9th/domain/inquiry/entity/Inquiry.java b/src/main/java/com/example/umc9th/domain/inquiry/entity/Inquiry.java index aa5cfa0..3dfcb72 100644 --- a/src/main/java/com/example/umc9th/domain/inquiry/entity/Inquiry.java +++ b/src/main/java/com/example/umc9th/domain/inquiry/entity/Inquiry.java @@ -2,7 +2,7 @@ import com.example.umc9th.domain.inquiry.entity.enums.InquiryType; import com.example.umc9th.domain.member.entity.Member; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.ColumnDefault; diff --git a/src/main/java/com/example/umc9th/domain/inquiry/entity/InquiryImage.java b/src/main/java/com/example/umc9th/domain/inquiry/entity/InquiryImage.java index f2bd668..3d5b810 100644 --- a/src/main/java/com/example/umc9th/domain/inquiry/entity/InquiryImage.java +++ b/src/main/java/com/example/umc9th/domain/inquiry/entity/InquiryImage.java @@ -1,6 +1,6 @@ package com.example.umc9th.domain.inquiry.entity; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java b/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java index 41081d3..8affeca 100644 --- a/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java @@ -1,7 +1,9 @@ package com.example.umc9th.domain.member.controller; +import com.example.umc9th.domain.member.dto.MemberResponse; import com.example.umc9th.domain.member.repository.MemberRepository; import com.example.umc9th.domain.member.service.MemberService; +import com.example.umc9th.global.apiPayload.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -17,8 +19,7 @@ public class MemberController { private final MemberService memberService; @DeleteMapping("/{memberId}") - public ResponseEntity deleteMember(@PathVariable Long memberId){ - memberService.deleteMemberAndAll(memberId); - return ResponseEntity.notFound().build(); // 성공 시 204 No Content 응답 + public ApiResponse deleteMember(@PathVariable Long memberId){ + return ApiResponse.onSuccess(memberService.deleteMemberAndAll(memberId)); } } diff --git a/src/main/java/com/example/umc9th/domain/member/dto/MemberResponse.java b/src/main/java/com/example/umc9th/domain/member/dto/MemberResponse.java new file mode 100644 index 0000000..717f21c --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/dto/MemberResponse.java @@ -0,0 +1,23 @@ +package com.example.umc9th.domain.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class MemberResponse { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "회원 탈퇴 응답 정보") + public static class MemberDeleteResultDTO{ + + @Schema(description = "유저 상세 정보 ID", example = "1") + private Long userId; + + private String message; + } +} diff --git a/src/main/java/com/example/umc9th/domain/member/entity/FoodCategory.java b/src/main/java/com/example/umc9th/domain/member/entity/FoodCategory.java index 122ae75..167e368 100644 --- a/src/main/java/com/example/umc9th/domain/member/entity/FoodCategory.java +++ b/src/main/java/com/example/umc9th/domain/member/entity/FoodCategory.java @@ -1,7 +1,7 @@ package com.example.umc9th.domain.member.entity; import com.example.umc9th.domain.store.entity.Store; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/member/entity/Member.java b/src/main/java/com/example/umc9th/domain/member/entity/Member.java index 30101ab..cc87a3d 100644 --- a/src/main/java/com/example/umc9th/domain/member/entity/Member.java +++ b/src/main/java/com/example/umc9th/domain/member/entity/Member.java @@ -6,7 +6,7 @@ import com.example.umc9th.domain.member.entity.enums.SocialLoginType; import com.example.umc9th.domain.mission.entity.MissionByMember; import com.example.umc9th.domain.review.entity.Review; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.ColumnDefault; diff --git a/src/main/java/com/example/umc9th/domain/member/entity/MemberAgree.java b/src/main/java/com/example/umc9th/domain/member/entity/MemberAgree.java index 0eb8867..5f5875c 100644 --- a/src/main/java/com/example/umc9th/domain/member/entity/MemberAgree.java +++ b/src/main/java/com/example/umc9th/domain/member/entity/MemberAgree.java @@ -1,7 +1,7 @@ package com.example.umc9th.domain.member.entity; import com.example.umc9th.domain.member.entity.enums.AgreementType; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/member/entity/MemberPreferCategory.java b/src/main/java/com/example/umc9th/domain/member/entity/MemberPreferCategory.java index 6b7af01..cf0d576 100644 --- a/src/main/java/com/example/umc9th/domain/member/entity/MemberPreferCategory.java +++ b/src/main/java/com/example/umc9th/domain/member/entity/MemberPreferCategory.java @@ -1,6 +1,6 @@ package com.example.umc9th.domain.member.entity; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; diff --git a/src/main/java/com/example/umc9th/domain/member/entity/Notification.java b/src/main/java/com/example/umc9th/domain/member/entity/Notification.java index feca560..70ab415 100644 --- a/src/main/java/com/example/umc9th/domain/member/entity/Notification.java +++ b/src/main/java/com/example/umc9th/domain/member/entity/Notification.java @@ -1,7 +1,7 @@ package com.example.umc9th.domain.member.entity; import com.example.umc9th.domain.member.entity.enums.NotificationType; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/member/entity/PointLog.java b/src/main/java/com/example/umc9th/domain/member/entity/PointLog.java index 362dfe4..3e7b7a5 100644 --- a/src/main/java/com/example/umc9th/domain/member/entity/PointLog.java +++ b/src/main/java/com/example/umc9th/domain/member/entity/PointLog.java @@ -1,7 +1,7 @@ package com.example.umc9th.domain.member.entity; import com.example.umc9th.domain.member.entity.enums.PointType; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java b/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java index 41508c9..2020861 100644 --- a/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java @@ -2,6 +2,15 @@ import com.example.umc9th.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; public interface MemberRepository extends JpaRepository { + + /** + * 회원 ID를 기준으로 회원 정보를 조회 + * JPA 기본 메서드인 findById(id)와 완전히 동일하게 동작 (사실상 불필요한 코드) + * @param memberId 조회할 회원의 ID + * @return Optional + */ + Optional findMemberById(Long memberId); } diff --git a/src/main/java/com/example/umc9th/domain/member/service/MemberService.java b/src/main/java/com/example/umc9th/domain/member/service/MemberService.java index 38aa07a..dcd588e 100644 --- a/src/main/java/com/example/umc9th/domain/member/service/MemberService.java +++ b/src/main/java/com/example/umc9th/domain/member/service/MemberService.java @@ -1,7 +1,9 @@ package com.example.umc9th.domain.member.service; +import com.example.umc9th.domain.member.dto.MemberResponse; + public interface MemberService { // 회원 탈퇴 + 연관된 모든 데이터 삭제 - void deleteMemberAndAll(Long memberId); + MemberResponse.MemberDeleteResultDTO deleteMemberAndAll(Long memberId); } diff --git a/src/main/java/com/example/umc9th/domain/member/service/MemberServiceImpl.java b/src/main/java/com/example/umc9th/domain/member/service/MemberServiceImpl.java index 42cb11e..f96624f 100644 --- a/src/main/java/com/example/umc9th/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/example/umc9th/domain/member/service/MemberServiceImpl.java @@ -1,10 +1,10 @@ package com.example.umc9th.domain.member.service; -import com.example.umc9th.domain.inquiry.repository.InquiryRepository; +import com.example.umc9th.domain.member.dto.MemberResponse; import com.example.umc9th.domain.member.entity.Member; import com.example.umc9th.domain.member.repository.*; -import com.example.umc9th.domain.mission.repository.MissionByMemberRepository; -import com.example.umc9th.domain.review.repository.ReviewRepository; +import com.example.umc9th.global.apiPayload.code.status.ErrorStatus; +import com.example.umc9th.global.apiPayload.exception.handler.ErrorHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -17,36 +17,20 @@ public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; - private final MemberAgreeRepository memberAgreeRepository; - private final PointLogRepository pointLogRepository; - private final NotificationRepository notificationRepository; - private final MemberPreferCategoryRepository memberPreferCategoryRepository; - private final MissionByMemberRepository missionByMemberRepository; - private final ReviewRepository reviewRepository; - private final InquiryRepository inquiryRepository; @Transactional @Override - public void deleteMemberAndAll(Long memberId){ + public MemberResponse.MemberDeleteResultDTO deleteMemberAndAll(Long memberId){ - log.info("회원 탈퇴를 시작합니다. memberId: {}", memberId); // 1. 회원 조회 (없으면 예외 발생) Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("ID에 해당하는 회원을 찾을 수 없습니다: " + memberId)); + .orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND)); + log.info("회원 {}의 탈퇴 처리를 시작합니다.", member.getNickname()); - // 2. 연관된 자식 데이터들을 Batch Delete로 먼저 삭제 - log.info("회원 {}의 연관 데이터 삭제를 시작합니다.", member.getNickname()); - memberAgreeRepository.deleteAllByMember(member); - pointLogRepository.deleteAllByMember(member); - notificationRepository.deleteAllByMember(member); - memberPreferCategoryRepository.deleteAllByMember(member); - missionByMemberRepository.deleteAllByMember(member); - reviewRepository.deleteAllByMember(member); - inquiryRepository.deleteAllByMember(member); - log.info("회원 {}의 연관 데이터 삭제 완료.", member.getNickname()); - - // 3. 부모 엔티티인 Member 삭제 + // 2. Member 삭제 memberRepository.delete(member); log.info("회원 {}의 탈퇴 처리가 성공적으로 완료되었습니다.", member.getNickname()); + + return new MemberResponse.MemberDeleteResultDTO(memberId, "회원 탈퇴가 성공적으로 처리되었습니다."); } } diff --git a/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java b/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java new file mode 100644 index 0000000..1a97bdf --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java @@ -0,0 +1,35 @@ +package com.example.umc9th.domain.mission.controller; + +import com.example.umc9th.domain.mission.dto.MissionResponse; +import com.example.umc9th.domain.mission.entity.enums.MissionStatus; +import com.example.umc9th.domain.mission.service.MissionService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; + +@RestController +@RequestMapping("/api/missions") +@RequiredArgsConstructor +public class MissionController { + + private final MissionService missionService; + + @GetMapping("/my") + @Operation(summary = "나의 미션 목록 조회 API", description = "진행중 또는 완료된 미션 목록을 커서 기반으로 조회합니다.") + public ApiResponse getMyMissions( + @RequestParam MissionStatus status, + @RequestParam(required = false) LocalDateTime cursorDeadline, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "10") int size + ) { + MissionResponse.MyMissionListDTO response = missionService.getMyMissions(1L, status, cursorDeadline, cursorId, size); + return ApiResponse.onSuccess(response); + } + +} diff --git a/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java b/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java new file mode 100644 index 0000000..b5267d7 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java @@ -0,0 +1,53 @@ +package com.example.umc9th.domain.mission.converter; + +import com.example.umc9th.domain.mission.dto.MissionResponse; +import com.example.umc9th.domain.mission.entity.Mission; +import com.example.umc9th.domain.mission.entity.MissionByMember; +import org.springframework.data.domain.Slice; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public class MissionConverter { + + public static MissionResponse.MyMissionDTO toMyMissionDTO(MissionByMember missionByMember) { + Mission mission = missionByMember.getMission(); + return MissionResponse.MyMissionDTO.builder() + .storeName(mission.getStore().getName()) + .missionContent(mission.getContent()) + .targetAmount(mission.getTargetAmount()) + .rewardPoint(mission.getRewardPoint()) + .deadline(mission.getDeadline()) + .build(); + } + + public static MissionResponse.MyMissionListDTO toMyMissionListDTO(Slice missionByMemberSlice) { + + // 각 MissionByMember 엔터티 -> MyMissionDTO로 변환 -> list로 + List missionDTOList = missionByMemberSlice.getContent().stream() + .map(MissionConverter::toMyMissionDTO) + .collect(Collectors.toList()); + + // 다음 페이지 조회를 위해 커서 값들을 초기화 + LocalDateTime nextCursorDeadline = null; + Long nextCursorId = null; + + // 현재 페이지의 미션 목록이 비어있지 않다면, 다음 페이지를 조회할 커서 설정 + if (!missionDTOList.isEmpty()) { + // 현재 페이지의 마지막 미션 엔티티 + Mission lastMission = missionByMemberSlice.getContent().get(missionByMemberSlice.getContent().size() - 1).getMission(); + + // 마지막 미션의 마감 시간과 ID를 다음 커서 값으로 설정 + nextCursorDeadline = lastMission.getDeadline(); + nextCursorId = lastMission.getId(); + } + + return MissionResponse.MyMissionListDTO.builder() + .missionList(missionDTOList) + .hasNext(missionByMemberSlice.hasNext()) + .nextCursorDeadline(nextCursorDeadline) + .nextCursorId(nextCursorId) + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponse.java b/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponse.java new file mode 100644 index 0000000..a11c7fd --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponse.java @@ -0,0 +1,39 @@ +package com.example.umc9th.domain.mission.dto; + +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 MissionResponse { + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "나의 미션 목록의 개별 미션 응답") + public static class MyMissionDTO { + + private String storeName; + private String missionContent; + private Integer rewardPoint; + private Integer targetAmount; + private LocalDateTime deadline; + } + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "나의 미션 목록 조회 응답") + public static class MyMissionListDTO { + + private List missionList; + private Boolean hasNext; + private LocalDateTime nextCursorDeadline; + private Long nextCursorId; + } +} diff --git a/src/main/java/com/example/umc9th/domain/mission/entity/Mission.java b/src/main/java/com/example/umc9th/domain/mission/entity/Mission.java index 2accbd8..d76e22c 100644 --- a/src/main/java/com/example/umc9th/domain/mission/entity/Mission.java +++ b/src/main/java/com/example/umc9th/domain/mission/entity/Mission.java @@ -1,7 +1,7 @@ package com.example.umc9th.domain.mission.entity; import com.example.umc9th.domain.store.entity.Store; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; diff --git a/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java b/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java index a6837b2..758df05 100644 --- a/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java +++ b/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java @@ -2,7 +2,7 @@ import com.example.umc9th.domain.member.entity.Member; import com.example.umc9th.domain.mission.entity.enums.MissionStatus; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.ColumnDefault; diff --git a/src/main/java/com/example/umc9th/domain/mission/repository/MissionByMemberRepository.java b/src/main/java/com/example/umc9th/domain/mission/repository/MissionByMemberRepository.java index 8225bf5..9850ae5 100644 --- a/src/main/java/com/example/umc9th/domain/mission/repository/MissionByMemberRepository.java +++ b/src/main/java/com/example/umc9th/domain/mission/repository/MissionByMemberRepository.java @@ -2,15 +2,45 @@ import com.example.umc9th.domain.member.entity.Member; import com.example.umc9th.domain.mission.entity.MissionByMember; +import com.example.umc9th.domain.mission.entity.enums.MissionStatus; +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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; +import java.util.List; + public interface MissionByMemberRepository extends JpaRepository { @Modifying(clearAutomatically = true) @Query("DELETE FROM MissionByMember mbm WHERE mbm.member = :member") void deleteAllByMember(@Param("member") Member member); + /** + * 내가 진행중, 진행완료한 미션 모아보는 쿼리 + * @param memberId 현재 사용자의 ID + * @param status 조회할 미션 상태 + * @param cursorDeadline 마지막으로 조회된 미션의 마감일 (첫 페이지는 null) + * @param cursorId 마지막으로 조회된 미션의 ID (첫 페이지는 null) + * @param pageable LIMIT 절과 페이징 처리를 위한 정보 + * @return Slice + */ + @Query("SELECT mbm FROM MissionByMember mbm " + + "JOIN mbm.mission m " + + "WHERE mbm.member.id = :memberId " + + " AND mbm.status = :status " + + " AND m.deadline >= CURRENT_TIMESTAMP " + + " AND (:cursorDeadline IS NULL OR m.deadline > :cursorDeadline OR (m.deadline = :cursorDeadline AND m.id < :cursorId)) " + + "ORDER BY m.deadline ASC, m.id DESC") + Slice findMyMissionsWithCompoundCursor( + @Param("memberId") Long memberId, + @Param("status") MissionStatus status, + @Param("cursorDeadline") LocalDateTime cursorDeadline, + @Param("cursorId") Long cursorId, + Pageable pageable + ); } diff --git a/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java b/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java new file mode 100644 index 0000000..de450fd --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java @@ -0,0 +1,31 @@ +package com.example.umc9th.domain.mission.repository; + +import com.example.umc9th.domain.mission.entity.Mission; +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 MissionRepository extends JpaRepository { + + /** + * 특정 지역에서 사용자가 아직 도전하지 않은 미션 목록을 커서 기반으로 조회 + * @param regionId 조회할 지역의 ID + * @param memberId 현재 사용자의 ID (이미 시작한 미션을 필터링하기 위해) + * @param cursorId 마지막으로 조회된 미션의 ID (첫 페이지는 null) + * @param pageable LIMIT 절과 페이징 처리를 위한 정보 + * @return Slice + */ + @Query("SELECT m FROM Mission m " + + "JOIN m.store s " + + "WHERE s.region.id = :regionId " + + " AND (:cursorId IS NULL OR m.id < :cursorId) " + + " AND NOT EXISTS (SELECT 1 FROM MissionByMember mbm WHERE mbm.member.id = :memberId AND mbm.mission = m) " + + "ORDER BY m.id DESC") + Slice findAvailableMissionsByRegion( + @Param("regionId") Long regionId, + @Param("memberId") Long memberId, + @Param("cursorId") Long cursorId, + Pageable pageable); +} diff --git a/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java b/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java new file mode 100644 index 0000000..fecece0 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java @@ -0,0 +1,13 @@ +package com.example.umc9th.domain.mission.service; + +import com.example.umc9th.domain.mission.dto.MissionResponse; +import com.example.umc9th.domain.mission.entity.enums.MissionStatus; + +import java.time.LocalDateTime; + +public interface MissionService { + + // 나의 미션 목록 조회 + MissionResponse.MyMissionListDTO getMyMissions(Long memberId, MissionStatus status, LocalDateTime cursorDeadline, Long cursorId, int size); + +} diff --git a/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java b/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java new file mode 100644 index 0000000..d831752 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java @@ -0,0 +1,38 @@ +package com.example.umc9th.domain.mission.service; + +import com.example.umc9th.domain.mission.converter.MissionConverter; +import com.example.umc9th.domain.mission.dto.MissionResponse; +import com.example.umc9th.domain.mission.entity.MissionByMember; +import com.example.umc9th.domain.mission.entity.enums.MissionStatus; +import com.example.umc9th.domain.mission.repository.MissionByMemberRepository; +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.time.LocalDateTime; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MissionServiceImpl implements MissionService { + + private final MissionByMemberRepository missionByMemberRepository; + + @Override + public MissionResponse.MyMissionListDTO getMyMissions(Long memberId, MissionStatus status, LocalDateTime cursorDeadline, Long cursorId, int size) { + + // 1. Pageable 객체 생성 (커서 기반이므로 페이지 번호는 항상 0) + PageRequest pageRequest = PageRequest.of(0, size); + + // 2. Repository로 페이징된 데이터 조회 + Slice missionSlice = missionByMemberRepository.findMyMissionsWithCompoundCursor( + memberId, status, cursorDeadline, cursorId, pageRequest + ); + + // 3. 응답 DTO로 변환 + return MissionConverter.toMyMissionListDTO(missionSlice); + } + +} diff --git a/src/main/java/com/example/umc9th/domain/region/entity/Region.java b/src/main/java/com/example/umc9th/domain/region/entity/Region.java new file mode 100644 index 0000000..b9ab114 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/region/entity/Region.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.region.entity; + +import com.example.umc9th.global.apiPayload.code.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Region extends BaseEntity { + + @Column(nullable = false, length = 20) + private String name; + +} diff --git a/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java b/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java new file mode 100644 index 0000000..ec1045b --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java @@ -0,0 +1,25 @@ +package com.example.umc9th.domain.review.controller; + +import com.example.umc9th.domain.review.dto.ReviewRequest; +import com.example.umc9th.domain.review.dto.ReviewResponse; +import com.example.umc9th.domain.review.service.ReviewService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/reviews") +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping + public ApiResponse createReview( + @RequestBody ReviewRequest.ReviewCreateDTO request + ){ + ReviewResponse.ReviewResultDTO response = reviewService.createReview(1L, request.getStoreId(), request); + return ApiResponse.onSuccess(response); + } + +} diff --git a/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java b/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java new file mode 100644 index 0000000..e40d2ee --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java @@ -0,0 +1,27 @@ +package com.example.umc9th.domain.review.converter; + +import com.example.umc9th.domain.member.entity.Member; +import com.example.umc9th.domain.review.dto.ReviewRequest; +import com.example.umc9th.domain.review.dto.ReviewResponse; +import com.example.umc9th.domain.review.entity.Review; +import com.example.umc9th.domain.store.entity.Store; + +public class ReviewConverter { + + // ReviewCreateDTO -> Review 엔터티 + public static Review toReview (ReviewRequest.ReviewCreateDTO dto, Member member, Store store) { + return Review.builder() + .content(dto.getContent()) + .rate(dto.getRate()) + .member(member) + .store(store) + .build(); + } + + // Review 엔터티 -> ReviewResultDTO + public static ReviewResponse.ReviewResultDTO toReviewResultDTO (Review review) { + return ReviewResponse.ReviewResultDTO.builder() + .reviewId(review.getId()) + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/domain/review/dto/ReviewRequest.java b/src/main/java/com/example/umc9th/domain/review/dto/ReviewRequest.java new file mode 100644 index 0000000..b9ec0e8 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/dto/ReviewRequest.java @@ -0,0 +1,16 @@ +package com.example.umc9th.domain.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +public class ReviewRequest { + + @Getter + @Schema(description = "리뷰 등록 요청 정보") + public static class ReviewCreateDTO { + + private Long storeId; + private String content; + private Float rate; + } +} diff --git a/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponse.java b/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponse.java new file mode 100644 index 0000000..0d2f5c6 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponse.java @@ -0,0 +1,17 @@ +package com.example.umc9th.domain.review.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +public class ReviewResponse { + + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "리뷰 등록 응답 정보") + public static class ReviewResultDTO { + private Long reviewId; + } +} diff --git a/src/main/java/com/example/umc9th/domain/review/entity/OwnerComment.java b/src/main/java/com/example/umc9th/domain/review/entity/OwnerComment.java index 31e7d98..d22a8f9 100644 --- a/src/main/java/com/example/umc9th/domain/review/entity/OwnerComment.java +++ b/src/main/java/com/example/umc9th/domain/review/entity/OwnerComment.java @@ -1,6 +1,6 @@ package com.example.umc9th.domain.review.entity; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/review/entity/Review.java b/src/main/java/com/example/umc9th/domain/review/entity/Review.java index e412098..148b489 100644 --- a/src/main/java/com/example/umc9th/domain/review/entity/Review.java +++ b/src/main/java/com/example/umc9th/domain/review/entity/Review.java @@ -2,7 +2,7 @@ import com.example.umc9th.domain.member.entity.Member; import com.example.umc9th.domain.store.entity.Store; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/review/entity/ReviewImage.java b/src/main/java/com/example/umc9th/domain/review/entity/ReviewImage.java index 07f891a..8ac897d 100644 --- a/src/main/java/com/example/umc9th/domain/review/entity/ReviewImage.java +++ b/src/main/java/com/example/umc9th/domain/review/entity/ReviewImage.java @@ -1,6 +1,6 @@ package com.example.umc9th.domain.review.entity; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java index 824430c..cde7845 100644 --- a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java @@ -13,4 +13,19 @@ public interface ReviewRepository extends JpaRepository { @Query("DELETE FROM Review r WHERE r.member = :member") void deleteAllByMember(@Param("member") Member member); + /** + * 리뷰 작성하는 쿼리 - 메소드 생성 방식 사용 + * @param memberId 리뷰 작성자 ID + * @param storeId 리뷰 대상 가게 ID + * @param content 리뷰 내용 + * @param rate 별점 + */ + @Modifying // insert시에는 clearAutomatically가 불필요 - 영속성 컨텍스트와 데이터 불일치 발생 x + @Query(value = "INSERT INTO review (member_id, store_id, content, rate, created_at, updated_at) " + + "VALUES (:memberId, :storeId, :content, :rate, NOW(), NOW())", + nativeQuery = true) + void createReview(@Param("memberId") Long memberId, + @Param("storeId") Long storeId, + @Param("content") String content, + @Param("rate") Float rate); } diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java new file mode 100644 index 0000000..77a9999 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java @@ -0,0 +1,9 @@ +package com.example.umc9th.domain.review.service; + +import com.example.umc9th.domain.review.dto.ReviewRequest; +import com.example.umc9th.domain.review.dto.ReviewResponse; + +public interface ReviewService { + + ReviewResponse.ReviewResultDTO createReview(Long memberId, Long storeId, ReviewRequest.ReviewCreateDTO request); +} diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java new file mode 100644 index 0000000..51a0bb9 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java @@ -0,0 +1,51 @@ +package com.example.umc9th.domain.review.service; + +import com.example.umc9th.domain.member.entity.Member; +import com.example.umc9th.domain.member.repository.MemberRepository; +import com.example.umc9th.domain.review.converter.ReviewConverter; +import com.example.umc9th.domain.review.dto.ReviewRequest; +import com.example.umc9th.domain.review.dto.ReviewResponse; +import com.example.umc9th.domain.review.entity.Review; +import com.example.umc9th.domain.review.repository.ReviewRepository; +import com.example.umc9th.domain.store.entity.Store; +import com.example.umc9th.domain.store.repository.StoreRepository; +import com.example.umc9th.global.apiPayload.code.status.ErrorStatus; +import com.example.umc9th.global.apiPayload.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ReviewServiceImpl implements ReviewService { + + private final MemberRepository memberRepository; + private final StoreRepository storeRepository; + private final ReviewRepository reviewRepository; + + @Transactional + @Override + public ReviewResponse.ReviewResultDTO createReview(Long memberId, Long storeId, ReviewRequest.ReviewCreateDTO request){ + + // 1. 회원 엔티티 조회 + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND)); + + // 2. 가게 엔티티 조회 + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.STORE_NOT_FOUND)); + + // 3. DTO를 엔티티로 변환 + Review review = ReviewConverter.toReview(request, member, store); + + // 4. DB에 저장 (INSERT) + reviewRepository.save(review); + + // 5. 응답 DTO로 변환하여 반환 + return ReviewConverter.toReviewResultDTO(review); + } + +} diff --git a/src/main/java/com/example/umc9th/domain/store/entity/Store.java b/src/main/java/com/example/umc9th/domain/store/entity/Store.java index 8653bdd..22c5b95 100644 --- a/src/main/java/com/example/umc9th/domain/store/entity/Store.java +++ b/src/main/java/com/example/umc9th/domain/store/entity/Store.java @@ -1,7 +1,8 @@ package com.example.umc9th.domain.store.entity; import com.example.umc9th.domain.member.entity.FoodCategory; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.domain.region.entity.Region; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.ColumnDefault; @@ -46,6 +47,10 @@ public class Store extends BaseEntity { private Double longitude; // 경도 // 연관관계 매핑 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_id") + private Region region; + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List storeImgList = new ArrayList<>(); diff --git a/src/main/java/com/example/umc9th/domain/store/entity/StoreImage.java b/src/main/java/com/example/umc9th/domain/store/entity/StoreImage.java index 6e4c390..61ae011 100644 --- a/src/main/java/com/example/umc9th/domain/store/entity/StoreImage.java +++ b/src/main/java/com/example/umc9th/domain/store/entity/StoreImage.java @@ -1,6 +1,6 @@ package com.example.umc9th.domain.store.entity; -import com.example.umc9th.global.common.BaseEntity; +import com.example.umc9th.global.apiPayload.code.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/umc9th/domain/store/repository/StoreRepository.java b/src/main/java/com/example/umc9th/domain/store/repository/StoreRepository.java new file mode 100644 index 0000000..d443ec3 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/store/repository/StoreRepository.java @@ -0,0 +1,8 @@ +package com.example.umc9th.domain.store.repository; + +import com.example.umc9th.domain.store.entity.Store; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; + +public interface StoreRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java b/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java new file mode 100644 index 0000000..ee7183d --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java @@ -0,0 +1,47 @@ +package com.example.umc9th.global.apiPayload; + +import com.example.umc9th.global.apiPayload.code.BaseCode; +import com.example.umc9th.global.apiPayload.code.status.SuccessStatus; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +@Schema(description = "공통 응답 포맷") +public class ApiResponse { + + @JsonProperty("isSuccess") + @Schema(description = "성공 여부", example = "true") + private final Boolean isSuccess; + + @Schema(description = "상태 코드", example = "COMMON200") + private final String code; + + @Schema(description = "상태 메시지", example = "성공입니다.") + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "결과 데이터") + private T result; + + // 성공한 경우 응답 생성 + public static ApiResponse onSuccess(T result){ + return new ApiResponse<>(true, SuccessStatus._OK.getCode() , SuccessStatus._OK.getMessage(), result); + } + + public static ApiResponse of(BaseCode code, T result){ + return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result); + } + + + // 실패한 경우 응답 생성 + public static ApiResponse onFailure(String code, String message, T data){ + return new ApiResponse<>(false, code, message, data); + } +} + diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/BaseCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseCode.java new file mode 100644 index 0000000..4c89cf1 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseCode.java @@ -0,0 +1,9 @@ +package com.example.umc9th.global.apiPayload.code; + +public interface BaseCode { + + ReasonDTO getReason(); + + ReasonDTO getReasonHttpStatus(); +} + diff --git a/src/main/java/com/example/umc9th/global/common/BaseEntity.java b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseEntity.java similarity index 93% rename from src/main/java/com/example/umc9th/global/common/BaseEntity.java rename to src/main/java/com/example/umc9th/global/apiPayload/code/BaseEntity.java index 049d6d9..2355c78 100644 --- a/src/main/java/com/example/umc9th/global/common/BaseEntity.java +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseEntity.java @@ -1,4 +1,4 @@ -package com.example.umc9th.global.common; +package com.example.umc9th.global.apiPayload.code; import jakarta.persistence.*; import lombok.AccessLevel; diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/BaseErrorCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseErrorCode.java new file mode 100644 index 0000000..fbe60b3 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,8 @@ +package com.example.umc9th.global.apiPayload.code; + +public interface BaseErrorCode { + + ErrorReasonDTO getReason(); + + ErrorReasonDTO getReasonHttpStatus(); +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/ErrorReasonDTO.java b/src/main/java/com/example/umc9th/global/apiPayload/code/ErrorReasonDTO.java new file mode 100644 index 0000000..5f829ad --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/ErrorReasonDTO.java @@ -0,0 +1,18 @@ +package com.example.umc9th.global.apiPayload.code; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ErrorReasonDTO { + + private HttpStatus httpStatus; + + private final boolean isSuccess; + private final String code; + private final String message; + + public boolean getIsSuccess(){return isSuccess;} +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/ReasonDTO.java b/src/main/java/com/example/umc9th/global/apiPayload/code/ReasonDTO.java new file mode 100644 index 0000000..ec939ee --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/ReasonDTO.java @@ -0,0 +1,18 @@ +package com.example.umc9th.global.apiPayload.code; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ReasonDTO { + + private HttpStatus httpStatus; + + private final boolean isSuccess; + private final String code; + private final String message; + + public boolean getIsSuccess(){return isSuccess;} +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/example/umc9th/global/apiPayload/code/status/ErrorStatus.java new file mode 100644 index 0000000..c55e12e --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/status/ErrorStatus.java @@ -0,0 +1,47 @@ +package com.example.umc9th.global.apiPayload.code.status; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseErrorCode { + + // 가장 일반적인 응답 + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST,"COMMON400","잘못된 요청입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"COMMON401","인증이 필요합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + + // 회원 관련 에러 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4041", "존재하지 않는 회원입니다."), + + // 가게 관련 에러 + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4041", "존재하지 않는 가게 입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/example/umc9th/global/apiPayload/code/status/SuccessStatus.java new file mode 100644 index 0000000..fa11e88 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/status/SuccessStatus.java @@ -0,0 +1,38 @@ +package com.example.umc9th.global.apiPayload.code.status; + +import com.example.umc9th.global.apiPayload.code.BaseCode; +import com.example.umc9th.global.apiPayload.code.ReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessStatus implements BaseCode { + + // HttpStatus, code, message + _OK(HttpStatus.OK, "COMMON200", "성공입니다."); + + private final HttpStatus httpStatus; // http 상태 코드 + private final String code; // 상태 코드 설명 + private final String message; // 메시지 + + @Override + public ReasonDTO getReason() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .build(); + } + + @Override + public ReasonDTO getReasonHttpStatus() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/exception/ExceptionAdvice.java b/src/main/java/com/example/umc9th/global/apiPayload/exception/ExceptionAdvice.java new file mode 100644 index 0000000..c37f443 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/exception/ExceptionAdvice.java @@ -0,0 +1,121 @@ +package com.example.umc9th.global.apiPayload.exception; + + +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.ErrorReasonDTO; +import com.example.umc9th.global.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@RequiredArgsConstructor +@Slf4j +@RestControllerAdvice(annotations = {RestController.class}) +public class ExceptionAdvice extends ResponseEntityExceptionHandler { + + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + String errorMessage = e.getConstraintViolations().stream() + .map(constraintViolation -> constraintViolation.getMessage()) + .findFirst() + .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생")); + + return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY,request); + } + + @Override + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + + Map errors = new LinkedHashMap<>(); + + e.getBindingResult().getFieldErrors().stream() + .forEach(fieldError -> { + String fieldName = fieldError.getField(); + String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); + errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); + }); + + return handleExceptionInternalArgs(e,HttpHeaders.EMPTY,ErrorStatus.valueOf("_BAD_REQUEST"),request,errors); + } + + @ExceptionHandler + public ResponseEntity exception(Exception e, WebRequest request) { + e.printStackTrace(); + + return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(),request, e.getMessage()); + } + + @ExceptionHandler(value = GeneralException.class) + public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) { + ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); + return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request); + } + + private ResponseEntity handleExceptionInternal(Exception e, ErrorReasonDTO reason, + HttpHeaders headers, HttpServletRequest request) { + + ApiResponse body = ApiResponse.onFailure(reason.getCode(),reason.getMessage(),null); +// e.printStackTrace(); + + WebRequest webRequest = new ServletWebRequest(request); + return super.handleExceptionInternal( + e, + body, + headers, + reason.getHttpStatus(), + webRequest + ); + } + + private ResponseEntity handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus, + HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorPoint); + return super.handleExceptionInternal( + e, + body, + headers, + status, + request + ); + } + + private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus, + WebRequest request, Map errorArgs) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs); + return super.handleExceptionInternal( + e, + body, + headers, + errorCommonStatus.getHttpStatus(), + request + ); + } + + private ResponseEntity handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus, + HttpHeaders headers, WebRequest request) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null); + return super.handleExceptionInternal( + e, + body, + headers, + errorCommonStatus.getHttpStatus(), + request + ); + } +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/exception/GeneralException.java b/src/main/java/com/example/umc9th/global/apiPayload/exception/GeneralException.java new file mode 100644 index 0000000..62f02af --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/exception/GeneralException.java @@ -0,0 +1,21 @@ +package com.example.umc9th.global.apiPayload.exception; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + + private BaseErrorCode code; + + public ErrorReasonDTO getErrorReason() { + return this.code.getReason(); + } + + public ErrorReasonDTO getErrorReasonHttpStatus(){ + return this.code.getReasonHttpStatus(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/exception/handler/ErrorHandler.java b/src/main/java/com/example/umc9th/global/apiPayload/exception/handler/ErrorHandler.java new file mode 100644 index 0000000..9453a13 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/exception/handler/ErrorHandler.java @@ -0,0 +1,10 @@ +package com.example.umc9th.global.apiPayload.exception.handler; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; + +public class ErrorHandler extends GeneralException { + public ErrorHandler(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/umc9th/global/config/SwaggerConfig.java b/src/main/java/com/example/umc9th/global/config/SwaggerConfig.java new file mode 100644 index 0000000..3bd4969 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/SwaggerConfig.java @@ -0,0 +1,44 @@ +package com.example.umc9th.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + + // API 기본 정보 + Info info = new Info() + .title("UMC 9th API") + .description("UMC 9th 워크북 API 명세서") + .version("1.0.0"); + + // JWT 인증 방식을 설정 + String jwtSchemeName = "JWT TOKEN"; + + // API 요청헤더에 인증정보 포함 + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + // SecuritySchemes 등록 + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) // HTTP 방식 + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(info) + .addSecurityItem(securityRequirement) + .components(components); + } +}