diff --git a/.gitignore b/.gitignore index c2065bc..dc01f20 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +application.yml ### STS ### .apt_generated diff --git a/build.gradle b/build.gradle index ee064e0..88ba49c 100644 --- a/build.gradle +++ b/build.gradle @@ -32,8 +32,50 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // QueryDSL : OpenFeign + implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0" + implementation "io.github.openfeign.querydsl:querydsl-core:7.0" + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.13' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' } tasks.named('test') { useJUnitPlatform() } + +// QueryDSL 관련 설정 +// generated/querydsl 폴더 생성 & 삽입 +def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile + +// 소스 세트에 생성 경로 추가 (구체적인 경로 지정) +sourceSets { + main.java.srcDirs += [ querydslDir ] +} + +// 컴파일 시 생성 경로 지정 +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory.set(querydslDir) +} + +// clean 태스크에 생성 폴더 삭제 로직 추가 +clean.doLast { + file(querydslDir).deleteDir() +} 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..fd3c03c --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java @@ -0,0 +1,84 @@ +package com.example.umc9th.domain.mission.controller; + +import com.example.umc9th.domain.mission.converter.MissionConverter; +import com.example.umc9th.domain.mission.dto.MissionRequestDTO; +import com.example.umc9th.domain.mission.dto.MissionResponseDTO; +import com.example.umc9th.domain.mission.entity.Mission; +import com.example.umc9th.domain.mission.service.MissionService; +import com.example.umc9th.domain.user.entity.UserMission; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; +import com.example.umc9th.global.validation.annotation.CheckPage; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@Validated +public class MissionController { + + private final MissionService missionService; + + @PostMapping("/restaurants/{restaurantId}/missions") + @Operation(summary = "가게에 미션 추가 API", description = "특정 가게에 미션을 추가하는 API입니다.") + public ApiResponse addMission(@PathVariable Long restaurantId, + @RequestBody @Valid MissionRequestDTO.AddMissionDTO request) { + Mission mission = missionService.addMission(restaurantId, request); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, MissionConverter.toAddResultDTO(mission)); + } + + @PostMapping("/missions/{missionId}/challenge") + @Operation(summary = "미션 도전하기 API", description = "미션을 도전하는 API입니다.") + public ApiResponse challengeMission(@PathVariable Long missionId, + @RequestBody @Valid MissionRequestDTO.ChallengeMissionDTO request) { + UserMission userMission = missionService.challengeMission(missionId, request); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, MissionConverter.toChallengeResultDTO(userMission)); + } + + @GetMapping("/restaurants/{restaurantId}/missions") + @Operation(summary = "특정 가게의 미션 목록 조회 API", description = "특정 가게의 미션 목록을 조회하는 API이며, 페이징을 포함합니다. query String으로 page 번호를 주세요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + @Parameters({ + @Parameter(name = "restaurantId", description = "가게의 아이디, path variable 입니다!"), + @Parameter(name = "page", description = "페이지 번호, 1번이 1 페이지 입니다."), + }) + public ApiResponse> getRestaurantMissions(@PathVariable Long restaurantId, + @CheckPage @RequestParam(name = "page") Integer page) { + Page response = missionService.getRestaurantMissions(restaurantId, page - 1); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, response); + } + + @GetMapping("/restaurants/{restaurantId}/missions/slice") + @Operation(summary = "특정 가게의 미션 목록 조회 API (Slice 버전)", description = "특정 가게의 미션 목록을 Slice 방식으로 조회하는 API입니다. query String으로 page 번호를 주세요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + @Parameters({ + @Parameter(name = "restaurantId", description = "가게의 아이디, path variable 입니다!"), + @Parameter(name = "page", description = "페이지 번호, 1번이 1 페이지 입니다."), + }) + public ApiResponse> getRestaurantMissionsSlice(@PathVariable Long restaurantId, + @CheckPage @RequestParam(name = "page") Integer page) { + Slice response = missionService.getRestaurantMissionsBySlice(restaurantId, page - 1); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, 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..31317a5 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java @@ -0,0 +1,55 @@ +package com.example.umc9th.domain.mission.converter; + +import com.example.umc9th.domain.mission.dto.MissionRequestDTO; +import com.example.umc9th.domain.mission.dto.MissionResponseDTO; +import com.example.umc9th.domain.mission.entity.Mission; +import com.example.umc9th.domain.restaurant.entity.Restaurant; +import com.example.umc9th.domain.user.entity.User; +import com.example.umc9th.domain.user.entity.UserMission; +import com.example.umc9th.domain.user.enums.MissionStatus; + +import java.time.LocalDateTime; + +public class MissionConverter { + + public static MissionResponseDTO.AddResultDTO toAddResultDTO(Mission mission) { + return MissionResponseDTO.AddResultDTO.builder() + .missionId(mission.getId()) + .createdAt(LocalDateTime.now()) + .build(); + } + + public static MissionResponseDTO.ChallengeResultDTO toChallengeResultDTO(UserMission userMission) { + return MissionResponseDTO.ChallengeResultDTO.builder() + .userMissionId(userMission.getId()) + .createdAt(LocalDateTime.now()) + .build(); + } + + public static Mission toMission(MissionRequestDTO.AddMissionDTO request, Restaurant restaurant) { + return Mission.builder() + .restaurant(restaurant) + .reward(request.getReward()) + .deadline(request.getDeadline()) + .goal(request.getGoal()) + .build(); + } + + public static UserMission toUserMission(Mission mission, User user) { + return UserMission.builder() + .mission(mission) + .user(user) + .status(MissionStatus.CHALLENGING) + .build(); + } + + public static MissionResponseDTO.MissionDTO toMissionDTO(Mission mission) { + return MissionResponseDTO.MissionDTO.builder() + .id(mission.getId()) + .reward(mission.getReward()) + .deadline(mission.getDeadline()) + .goal(mission.getGoal()) + .build(); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/mission/dto/MissionRequestDTO.java b/src/main/java/com/example/umc9th/domain/mission/dto/MissionRequestDTO.java new file mode 100644 index 0000000..5008a68 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/dto/MissionRequestDTO.java @@ -0,0 +1,28 @@ +package com.example.umc9th.domain.mission.dto; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +import java.time.LocalDateTime; + +public class MissionRequestDTO { + + @Getter + public static class AddMissionDTO { + @NotNull + private Integer reward; + @NotNull + private Integer goal; + @NotNull + @Future + private LocalDateTime deadline; + } + + @Getter + public static class ChallengeMissionDTO { + @NotNull + private Long userId; + } +} + diff --git a/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponseDTO.java b/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponseDTO.java new file mode 100644 index 0000000..97eb654 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponseDTO.java @@ -0,0 +1,41 @@ +package com.example.umc9th.domain.mission.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +public class MissionResponseDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class AddResultDTO { + private Long missionId; + private LocalDateTime createdAt; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ChallengeResultDTO { + private Long userMissionId; + private LocalDateTime createdAt; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class MissionDTO { + private Long id; + private Integer reward; + private LocalDateTime deadline; + private Integer goal; + } +} + 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 index 9987818..3f0bebf 100644 --- a/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java +++ b/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java @@ -1,8 +1,13 @@ package com.example.umc9th.domain.mission.repository; import com.example.umc9th.domain.mission.entity.Mission; +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; public interface MissionRepository extends JpaRepository { + Page findAllByRestaurantId(Long restaurantId, Pageable pageable); + Slice findSliceByRestaurantId(Long restaurantId, 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..128d43b --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java @@ -0,0 +1,16 @@ +package com.example.umc9th.domain.mission.service; + +import com.example.umc9th.domain.mission.dto.MissionRequestDTO; +import com.example.umc9th.domain.mission.dto.MissionResponseDTO; +import com.example.umc9th.domain.mission.entity.Mission; +import com.example.umc9th.domain.user.entity.UserMission; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +public interface MissionService { + Mission addMission(Long restaurantId, MissionRequestDTO.AddMissionDTO request); + UserMission challengeMission(Long missionId, MissionRequestDTO.ChallengeMissionDTO request); + Page getRestaurantMissions(Long restaurantId, Integer page); + Slice getRestaurantMissionsBySlice(Long restaurantId, Integer page); +} + 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..eececad --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java @@ -0,0 +1,78 @@ +package com.example.umc9th.domain.mission.service; + +import com.example.umc9th.domain.mission.converter.MissionConverter; +import com.example.umc9th.domain.mission.dto.MissionRequestDTO; +import com.example.umc9th.domain.mission.dto.MissionResponseDTO; +import com.example.umc9th.domain.mission.entity.Mission; +import com.example.umc9th.domain.mission.repository.MissionRepository; +import com.example.umc9th.domain.restaurant.entity.Restaurant; +import com.example.umc9th.domain.restaurant.repository.RestaurantRepository; +import com.example.umc9th.domain.user.entity.User; +import com.example.umc9th.domain.user.entity.UserMission; +import com.example.umc9th.domain.user.repository.UserMissionRepository; +import com.example.umc9th.domain.user.repository.UserRepository; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +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; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MissionServiceImpl implements MissionService { + + private final MissionRepository missionRepository; + private final RestaurantRepository restaurantRepository; + private final UserRepository userRepository; + private final UserMissionRepository userMissionRepository; + + @Override + @Transactional + public Mission addMission(Long restaurantId, MissionRequestDTO.AddMissionDTO request) { + Restaurant restaurant = restaurantRepository.findById(restaurantId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.RESTAURANT_NOT_FOUND)); + + Mission mission = MissionConverter.toMission(request, restaurant); + + return missionRepository.save(mission); + } + + @Override + @Transactional + public UserMission challengeMission(Long missionId, MissionRequestDTO.ChallengeMissionDTO request) { + Mission mission = missionRepository.findById(missionId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.MISSION_NOT_FOUND)); + + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + + // 이미 도전 중인지 체크하는 로직이 있으면 좋겠지만 생략 + + UserMission userMission = MissionConverter.toUserMission(mission, user); + + return userMissionRepository.save(userMission); + } + + @Override + public Page getRestaurantMissions(Long restaurantId, Integer page) { + Restaurant restaurant = restaurantRepository.findById(restaurantId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.RESTAURANT_NOT_FOUND)); + + Page missionPage = missionRepository.findAllByRestaurantId(restaurantId, PageRequest.of(page, 10)); + return missionPage.map(MissionConverter::toMissionDTO); + } + + @Override + public Slice getRestaurantMissionsBySlice(Long restaurantId, Integer page) { + Restaurant restaurant = restaurantRepository.findById(restaurantId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.RESTAURANT_NOT_FOUND)); + + Slice missionSlice = missionRepository.findSliceByRestaurantId(restaurantId, PageRequest.of(page, 10)); + return missionSlice.map(MissionConverter::toMissionDTO); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/restaurant/controller/RestaurantController.java b/src/main/java/com/example/umc9th/domain/restaurant/controller/RestaurantController.java new file mode 100644 index 0000000..132f299 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/restaurant/controller/RestaurantController.java @@ -0,0 +1,28 @@ +package com.example.umc9th.domain.restaurant.controller; + +import com.example.umc9th.domain.restaurant.converter.RestaurantConverter; +import com.example.umc9th.domain.restaurant.dto.RestaurantRequestDTO; +import com.example.umc9th.domain.restaurant.dto.RestaurantResponseDTO; +import com.example.umc9th.domain.restaurant.entity.Restaurant; +import com.example.umc9th.domain.restaurant.service.RestaurantService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/regions") +public class RestaurantController { + + private final RestaurantService restaurantService; + + @PostMapping("/{regionId}/restaurants") + public ApiResponse addRestaurant(@PathVariable Long regionId, + @RequestBody @Valid RestaurantRequestDTO.AddRestaurantDTO request) { + Restaurant restaurant = restaurantService.addRestaurant(regionId, request); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, RestaurantConverter.toAddResultDTO(restaurant)); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/restaurant/converter/RestaurantConverter.java b/src/main/java/com/example/umc9th/domain/restaurant/converter/RestaurantConverter.java new file mode 100644 index 0000000..311e73d --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/restaurant/converter/RestaurantConverter.java @@ -0,0 +1,29 @@ +package com.example.umc9th.domain.restaurant.converter; + +import com.example.umc9th.domain.restaurant.dto.RestaurantRequestDTO; +import com.example.umc9th.domain.restaurant.dto.RestaurantResponseDTO; +import com.example.umc9th.domain.restaurant.entity.Region; +import com.example.umc9th.domain.restaurant.entity.Restaurant; +import com.example.umc9th.domain.restaurant.entity.RestaurantType; + +import java.time.LocalDateTime; + +public class RestaurantConverter { + + public static RestaurantResponseDTO.AddResultDTO toAddResultDTO(Restaurant restaurant) { + return RestaurantResponseDTO.AddResultDTO.builder() + .restaurantId(restaurant.getId()) + .createdAt(LocalDateTime.now()) + .build(); + } + + public static Restaurant toRestaurant(RestaurantRequestDTO.AddRestaurantDTO request, Region region, RestaurantType restaurantType) { + return Restaurant.builder() + .name(request.getName()) + .location(request.getAddress()) + .region(region) + .restaurantType(restaurantType) + .build(); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/restaurant/dto/RestaurantRequestDTO.java b/src/main/java/com/example/umc9th/domain/restaurant/dto/RestaurantRequestDTO.java new file mode 100644 index 0000000..67606d9 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/restaurant/dto/RestaurantRequestDTO.java @@ -0,0 +1,19 @@ +package com.example.umc9th.domain.restaurant.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +public class RestaurantRequestDTO { + + @Getter + public static class AddRestaurantDTO { + @NotBlank + private String name; + @NotBlank + private String address; + @NotNull + private Long categoryId; // RestaurantType ID + } +} + diff --git a/src/main/java/com/example/umc9th/domain/restaurant/dto/RestaurantResponseDTO.java b/src/main/java/com/example/umc9th/domain/restaurant/dto/RestaurantResponseDTO.java new file mode 100644 index 0000000..aa30b57 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/restaurant/dto/RestaurantResponseDTO.java @@ -0,0 +1,21 @@ +package com.example.umc9th.domain.restaurant.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +public class RestaurantResponseDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class AddResultDTO { + private Long restaurantId; + private LocalDateTime createdAt; + } +} + diff --git a/src/main/java/com/example/umc9th/domain/restaurant/service/RestaurantService.java b/src/main/java/com/example/umc9th/domain/restaurant/service/RestaurantService.java new file mode 100644 index 0000000..3b80320 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/restaurant/service/RestaurantService.java @@ -0,0 +1,9 @@ +package com.example.umc9th.domain.restaurant.service; + +import com.example.umc9th.domain.restaurant.dto.RestaurantRequestDTO; +import com.example.umc9th.domain.restaurant.entity.Restaurant; + +public interface RestaurantService { + Restaurant addRestaurant(Long regionId, RestaurantRequestDTO.AddRestaurantDTO request); +} + diff --git a/src/main/java/com/example/umc9th/domain/restaurant/service/RestaurantServiceImpl.java b/src/main/java/com/example/umc9th/domain/restaurant/service/RestaurantServiceImpl.java new file mode 100644 index 0000000..86ff02e --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/restaurant/service/RestaurantServiceImpl.java @@ -0,0 +1,39 @@ +package com.example.umc9th.domain.restaurant.service; + +import com.example.umc9th.domain.restaurant.converter.RestaurantConverter; +import com.example.umc9th.domain.restaurant.dto.RestaurantRequestDTO; +import com.example.umc9th.domain.restaurant.entity.Region; +import com.example.umc9th.domain.restaurant.entity.Restaurant; +import com.example.umc9th.domain.restaurant.entity.RestaurantType; +import com.example.umc9th.domain.restaurant.repository.RegionRepository; +import com.example.umc9th.domain.restaurant.repository.RestaurantRepository; +import com.example.umc9th.domain.restaurant.repository.RestaurantTypeRepository; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RestaurantServiceImpl implements RestaurantService { + + private final RestaurantRepository restaurantRepository; + private final RegionRepository regionRepository; + private final RestaurantTypeRepository restaurantTypeRepository; + + @Override + @Transactional + public Restaurant addRestaurant(Long regionId, RestaurantRequestDTO.AddRestaurantDTO request) { + Region region = regionRepository.findById(regionId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.REGION_NOT_FOUND)); + + RestaurantType restaurantType = restaurantTypeRepository.findById(request.getCategoryId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.FOOD_CATEGORY_NOT_FOUND)); + + Restaurant restaurant = RestaurantConverter.toRestaurant(request, region, restaurantType); + + return restaurantRepository.save(restaurant); + } +} + 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..ee1591d --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java @@ -0,0 +1,64 @@ +package com.example.umc9th.domain.review.controller; + +import com.example.umc9th.domain.review.converter.ReviewConverter; +import com.example.umc9th.domain.review.dto.ReviewMyReviewResponse; +import com.example.umc9th.domain.review.dto.ReviewRequestDTO; +import com.example.umc9th.domain.review.dto.ReviewResponseDTO; +import com.example.umc9th.domain.review.entity.Review; +import com.example.umc9th.domain.review.service.ReviewService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; +import com.example.umc9th.global.validation.annotation.CheckPage; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@Validated +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping("/restaurants/{restaurantId}/reviews") + @Operation(summary = "가게에 리뷰 추가 API", description = "특정 가게에 리뷰를 추가하는 API입니다.") + public ApiResponse addReview(@PathVariable Long restaurantId, + @RequestBody @Valid ReviewRequestDTO.AddReviewDTO request) { + Review review = reviewService.addReview(restaurantId, request); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, ReviewConverter.toAddResultDTO(review)); + } + + @GetMapping("/users/{userId}/reviews") + @Operation(summary = "내가 작성한 리뷰 목록 조회 API", description = "특정 유저가 작성한 리뷰 목록을 조회하는 API이며, 페이징을 포함합니다. query String으로 page 번호를 주세요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + @Parameters({ + @Parameter(name = "userId", description = "유저의 아이디, path variable 입니다!"), + @Parameter(name = "page", description = "페이지 번호, 1번이 1 페이지 입니다."), + }) + public ApiResponse> getMyReviews(@PathVariable Long userId, + @RequestParam(required = false) String restaurantName, + @RequestParam(required = false) Integer ratingFloor, + @CheckPage @RequestParam(name = "page") Integer page) { + Page response = reviewService.getMyReviews(userId, restaurantName, ratingFloor, page - 1); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, 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..4eeaac6 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java @@ -0,0 +1,45 @@ +package com.example.umc9th.domain.review.converter; + +import com.example.umc9th.domain.review.dto.ReviewMyReviewResponse; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; + +import com.example.umc9th.domain.restaurant.entity.Restaurant; +import com.example.umc9th.domain.review.dto.ReviewRequestDTO; +import com.example.umc9th.domain.review.dto.ReviewResponseDTO; +import com.example.umc9th.domain.review.entity.Review; +import com.example.umc9th.domain.user.entity.User; + +import java.time.LocalDateTime; + +public final class ReviewConverter { + + private ReviewConverter() { + } + + public static ReviewResponseDTO.AddResultDTO toAddResultDTO(Review review) { + return ReviewResponseDTO.AddResultDTO.builder() + .reviewId(review.getId()) + .createdAt(LocalDateTime.now()) + .build(); + } + + public static Review toReview(ReviewRequestDTO.AddReviewDTO request, Restaurant restaurant, User user) { + return Review.builder() + .restaurant(restaurant) + .user(user) + .reviewStar(Math.round(request.getScore())) // Float -> Integer 변환 + .body(request.getBody()) + .build(); + } + + public static ReviewMyReviewResponse toMyReviewResponse(ReviewSummaryProjection projection) { + return new ReviewMyReviewResponse( + projection.reviewId(), + projection.restaurantName(), + projection.reviewStar(), + projection.body(), + projection.createdAt() + ); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/review/dto/ReviewMyReviewResponse.java b/src/main/java/com/example/umc9th/domain/review/dto/ReviewMyReviewResponse.java new file mode 100644 index 0000000..a9ccb52 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/dto/ReviewMyReviewResponse.java @@ -0,0 +1,13 @@ +package com.example.umc9th.domain.review.dto; + +import java.time.LocalDateTime; + +public record ReviewMyReviewResponse( + Long reviewId, + String restaurantName, + Integer reviewStar, + String body, + LocalDateTime createdAt +) { +} + diff --git a/src/main/java/com/example/umc9th/domain/review/dto/ReviewRequestDTO.java b/src/main/java/com/example/umc9th/domain/review/dto/ReviewRequestDTO.java new file mode 100644 index 0000000..1a34433 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/dto/ReviewRequestDTO.java @@ -0,0 +1,23 @@ +package com.example.umc9th.domain.review.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +public class ReviewRequestDTO { + + @Getter + public static class AddReviewDTO { + @NotNull + private Long userId; + @NotNull + @DecimalMin("0.0") + @DecimalMax("5.0") + private Float score; + @NotBlank + private String body; + } +} + diff --git a/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponseDTO.java b/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponseDTO.java new file mode 100644 index 0000000..dc4068e --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponseDTO.java @@ -0,0 +1,21 @@ +package com.example.umc9th.domain.review.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +public class ReviewResponseDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class AddResultDTO { + private Long reviewId; + private LocalDateTime createdAt; + } +} + diff --git a/src/main/java/com/example/umc9th/domain/review/enums/ReviewRatingGroup.java b/src/main/java/com/example/umc9th/domain/review/enums/ReviewRatingGroup.java new file mode 100644 index 0000000..02ff22a --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/enums/ReviewRatingGroup.java @@ -0,0 +1,47 @@ +package com.example.umc9th.domain.review.enums; + +import java.util.Arrays; + +public enum ReviewRatingGroup { + + FIVE(5, 5), + FOUR(4, 4), + THREE(3, 3), + TWO(2, 2), + ONE(1, 1); + + private final int minInclusive; + private final int maxInclusive; + + ReviewRatingGroup(int minInclusive, int maxInclusive) { + this.minInclusive = minInclusive; + this.maxInclusive = maxInclusive; + } + + public int getMinInclusive() { + return minInclusive; + } + + public int getMaxInclusive() { + return maxInclusive; + } + + public static ReviewRatingGroup fromValue(Integer value) { + if (value == null) { + return null; + } + return Arrays.stream(values()) + .filter(group -> group.minInclusive == value) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported rating group value: " + value)); + } +} + + + + + + + + + diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepository.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepository.java new file mode 100644 index 0000000..f819f16 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepository.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.review.repository; + +import com.example.umc9th.domain.review.enums.ReviewRatingGroup; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ReviewQueryRepository { + + Page findMyReviews(Long userId, + String restaurantName, + ReviewRatingGroup ratingGroup, + Pageable pageable); +} + + + + diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepositoryImpl.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepositoryImpl.java new file mode 100644 index 0000000..740c450 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepositoryImpl.java @@ -0,0 +1,80 @@ +package com.example.umc9th.domain.review.repository; + +import com.example.umc9th.domain.restaurant.entity.QRestaurant; +import com.example.umc9th.domain.review.entity.QReview; +import com.example.umc9th.domain.review.enums.ReviewRatingGroup; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ReviewQueryRepositoryImpl implements ReviewQueryRepository { + + private final JPAQueryFactory queryFactory; + + private static final QReview review = QReview.review; + private static final QRestaurant restaurant = QRestaurant.restaurant; + + @Override + public Page findMyReviews(Long userId, + String restaurantName, + ReviewRatingGroup ratingGroup, + Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder() + .and(review.user.id.eq(userId)); + + if (StringUtils.hasText(restaurantName)) { + builder.and(restaurant.name.eq(restaurantName)); + } + + BooleanExpression ratingCondition = ratingGroupCondition(ratingGroup); + if (ratingCondition != null) { + builder.and(ratingCondition); + } + + List content = queryFactory + .select(Projections.constructor(ReviewSummaryProjection.class, + review.id, + restaurant.name, + review.reviewStar, + review.body, + review.createdAt + )) + .from(review) + .join(review.restaurant, restaurant) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(review.createdAt.desc()) + .fetch(); + + Long total = queryFactory + .select(review.count()) + .from(review) + .join(review.restaurant, restaurant) + .where(builder) + .fetchOne(); + + long totalElements = total != null ? total : 0L; + return new PageImpl<>(content, pageable, totalElements); + } + + private BooleanExpression ratingGroupCondition(ReviewRatingGroup ratingGroup) { + if (ratingGroup == null) { + return null; + } + return review.reviewStar.between(ratingGroup.getMinInclusive(), ratingGroup.getMaxInclusive()); + } +} + 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 7a7a4ac..c2d2221 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 @@ -3,6 +3,6 @@ import com.example.umc9th.domain.review.entity.Review; import org.springframework.data.jpa.repository.JpaRepository; -public interface ReviewRepository extends JpaRepository { +public interface ReviewRepository extends JpaRepository, ReviewQueryRepository { } diff --git a/src/main/java/com/example/umc9th/domain/review/repository/result/ReviewSummaryProjection.java b/src/main/java/com/example/umc9th/domain/review/repository/result/ReviewSummaryProjection.java new file mode 100644 index 0000000..68e3281 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/result/ReviewSummaryProjection.java @@ -0,0 +1,21 @@ +package com.example.umc9th.domain.review.repository.result; + +import java.time.LocalDateTime; + +public record ReviewSummaryProjection( + Long reviewId, + String restaurantName, + Integer reviewStar, + String body, + LocalDateTime createdAt +) { +} + + + + + + + + + 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..f82086f --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java @@ -0,0 +1,69 @@ +package com.example.umc9th.domain.review.service; + +import com.example.umc9th.domain.restaurant.entity.Restaurant; +import com.example.umc9th.domain.restaurant.repository.RestaurantRepository; +import com.example.umc9th.domain.review.converter.ReviewConverter; +import com.example.umc9th.domain.review.dto.ReviewMyReviewResponse; +import com.example.umc9th.domain.review.dto.ReviewRequestDTO; +import com.example.umc9th.domain.review.entity.Review; +import com.example.umc9th.domain.review.enums.ReviewRatingGroup; +import com.example.umc9th.domain.review.repository.ReviewRepository; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; +import com.example.umc9th.domain.user.entity.User; +import com.example.umc9th.domain.user.repository.UserRepository; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final RestaurantRepository restaurantRepository; + private final UserRepository userRepository; + + @Transactional + public Review addReview(Long restaurantId, ReviewRequestDTO.AddReviewDTO request) { + Restaurant restaurant = restaurantRepository.findById(restaurantId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.RESTAURANT_NOT_FOUND)); + + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + + Review review = ReviewConverter.toReview(request, restaurant, user); + + return reviewRepository.save(review); + } + + public Page getMyReviews(Long userId, + String restaurantName, + Integer ratingFloor, + Integer page) { + ReviewRatingGroup ratingGroup = ReviewRatingGroup.fromValue(ratingFloor); + Pageable pageable = PageRequest.of(page, 10); + + Page projectionPage = reviewRepository.findMyReviews( + userId, + restaurantName, + ratingGroup, + pageable + ); + + return projectionPage.map(ReviewConverter::toMyReviewResponse); + } +} + + + + + + + + diff --git a/src/main/java/com/example/umc9th/domain/test/controller/ForLoopVsStreamController.java b/src/main/java/com/example/umc9th/domain/test/controller/ForLoopVsStreamController.java new file mode 100644 index 0000000..d83aa8c --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/controller/ForLoopVsStreamController.java @@ -0,0 +1,61 @@ +package com.example.umc9th.domain.test.controller; + +import com.example.umc9th.domain.test.service.ForLoopVsStreamService; +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.util.ArrayList; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/test/performance") +public class ForLoopVsStreamController { + + private final ForLoopVsStreamService performanceService; + + @GetMapping("/compare") + public String compare(@RequestParam(defaultValue = "1000000") int size) { + List numbers = new ArrayList<>(); + for (int i = 0; i < size; i++) { + numbers.add(i); + } + + StringBuilder result = new StringBuilder(); + result.append(String.format("데이터 크기: %d개\n\n", size)); + + // 1. Sum 비교 + long start = System.nanoTime(); + performanceService.sumByForLoop(numbers); + long forLoopSumTime = System.nanoTime() - start; + + start = System.nanoTime(); + performanceService.sumByStream(numbers); + long streamSumTime = System.nanoTime() - start; + + result.append("[Sum 연산]\n"); + result.append(String.format("For Loop: %d ns\n", forLoopSumTime)); + result.append(String.format("Stream: %d ns\n", streamSumTime)); + result.append(String.format("더 빠른 방식: %s\n\n", forLoopSumTime < streamSumTime ? "For Loop" : "Stream")); + + // 2. Filter 비교 + start = System.nanoTime(); + performanceService.filterByForLoop(numbers); + long forLoopFilterTime = System.nanoTime() - start; + + start = System.nanoTime(); + performanceService.filterByStream(numbers); + long streamFilterTime = System.nanoTime() - start; + + result.append("[Filter 연산]\n"); + result.append(String.format("For Loop: %d ns\n", forLoopFilterTime)); + result.append(String.format("Stream: %d ns\n", streamFilterTime)); + result.append(String.format("더 빠른 방식: %s\n", forLoopFilterTime < streamFilterTime ? "For Loop" : "Stream")); + + return result.toString(); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/test/controller/TestController.java b/src/main/java/com/example/umc9th/domain/test/controller/TestController.java new file mode 100644 index 0000000..8b9b8a3 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/controller/TestController.java @@ -0,0 +1,49 @@ +package com.example.umc9th.domain.test.controller; + +import com.example.umc9th.domain.test.converter.TestConverter; +import com.example.umc9th.domain.test.dto.res.TestResDTO; +import com.example.umc9th.domain.test.service.query.TestQueryService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/temp") +public class TestController { + + private final TestQueryService testQueryService; + + @GetMapping("/test") + public ApiResponse test() { + // 응답 코드 정의 + GeneralSuccessCode code = GeneralSuccessCode.SUCCESS; + + return ApiResponse.onSuccess( + code, + TestConverter.toTestingDTO("This is Test!") + ); + } + + // 예외 상황 + @GetMapping("/exception") + public ApiResponse exception( + @RequestParam Long flag + ) { + + testQueryService.checkFlag(flag); + + // 응답 코드 정의 + GeneralSuccessCode code = GeneralSuccessCode.SUCCESS; + return ApiResponse.onSuccess(code, TestConverter.toExceptionDTO("This is Test!")); + } + + @GetMapping("/error") + public ApiResponse error() { + throw new RuntimeException("500 Error Test!"); + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/converter/TestConverter.java b/src/main/java/com/example/umc9th/domain/test/converter/TestConverter.java new file mode 100644 index 0000000..af81183 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/converter/TestConverter.java @@ -0,0 +1,24 @@ +package com.example.umc9th.domain.test.converter; + +import com.example.umc9th.domain.test.dto.res.TestResDTO; + +public class TestConverter { + + // 객체 -> DTO + public static TestResDTO.Testing toTestingDTO( + String testing + ) { + return TestResDTO.Testing.builder() + .testString(testing) + .build(); + } + + // 객체 -> DTO + public static TestResDTO.Exception toExceptionDTO( + String testing + ){ + return TestResDTO.Exception.builder() + .testString(testing) + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/dto/res/TestResDTO.java b/src/main/java/com/example/umc9th/domain/test/dto/res/TestResDTO.java new file mode 100644 index 0000000..9f13f52 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/dto/res/TestResDTO.java @@ -0,0 +1,19 @@ +package com.example.umc9th.domain.test.dto.res; + +import lombok.Builder; +import lombok.Getter; + +public class TestResDTO { + + @Builder + @Getter + public static class Testing { + private String testString; + } + + @Builder + @Getter + public static class Exception { + private String testString; + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/exception/TestException.java b/src/main/java/com/example/umc9th/domain/test/exception/TestException.java new file mode 100644 index 0000000..1faf200 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/exception/TestException.java @@ -0,0 +1,10 @@ +package com.example.umc9th.domain.test.exception; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; + +public class TestException extends GeneralException { + public TestException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/exception/code/TestErrorCode.java b/src/main/java/com/example/umc9th/domain/test/exception/code/TestErrorCode.java new file mode 100644 index 0000000..7264f21 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/exception/code/TestErrorCode.java @@ -0,0 +1,19 @@ +package com.example.umc9th.domain.test.exception.code; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TestErrorCode implements BaseErrorCode { + + // For test + TEST_EXCEPTION(HttpStatus.BAD_REQUEST, "TEST400_1", "이거는 테스트"), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/test/service/ForLoopVsStreamService.java b/src/main/java/com/example/umc9th/domain/test/service/ForLoopVsStreamService.java new file mode 100644 index 0000000..e93e235 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/ForLoopVsStreamService.java @@ -0,0 +1,46 @@ +package com.example.umc9th.domain.test.service; + +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class ForLoopVsStreamService { + + // 1. Sum (합계) - for문 + public long sumByForLoop(List numbers) { + long sum = 0; + for (Integer number : numbers) { + sum += number; + } + return sum; + } + + // 1. Sum (합계) - Stream + public long sumByStream(List numbers) { + return numbers.stream() + .mapToLong(Integer::longValue) + .sum(); + } + + // 2. Filter (필터링) - for문 + public List filterByForLoop(List numbers) { + List result = new ArrayList<>(); + for (Integer number : numbers) { + if (number % 2 == 0) { + result.add(number); + } + } + return result; + } + + // 2. Filter (필터링) - Stream + public List filterByStream(List numbers) { + return numbers.stream() + .filter(number -> number % 2 == 0) + .collect(Collectors.toList()); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandService.java b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandService.java new file mode 100644 index 0000000..9b92691 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandService.java @@ -0,0 +1,5 @@ +package com.example.umc9th.domain.test.service.command; + +public interface TestCommandService { + +} diff --git a/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandServiceImpl.java b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandServiceImpl.java new file mode 100644 index 0000000..93d6607 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandServiceImpl.java @@ -0,0 +1,5 @@ +package com.example.umc9th.domain.test.service.command; + +public class TestCommandServiceImpl { + +} diff --git a/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryService.java b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryService.java new file mode 100644 index 0000000..a6419ab --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryService.java @@ -0,0 +1,5 @@ +package com.example.umc9th.domain.test.service.query; + +public interface TestQueryService { + void checkFlag(Long flag); +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryServiceImpl.java b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryServiceImpl.java new file mode 100644 index 0000000..a6190fa --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryServiceImpl.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.test.service.query; + +import com.example.umc9th.domain.test.exception.TestException; +import com.example.umc9th.domain.test.exception.code.TestErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TestQueryServiceImpl implements TestQueryService { + + @Override + public void checkFlag(Long flag){ + if (flag == 1){ + throw new TestException(TestErrorCode.TEST_EXCEPTION); + } + } +} diff --git a/src/main/java/com/example/umc9th/domain/user/controller/UserRestController.java b/src/main/java/com/example/umc9th/domain/user/controller/UserRestController.java new file mode 100644 index 0000000..ceca71f --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/controller/UserRestController.java @@ -0,0 +1,80 @@ +package com.example.umc9th.domain.user.controller; + +import com.example.umc9th.domain.user.dto.MemberReqDTO; +import com.example.umc9th.domain.user.dto.MemberResDTO; +import com.example.umc9th.domain.user.service.UserCommandService; +import com.example.umc9th.domain.user.service.UserQueryService; +import com.example.umc9th.domain.user.dto.UserMissionResponseDTO; +import com.example.umc9th.domain.user.service.UserMissionService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; +import com.example.umc9th.global.validation.annotation.CheckPage; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping("/users") +public class UserRestController { + + private final UserMissionService userMissionService; + private final UserCommandService userCommandService; + private final UserQueryService userQueryService; + + @PostMapping("/sign-up") + @Operation(summary = "회원가입 API", description = "회원가입하는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + public ApiResponse join(@RequestBody @Valid MemberReqDTO.JoinDTO request){ + MemberResDTO.JoinResultDTO result = userCommandService.joinUser(request); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, result); + } + + @PostMapping("/login") + @Operation(summary = "로그인 API", description = "로그인하는 API입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + }) + public ApiResponse login(@RequestBody @Valid MemberReqDTO.LoginDTO request){ + MemberResDTO.LoginDTO result = userQueryService.login(request); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, result); + } + + @GetMapping("/{userId}/missions") + @Operation(summary = "내가 진행중인 미션 목록 조회 API", description = "내가 진행중인 미션 목록을 조회하는 API이며, 페이징을 포함합니다. query String으로 page 번호를 주세요") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON400", description = "잘못된 요청입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + }) + @Parameters({ + @Parameter(name = "userId", description = "유저의 아이디, path variable 입니다!"), + @Parameter(name = "page", description = "페이지 번호, 1번이 1 페이지 입니다."), + }) + public ApiResponse> getChallengingMissions(@PathVariable Long userId, + @CheckPage @RequestParam(name = "page") Integer page) { + Page response = userMissionService.getChallengingMissions(userId, page - 1); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, response); + } + + @PatchMapping("/{userId}/missions/{missionId}/complete") + @Operation(summary = "진행중인 미션 완료 처리 API", description = "진행중인 미션을 완료 상태로 변경하는 API입니다.") + public ApiResponse completeMission(@PathVariable Long userId, + @PathVariable Long missionId) { + UserMissionResponseDTO.UserMissionDTO response = userMissionService.completeMission(userId, missionId); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, response); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/user/converter/MemberConverter.java b/src/main/java/com/example/umc9th/domain/user/converter/MemberConverter.java new file mode 100644 index 0000000..d5b061b --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/converter/MemberConverter.java @@ -0,0 +1,31 @@ +package com.example.umc9th.domain.user.converter; + +import com.example.umc9th.domain.user.dto.MemberReqDTO; +import com.example.umc9th.domain.user.dto.MemberResDTO; +import com.example.umc9th.domain.user.entity.User; +import com.example.umc9th.global.auth.enums.Role; + +public class MemberConverter { + + public static MemberResDTO.JoinResultDTO toJoinResultDTO(User user){ + return new MemberResDTO.JoinResultDTO(user.getId(), user.getCreatedAt()); + } + + // DTO, Salted Password, Role -> Entity + public static User toUser( + MemberReqDTO.JoinDTO dto, + String password, + Role role +){ + return User.builder() + .name(dto.name()) + .email(dto.email()) // 추가된 코드 + .password(password) // 추가된 코드 + .role(role) // 추가된 코드 + .birth(dto.birth()) + .address(dto.address()) + .detailAddress(dto.specAddress()) + .gender(dto.gender()) + .build(); +} +} diff --git a/src/main/java/com/example/umc9th/domain/user/converter/UserMissionConverter.java b/src/main/java/com/example/umc9th/domain/user/converter/UserMissionConverter.java new file mode 100644 index 0000000..ca52a1e --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/converter/UserMissionConverter.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.user.converter; + +import com.example.umc9th.domain.user.dto.UserMissionResponseDTO; +import com.example.umc9th.domain.user.entity.UserMission; + +public class UserMissionConverter { + + public static UserMissionResponseDTO.UserMissionDTO toUserMissionDTO(UserMission userMission) { + return UserMissionResponseDTO.UserMissionDTO.builder() + .id(userMission.getId()) + .reward(userMission.getMission().getReward()) + .goal(userMission.getMission().getGoal()) + .deadline(userMission.getMission().getDeadline()) + .missionStatus(userMission.getStatus().toString()) + .build(); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/user/dto/MemberReqDTO.java b/src/main/java/com/example/umc9th/domain/user/dto/MemberReqDTO.java new file mode 100644 index 0000000..f381610 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/dto/MemberReqDTO.java @@ -0,0 +1,36 @@ +package com.example.umc9th.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import com.example.umc9th.domain.user.enums.Gender; +import java.util.List; + +public class MemberReqDTO { + public record JoinDTO( + @NotBlank + String name, + @Email + String email, // 추가된 속성 + @NotBlank + String password, // 추가된 속성 + @NotNull + Gender gender, + @NotNull + LocalDate birth, + @NotNull + String address, + @NotNull + String specAddress, + @NotNull + List preferCategory + ){} + + public record LoginDTO( + @NotBlank + String email, + @NotBlank + String password + ){} +} diff --git a/src/main/java/com/example/umc9th/domain/user/dto/MemberResDTO.java b/src/main/java/com/example/umc9th/domain/user/dto/MemberResDTO.java new file mode 100644 index 0000000..f5abbad --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/dto/MemberResDTO.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.user.dto; + +import java.time.LocalDateTime; +import lombok.Builder; + +public class MemberResDTO { + public record JoinResultDTO( + Long memberId, + LocalDateTime createdAt + ){} + + @Builder + public record LoginDTO( + Long memberId, + String accessToken + ){} +} + diff --git a/src/main/java/com/example/umc9th/domain/user/dto/UserMissionResponseDTO.java b/src/main/java/com/example/umc9th/domain/user/dto/UserMissionResponseDTO.java new file mode 100644 index 0000000..2d627f6 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/dto/UserMissionResponseDTO.java @@ -0,0 +1,24 @@ +package com.example.umc9th.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +public class UserMissionResponseDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UserMissionDTO { + private Long id; + private Integer reward; + private Integer goal; + private LocalDateTime deadline; + private String missionStatus; + } +} + diff --git a/src/main/java/com/example/umc9th/domain/user/entity/User.java b/src/main/java/com/example/umc9th/domain/user/entity/User.java index ebc4083..de201b4 100644 --- a/src/main/java/com/example/umc9th/domain/user/entity/User.java +++ b/src/main/java/com/example/umc9th/domain/user/entity/User.java @@ -2,6 +2,7 @@ import com.example.umc9th.domain.user.enums.Gender; import com.example.umc9th.global.BaseEntity; +import com.example.umc9th.global.auth.enums.Role; import jakarta.persistence.*; import lombok.*; @@ -25,6 +26,15 @@ public class User extends BaseEntity { @Column(length = 20) private String name; + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private Role role; + @Enumerated(EnumType.STRING) private Gender gender; @@ -33,6 +43,9 @@ public class User extends BaseEntity { @Column(length = 50) private String address; + @Column(length = 50) + private String detailAddress; + private Long point; @Column(length = 20) @@ -41,8 +54,6 @@ public class User extends BaseEntity { @Column(name = "inactive_date") private LocalDateTime inactiveDate; - @Column(length = 50) - private String email; @Column(name = "is_auth") private Boolean isAuth; diff --git a/src/main/java/com/example/umc9th/domain/user/entity/UserMission.java b/src/main/java/com/example/umc9th/domain/user/entity/UserMission.java index 342da72..5c7f533 100644 --- a/src/main/java/com/example/umc9th/domain/user/entity/UserMission.java +++ b/src/main/java/com/example/umc9th/domain/user/entity/UserMission.java @@ -28,5 +28,9 @@ public class UserMission extends BaseEntity { @Enumerated(EnumType.STRING) private MissionStatus status; + + public void setStatus(MissionStatus status) { + this.status = status; + } } diff --git a/src/main/java/com/example/umc9th/domain/user/repository/UserMissionRepository.java b/src/main/java/com/example/umc9th/domain/user/repository/UserMissionRepository.java index 4da6811..d28a977 100644 --- a/src/main/java/com/example/umc9th/domain/user/repository/UserMissionRepository.java +++ b/src/main/java/com/example/umc9th/domain/user/repository/UserMissionRepository.java @@ -1,8 +1,15 @@ package com.example.umc9th.domain.user.repository; import com.example.umc9th.domain.user.entity.UserMission; +import com.example.umc9th.domain.user.enums.MissionStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserMissionRepository extends JpaRepository { + Page findAllByUserIdAndStatus(Long userId, MissionStatus status, Pageable pageable); + Optional findByUserIdAndMissionId(Long userId, Long missionId); } diff --git a/src/main/java/com/example/umc9th/domain/user/repository/UserRepository.java b/src/main/java/com/example/umc9th/domain/user/repository/UserRepository.java index 58ca45e..39a4b9f 100644 --- a/src/main/java/com/example/umc9th/domain/user/repository/UserRepository.java +++ b/src/main/java/com/example/umc9th/domain/user/repository/UserRepository.java @@ -1,8 +1,13 @@ package com.example.umc9th.domain.user.repository; import com.example.umc9th.domain.user.entity.User; + +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); } diff --git a/src/main/java/com/example/umc9th/domain/user/service/UserCommandService.java b/src/main/java/com/example/umc9th/domain/user/service/UserCommandService.java new file mode 100644 index 0000000..31745ae --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/service/UserCommandService.java @@ -0,0 +1,9 @@ +package com.example.umc9th.domain.user.service; + +import com.example.umc9th.domain.user.dto.MemberReqDTO; +import com.example.umc9th.domain.user.dto.MemberResDTO; + +public interface UserCommandService { + MemberResDTO.JoinResultDTO joinUser(MemberReqDTO.JoinDTO request); +} + diff --git a/src/main/java/com/example/umc9th/domain/user/service/UserCommandServiceImpl.java b/src/main/java/com/example/umc9th/domain/user/service/UserCommandServiceImpl.java new file mode 100644 index 0000000..c26e5b4 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/service/UserCommandServiceImpl.java @@ -0,0 +1,41 @@ +package com.example.umc9th.domain.user.service; + +import com.example.umc9th.domain.user.converter.MemberConverter; +import com.example.umc9th.domain.user.dto.MemberReqDTO; +import com.example.umc9th.domain.user.entity.User; +import com.example.umc9th.domain.user.repository.UserRepository; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import com.example.umc9th.global.auth.enums.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.umc9th.domain.user.dto.MemberResDTO; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserCommandServiceImpl implements UserCommandService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Override + @Transactional + public MemberResDTO.JoinResultDTO joinUser(MemberReqDTO.JoinDTO request) { + + // 이메일 중복 체크 + if (userRepository.findByEmail(request.email()).isPresent()) { + throw new GeneralException(GeneralErrorCode.BAD_REQUEST); // TODO: 적절한 예외 처리로 변경 필요 + } + + User newUser = MemberConverter.toUser(request, passwordEncoder.encode(request.password()), Role.ROLE_USER); + + userRepository.save(newUser); + + return MemberConverter.toJoinResultDTO(newUser); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/user/service/UserMissionService.java b/src/main/java/com/example/umc9th/domain/user/service/UserMissionService.java new file mode 100644 index 0000000..3988a58 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/service/UserMissionService.java @@ -0,0 +1,10 @@ +package com.example.umc9th.domain.user.service; + +import com.example.umc9th.domain.user.dto.UserMissionResponseDTO; +import org.springframework.data.domain.Page; + +public interface UserMissionService { + Page getChallengingMissions(Long userId, Integer page); + UserMissionResponseDTO.UserMissionDTO completeMission(Long userId, Long missionId); +} + diff --git a/src/main/java/com/example/umc9th/domain/user/service/UserMissionServiceImpl.java b/src/main/java/com/example/umc9th/domain/user/service/UserMissionServiceImpl.java new file mode 100644 index 0000000..60128a6 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/service/UserMissionServiceImpl.java @@ -0,0 +1,48 @@ +package com.example.umc9th.domain.user.service; + +import com.example.umc9th.domain.user.converter.UserMissionConverter; +import com.example.umc9th.domain.user.dto.UserMissionResponseDTO; +import com.example.umc9th.domain.user.entity.UserMission; +import com.example.umc9th.domain.user.enums.MissionStatus; +import com.example.umc9th.domain.user.repository.UserMissionRepository; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserMissionServiceImpl implements UserMissionService { + + private final UserMissionRepository userMissionRepository; + + @Override + public Page getChallengingMissions(Long userId, Integer page) { + Page userMissionPage = userMissionRepository.findAllByUserIdAndStatus( + userId, + MissionStatus.CHALLENGING, + PageRequest.of(page, 10) + ); + return userMissionPage.map(UserMissionConverter::toUserMissionDTO); + } + + @Override + @Transactional + public UserMissionResponseDTO.UserMissionDTO completeMission(Long userId, Long missionId) { + UserMission userMission = userMissionRepository.findByUserIdAndMissionId(userId, missionId) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.MISSION_NOT_FOUND)); + + if (userMission.getStatus() == MissionStatus.COMPLETE) { + // 이미 완료된 경우 에러 처리 혹은 그대로 반환. 여기서는 그대로 진행 (상태 변경이므로) + } + + userMission.setStatus(MissionStatus.COMPLETE); + + return UserMissionConverter.toUserMissionDTO(userMissionRepository.save(userMission)); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/user/service/UserQueryService.java b/src/main/java/com/example/umc9th/domain/user/service/UserQueryService.java new file mode 100644 index 0000000..da8893b --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/service/UserQueryService.java @@ -0,0 +1,9 @@ +package com.example.umc9th.domain.user.service; + +import com.example.umc9th.domain.user.dto.MemberReqDTO; +import com.example.umc9th.domain.user.dto.MemberResDTO; + +public interface UserQueryService { + MemberResDTO.LoginDTO login(MemberReqDTO.LoginDTO request); +} + diff --git a/src/main/java/com/example/umc9th/domain/user/service/UserQueryServiceImpl.java b/src/main/java/com/example/umc9th/domain/user/service/UserQueryServiceImpl.java new file mode 100644 index 0000000..6795914 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/user/service/UserQueryServiceImpl.java @@ -0,0 +1,45 @@ +package com.example.umc9th.domain.user.service; + +import com.example.umc9th.domain.user.dto.MemberReqDTO; +import com.example.umc9th.domain.user.dto.MemberResDTO; +import com.example.umc9th.domain.user.entity.User; +import com.example.umc9th.domain.user.repository.UserRepository; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import com.example.umc9th.global.auth.CustomUserDetails; +import com.example.umc9th.global.auth.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserQueryServiceImpl implements UserQueryService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + @Override + public MemberResDTO.LoginDTO login(MemberReqDTO.LoginDTO request) { + // 1. 이메일로 유저 찾기 + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + + // 2. 비밀번호 확인 + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + throw new GeneralException(GeneralErrorCode.BAD_REQUEST); // 비밀번호 불일치 + } + + // 3. 토큰 생성 + String accessToken = jwtUtil.createAccessToken(new CustomUserDetails(user)); + + return MemberResDTO.LoginDTO.builder() + .memberId(user.getId()) + .accessToken(accessToken) + .build(); + } +} + 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..83f3cd3 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java @@ -0,0 +1,35 @@ +package com.example.umc9th.global.apiPayload; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.code.BaseSuccessCode; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +public class ApiResponse { + + @JsonProperty("isSuccess") + private final Boolean isSuccess; + + @JsonProperty("code") + private final String code; + + @JsonProperty("message") + private final String message; + + @JsonProperty("result") + private T result; + + // 성공한 경우 (result 포함) + public static ApiResponse onSuccess(BaseSuccessCode code, T result) { + return new ApiResponse<>(true, code.getCode(), code.getMessage(), result); + } + // 실패한 경우 (result 포함) + public static ApiResponse onFailure(BaseErrorCode code, T result) { + return new ApiResponse<>(false, code.getCode(), code.getMessage(), result); + } +} 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..98002d6 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,11 @@ +package com.example.umc9th.global.apiPayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} + \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/BaseSuccessCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseSuccessCode.java new file mode 100644 index 0000000..57bf5bc --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseSuccessCode.java @@ -0,0 +1,11 @@ +package com.example.umc9th.global.apiPayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseSuccessCode { + + String getCode(); + String getMessage(); + HttpStatus getStatus(); +} + \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java new file mode 100644 index 0000000..24f604d --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java @@ -0,0 +1,46 @@ +package com.example.umc9th.global.apiPayload.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GeneralErrorCode implements BaseErrorCode{ + + BAD_REQUEST(HttpStatus.BAD_REQUEST, + "COMMON400_1", + "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, + "AUTH401_1", + "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, + "AUTH403_1", + "요청이 거부되었습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, + "COMMON404_1", + "요청한 리소스를 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, + "COMMON500_1", + "예기치 않은 서버 에러가 발생했습니다."), + REGION_NOT_FOUND(HttpStatus.NOT_FOUND, + "REGION404_1", + "존재하지 않는 지역입니다."), + FOOD_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, + "FOOD_CATEGORY404_1", + "존재하지 않는 음식 카테고리입니다."), + RESTAURANT_NOT_FOUND(HttpStatus.NOT_FOUND, + "RESTAURANT404_1", + "존재하지 않는 가게입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, + "USER404_1", + "존재하지 않는 유저입니다."), + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, + "MISSION404_1", + "존재하지 않는 미션입니다."), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralSuccessCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralSuccessCode.java new file mode 100644 index 0000000..967615c --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralSuccessCode.java @@ -0,0 +1,19 @@ +package com.example.umc9th.global.apiPayload.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GeneralSuccessCode implements BaseSuccessCode{ + + SUCCESS(HttpStatus.OK, + "SUCCESS", + "Success"), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} 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..bd7517f --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/exception/GeneralException.java @@ -0,0 +1,12 @@ +package com.example.umc9th.global.apiPayload.exception; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + + private final BaseErrorCode code; +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/example/umc9th/global/apiPayload/handler/GeneralExceptionAdvice.java new file mode 100644 index 0000000..ddb9606 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -0,0 +1,116 @@ +package com.example.umc9th.global.apiPayload.handler; + +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import com.example.umc9th.global.notification.dto.DiscordMessage; +import com.example.umc9th.global.notification.service.DiscordNotificationService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; +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.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GeneralExceptionAdvice { + + private final DiscordNotificationService discordNotificationService; + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException( + ConstraintViolationException ex + ) { + String errorMessage = ex.getConstraintViolations().stream() + .map(violation -> violation.getMessage()) + .findFirst() + .orElse("Invalid Request"); + + BaseErrorCode code = GeneralErrorCode.BAD_REQUEST; + return ResponseEntity.status(code.getStatus()) + .body(ApiResponse.onFailure(code, errorMessage)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex + ) { + String errorMessage = Optional.ofNullable(ex.getBindingResult().getFieldError()) + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .orElse("Invalid Request"); + + BaseErrorCode code = GeneralErrorCode.BAD_REQUEST; + return ResponseEntity.status(code.getStatus()) + .body(ApiResponse.onFailure(code, errorMessage)); + } + + // 애플리케이션에서 발생하는 커스텀 예외를 처리 + @ExceptionHandler(GeneralException.class) + public ResponseEntity> handleException( + GeneralException ex + ) { + + return ResponseEntity.status(ex.getCode().getStatus()) + .body(ApiResponse.onFailure( + ex.getCode(), + null + ) + ); + } + + // 그 외의 정의되지 않은 모든 예외 처리 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException( + Exception ex, + HttpServletRequest request + ) { + + sendDiscordAlert(ex, request); + + BaseErrorCode code = GeneralErrorCode.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(code.getStatus()) + .body(ApiResponse.onFailure( + code, + ex.getMessage() + ) + ); + } + + private void sendDiscordAlert(Exception ex, HttpServletRequest request) { + String alertTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + String exceptionName = ex.getClass().getSimpleName(); + String exceptionMessage = ex.getMessage(); + String requestUri = request.getRequestURI(); + String requestMethod = request.getMethod(); + + String description = String.format( + "## 🚨 500 Internal Server Error 🚨\n\n" + + "**- 발생 시각**: %s\n" + + "**- 요청 URI**: %s\n" + + "**- HTTP 메서드**: %s\n" + + "**- 예외 클래스**: %s\n" + + "**- 예외 메시지**: %s\n", + alertTime, requestUri, requestMethod, exceptionName, exceptionMessage + ); + + DiscordMessage.Embed embed = DiscordMessage.Embed.builder() + .title("🔥 서버 에러 발생 🔥") + .description(description) + .color(15158332) // Red color + .build(); + + DiscordMessage discordMessage = DiscordMessage.builder() + .content("서버 에러가 발생했습니다.") + .embeds(new DiscordMessage.Embed[]{embed}) + .build(); + + discordNotificationService.sendMessage(discordMessage); + } +} diff --git a/src/main/java/com/example/umc9th/global/auth/CustomUserDetails.java b/src/main/java/com/example/umc9th/global/auth/CustomUserDetails.java new file mode 100644 index 0000000..0c5bca5 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/auth/CustomUserDetails.java @@ -0,0 +1,56 @@ +package com.example.umc9th.global.auth; + +import com.example.umc9th.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRole().toString(); + } + }); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} + diff --git a/src/main/java/com/example/umc9th/global/auth/CustomUserDetailsService.java b/src/main/java/com/example/umc9th/global/auth/CustomUserDetailsService.java new file mode 100644 index 0000000..0953b00 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/auth/CustomUserDetailsService.java @@ -0,0 +1,28 @@ +package com.example.umc9th.global.auth; + +import com.example.umc9th.domain.user.entity.User; +import com.example.umc9th.domain.user.repository.UserRepository; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + // 검증할 User 조회 + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND)); + // CustomUserDetails 반환 + return new CustomUserDetails(user); + } +} + diff --git a/src/main/java/com/example/umc9th/global/auth/enums/Role.java b/src/main/java/com/example/umc9th/global/auth/enums/Role.java new file mode 100644 index 0000000..487d0aa --- /dev/null +++ b/src/main/java/com/example/umc9th/global/auth/enums/Role.java @@ -0,0 +1,6 @@ +package com.example.umc9th.global.auth.enums; + +public enum Role { + ROLE_ADMIN, ROLE_USER +} + diff --git a/src/main/java/com/example/umc9th/global/auth/jwt/JwtAuthFilter.java b/src/main/java/com/example/umc9th/global/auth/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..18534a2 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/auth/jwt/JwtAuthFilter.java @@ -0,0 +1,73 @@ +package com.example.umc9th.global.auth.jwt; + +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.auth.CustomUserDetailsService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 + String email = jwtUtil.getEmail(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } catch (Exception e) { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + ApiResponse errorResponse = ApiResponse.onFailure( + GeneralErrorCode.UNAUTHORIZED, + null + ); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} + diff --git a/src/main/java/com/example/umc9th/global/auth/jwt/JwtUtil.java b/src/main/java/com/example/umc9th/global/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..f01b925 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/auth/jwt/JwtUtil.java @@ -0,0 +1,94 @@ +package com.example.umc9th.global.auth.jwt; + +import com.example.umc9th.global.auth.CustomUserDetails; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(CustomUserDetails user) { + return createToken(user, accessExpiration); + } + + /** 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 + } catch (JwtException e) { + return null; + } + } + + /** 토큰 유효성 확인 + * + * @param token 유효한지 확인할 토큰 + * @return True, False 반환합니다 + */ + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(CustomUserDetails user, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(user.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", user.getUsername()) + .issuedAt(Date.from(now)) // 언제 발급한지 + .expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지 + .signWith(secretKey) // sign할 Key + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} + diff --git a/src/main/java/com/example/umc9th/global/config/QuerydslConfig.java b/src/main/java/com/example/umc9th/global/config/QuerydslConfig.java new file mode 100644 index 0000000..c3e7a96 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/QuerydslConfig.java @@ -0,0 +1,27 @@ +package com.example.umc9th.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} + + + + + + + + diff --git a/src/main/java/com/example/umc9th/global/config/RestTemplateConfig.java b/src/main/java/com/example/umc9th/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..3c6b9d2 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.example.umc9th.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/example/umc9th/global/config/SecurityConfig.java b/src/main/java/com/example/umc9th/global/config/SecurityConfig.java new file mode 100644 index 0000000..460926f --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.example.umc9th.global.config; + +import com.example.umc9th.global.auth.CustomUserDetailsService; +import com.example.umc9th.global.auth.jwt.JwtAuthFilter; +import com.example.umc9th.global.auth.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + private final String[] allowUris = { + "/users/sign-up", + "/users/login", + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + // 폼로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // JwtAuthFilter를 UsernamePasswordAuthenticationFilter 앞에 추가 + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) + .csrf(AbstractHttpConfigurer::disable) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ); + + return http.build(); + } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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..c4f76d8 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/SwaggerConfig.java @@ -0,0 +1,36 @@ +package com.example.umc9th.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.Components; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI swagger() { + Info info = new Info().title("Project").description("Project Swagger").version("0.0.1"); + + // JWT 토큰 헤더 방식 + String securityScheme = "JWT TOKEN"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(securityScheme); + + Components components = new Components() + .addSecuritySchemes(securityScheme, new SecurityScheme() + .name(securityScheme) + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .info(info) + .addServersItem(new Server().url("/")) + .addSecurityItem(securityRequirement) + .components(components); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/notification/dto/DiscordMessage.java b/src/main/java/com/example/umc9th/global/notification/dto/DiscordMessage.java new file mode 100644 index 0000000..4aed1fb --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notification/dto/DiscordMessage.java @@ -0,0 +1,22 @@ +package com.example.umc9th.global.notification.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class DiscordMessage { + private String content; + + @JsonProperty("embeds") + private Embed[] embeds; + + @Getter + @Builder + public static class Embed { + private String title; + private String description; + private int color; + } +} diff --git a/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationService.java b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationService.java new file mode 100644 index 0000000..6bde6a9 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationService.java @@ -0,0 +1,7 @@ +package com.example.umc9th.global.notification.service; + +import com.example.umc9th.global.notification.dto.DiscordMessage; + +public interface DiscordNotificationService { + void sendMessage(DiscordMessage message); +} diff --git a/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationServiceImpl.java b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationServiceImpl.java new file mode 100644 index 0000000..74c476d --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationServiceImpl.java @@ -0,0 +1,29 @@ +package com.example.umc9th.global.notification.service; + +import com.example.umc9th.global.notification.dto.DiscordMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DiscordNotificationServiceImpl implements DiscordNotificationService { + + @Value("${discord.webhook.url}") + private String discordWebhookUrl; + + private final RestTemplate restTemplate; + + @Override + public void sendMessage(DiscordMessage message) { + try { + log.info("Sending Discord notification."); + restTemplate.postForObject(discordWebhookUrl, message, String.class); + } catch (Exception e) { + log.error("Failed to send Discord notification.", e); + } + } +} diff --git a/src/main/java/com/example/umc9th/global/validation/annotation/CheckPage.java b/src/main/java/com/example/umc9th/global/validation/annotation/CheckPage.java new file mode 100644 index 0000000..9796329 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/validation/annotation/CheckPage.java @@ -0,0 +1,21 @@ +package com.example.umc9th.global.validation.annotation; + +import com.example.umc9th.global.validation.validator.CheckPageValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = CheckPageValidator.class) +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CheckPage { + + String message() default "페이지 번호는 1 이상이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} + diff --git a/src/main/java/com/example/umc9th/global/validation/validator/CheckPageValidator.java b/src/main/java/com/example/umc9th/global/validation/validator/CheckPageValidator.java new file mode 100644 index 0000000..3aed662 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/validation/validator/CheckPageValidator.java @@ -0,0 +1,25 @@ +package com.example.umc9th.global.validation.validator; + +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.validation.annotation.CheckPage; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class CheckPageValidator implements ConstraintValidator { + + @Override + public void initialize(CheckPage constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + if (value == null || value < 1) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(GeneralErrorCode.BAD_REQUEST.toString()).addConstraintViolation(); + return false; + } + return true; + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 29ca312..d87512f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,4 +16,14 @@ spring: ddl-auto: update # 애플리케이션 실행 시 데이터베이스 스키마의 상태를 설정 properties: hibernate: - format_sql: true # 출력되는 SQL 쿼리를 보기 좋게 포맷팅 \ No newline at end of file + format_sql: true # 출력되는 SQL 쿼리를 보기 좋게 포맷팅 + +jwt: + token: + secretKey: ZGh3YWlkc2F2ZXdhZXZ3b2EgMTM5ZXUgMDMxdWMyIHEyMiBAIDAgKTJFVio= + expiration: + access: 14400000 + +discord: + webhook: + url: "https://discord.com/api/webhooks/1438720476204498987/7NE_rXydx9r2fy0HKRtGMmYiNoiDrGcy_9aQbh24XMOG3x3kWYPbqbJ7s4OHfBOv2YnM" \ No newline at end of file