diff --git a/leeseo/umc10th/build.gradle b/leeseo/umc10th/build.gradle index 93fc11f..d543f5e 100644 --- a/leeseo/umc10th/build.gradle +++ b/leeseo/umc10th/build.gradle @@ -32,6 +32,22 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1' + + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Security + 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' + + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..25ebc3b --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthController.java @@ -0,0 +1,37 @@ +package com.example.umc10th.domain.auth.controller; + +import com.example.umc10th.domain.auth.dto.AuthReqDto; +import com.example.umc10th.domain.auth.dto.AuthResDto; +import com.example.umc10th.domain.auth.exception.code.AuthSuccessCode; +import com.example.umc10th.domain.auth.service.AuthService; +import com.example.umc10th.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +@Tag(name = "Auth", description = "인증 관련 API") +public class AuthController implements AuthControllerDocs{ + + private final AuthService authService; + + @PostMapping("/sign-up") + public ApiResponse signUp( + @RequestBody @Valid AuthReqDto.Signup dto + ) { + return ApiResponse.onSuccess(AuthSuccessCode.SIGNUP_OK, authService.signUp(dto)); + } + + @PostMapping("/login") + public ApiResponse login( + @RequestBody @Valid AuthReqDto.Login dto + ) { + return ApiResponse.onSuccess(AuthSuccessCode.LOGIN_OK, authService.login(dto)); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthControllerDocs.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthControllerDocs.java new file mode 100644 index 0000000..b68adba --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/controller/AuthControllerDocs.java @@ -0,0 +1,27 @@ +package com.example.umc10th.domain.auth.controller; + +import com.example.umc10th.domain.auth.dto.AuthReqDto; +import com.example.umc10th.domain.auth.dto.AuthResDto; +import com.example.umc10th.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; + +public interface AuthControllerDocs { + + @Operation( + summary = "회원가입", + description = "이메일 회원가입 진행" + ) + public ApiResponse signUp( + @RequestBody @Valid AuthReqDto.Signup dto + ); + + @Operation( + summary = "이메일 로그인", + description = "이메일 로그인 진행" + ) + public ApiResponse login( + @RequestBody @Valid AuthReqDto.Login dto + ); +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/converter/AuthConverter.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/converter/AuthConverter.java new file mode 100644 index 0000000..37ccd9e --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/converter/AuthConverter.java @@ -0,0 +1,49 @@ +package com.example.umc10th.domain.auth.converter; + +import com.example.umc10th.domain.auth.dto.AuthReqDto; +import com.example.umc10th.domain.auth.dto.AuthResDto; +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; +import com.example.umc10th.global.security.dto.OAuthDto; + +import java.util.UUID; + + +public class AuthConverter { + + public static Member toMember( + AuthReqDto.Signup dto, + String salt + ) { + return Member.builder() + .email(dto.email()) + .password(salt) + .nickname(dto.nickname()) + .gender(dto.gender()) + .birth(dto.birth()) + .address(dto.address()) + .fullAddress(dto.fullAddress()) + .socialId("email"+UUID.randomUUID()) + .socialType(SocialType.EMAIL) + .build(); + } + + public static Member toSocialMember( + OAuthDto dto + ) { + return Member.builder() + .email(dto.getSocialEmail()) + .nickname(dto.getName()) + .socialId(dto.getSocialUid()) + .socialType(dto.getSocialType()) + .build(); + } + + public static AuthResDto.AccessToken toAccessToken( + String accessToken + ) { + return AuthResDto.AccessToken.builder() + .accessToken(accessToken) + .build(); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthReqDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthReqDto.java new file mode 100644 index 0000000..00018be --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthReqDto.java @@ -0,0 +1,47 @@ +package com.example.umc10th.domain.auth.dto; + +import com.example.umc10th.domain.member.dto.MemberReqDto; +import com.example.umc10th.domain.member.enums.FoodType; +import com.example.umc10th.domain.member.enums.Gender; +import com.example.umc10th.domain.mission.enums.Address; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +public class AuthReqDto { + + @Schema(name = "SignUp", description = "회원가입 정보를 저장합합니다.") + public record Signup ( + @Email + @NotBlank + String email, + @NotBlank + String password, + @NotBlank + String nickname, + @NotNull + Gender gender, + @NotNull + LocalDate birth, + @NotNull + Address address, + @NotBlank + String fullAddress, + @NotNull + List food + ) {} + + @Schema(name = "Login", description = "로그인 정보를 저장합니다.") + public record Login ( + @Email + @NotBlank + String email, + @NotBlank + String password + ) {} +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthResDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthResDto.java new file mode 100644 index 0000000..97c67a3 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/dto/AuthResDto.java @@ -0,0 +1,18 @@ +package com.example.umc10th.domain.auth.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class AuthResDto { + + @Builder + public record Id( + Long id + ) {} + + @Builder + public record AccessToken( + String accessToken + ) {} +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/AuthException.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/AuthException.java new file mode 100644 index 0000000..9df4eb4 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/AuthException.java @@ -0,0 +1,10 @@ +package com.example.umc10th.domain.auth.exception; + +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.exception.BaseException; + +public class AuthException extends BaseException { + public AuthException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/code/AuthErrorCode.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/code/AuthErrorCode.java new file mode 100644 index 0000000..1c30279 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/code/AuthErrorCode.java @@ -0,0 +1,18 @@ +package com.example.umc10th.domain.auth.exception.code; + +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements BaseErrorCode { + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, + "AUTH400_1", + "잘못된 비밀번호입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/code/AuthSuccessCode.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/code/AuthSuccessCode.java new file mode 100644 index 0000000..ce93630 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/exception/code/AuthSuccessCode.java @@ -0,0 +1,23 @@ +package com.example.umc10th.domain.auth.exception.code; + +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthSuccessCode implements BaseSuccessCode { + + SIGNUP_OK(HttpStatus.OK, + "AUTH200_1", + "회원가입 되었습니다."), + + LOGIN_OK(HttpStatus.OK, + "AUTH200_2", + "로그인 되었습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthService.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthService.java new file mode 100644 index 0000000..3a73035 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/auth/service/AuthService.java @@ -0,0 +1,69 @@ +package com.example.umc10th.domain.auth.service; + +import com.example.umc10th.domain.auth.converter.AuthConverter; +import com.example.umc10th.domain.auth.dto.AuthReqDto; +import com.example.umc10th.domain.auth.dto.AuthResDto; +import com.example.umc10th.domain.auth.exception.AuthException; +import com.example.umc10th.domain.auth.exception.code.AuthErrorCode; +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.entity.Food; +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.FoodType; +import com.example.umc10th.domain.member.exception.FoodException; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.FoodErrorCode; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.FoodRepository; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.domain.member.service.MemberService; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final FoodRepository foodRepository; + private final PasswordEncoder passwordEncoder; + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + @Transactional + public AuthResDto.Id signUp(AuthReqDto.Signup dto) { + String salt = passwordEncoder.encode(dto.password()); + Member member = AuthConverter.toMember(dto, salt); + List list = new ArrayList<>(); + if (dto.food() != null) { + for (FoodType f : dto.food()) { + Food food = foodRepository.findByName(f) + .orElseThrow(() -> new FoodException(FoodErrorCode.FOOD_TYPE_NOT_FOUND)); + list.add(food); + } + } + memberRepository.save(member); + member.updateFoodList(list); + return AuthResDto.Id.builder().id(member.getId()).build(); + } + + public AuthResDto.AccessToken login(AuthReqDto.Login dto) { + Member member = memberRepository.findByEmail(dto.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (!passwordEncoder.matches(dto.password(), member.getPassword())) { + throw new AuthException(AuthErrorCode.INVALID_PASSWORD); + } + + AuthMember authMember = new AuthMember(member); + String accessToken = jwtUtil.createAccessToken(authMember); + + return AuthConverter.toAccessToken(accessToken); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index 67f9411..a171838 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -2,17 +2,19 @@ import com.example.umc10th.domain.member.dto.MemberReqDto; import com.example.umc10th.domain.member.dto.MemberResDto; -import com.example.umc10th.domain.member.exception.code.MemberErrorCode; import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; import com.example.umc10th.domain.member.service.MemberService; import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.security.entity.AuthMember; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController -@RequestMapping("/api/vi/members") +@RequestMapping("/api/v1/members") @Tag(name = "Member", description = "회원 관련 API") public class MemberController implements MemberControllerDocs{ @@ -27,8 +29,16 @@ public ApiResponse saveTermAgreement( } @GetMapping("/me/profile") + public ApiResponse getMyProfile( + @AuthenticationPrincipal AuthMember member + ) { + MemberResDto.Profile response = memberService.getMyProfile(member.getMember()); + return ApiResponse.onSuccess(MemberSuccessCode.PROFILE_GET_OK, response); + } + + @GetMapping("/{id}/profile") public ApiResponse getProfile( - @RequestParam Long id + @PathVariable Long id ) { MemberResDto.Profile response = memberService.getProfile(id); return ApiResponse.onSuccess(MemberSuccessCode.PROFILE_GET_OK, response); @@ -36,7 +46,7 @@ public ApiResponse getProfile( @PatchMapping("/me/profile") public ApiResponse updateProfile( - @RequestBody MemberReqDto.Profile dto + @RequestBody @Valid MemberReqDto.Profile dto ) { memberService.updateProfile(dto); return ApiResponse.onSuccess(MemberSuccessCode.PROFILE_PATCH_OK, null); @@ -44,7 +54,7 @@ public ApiResponse updateProfile( @PatchMapping("/me/nickname") public ApiResponse updateNickname( - @RequestBody MemberReqDto.Nickname dto + @RequestBody @Valid MemberReqDto.Nickname dto ) { memberService.updateNickname(dto); return ApiResponse.onSuccess(MemberSuccessCode.NICKNAME_PATCH_OK, null); diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberControllerDocs.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberControllerDocs.java index 45ee6b1..7ca7c90 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberControllerDocs.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberControllerDocs.java @@ -3,8 +3,11 @@ import com.example.umc10th.domain.member.dto.MemberReqDto; import com.example.umc10th.domain.member.dto.MemberResDto; import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.security.entity.AuthMember; import io.swagger.v3.oas.annotations.Operation; -import org.springframework.web.bind.annotation.PatchMapping; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -19,11 +22,19 @@ public ApiResponse saveTermAgreement( ); @Operation( - summary = "회원정보 조회", + summary = "내 프로필 조회", + description = "내 프로필 정보를 조회합니다." + ) + public ApiResponse getMyProfile( + @AuthenticationPrincipal AuthMember member + ); + + @Operation( + summary = "타인 프로필 조회", description = "회원 프로필 정보를 조회합니다." ) public ApiResponse getProfile( - @RequestParam Long id + @PathVariable Long id ); @Operation( @@ -31,15 +42,14 @@ public ApiResponse getProfile( description = "회원 프로필 정보를 저장합니다." ) public ApiResponse updateProfile( - @RequestBody MemberReqDto.Profile dto + @RequestBody @Valid MemberReqDto.Profile dto ); @Operation( summary = "닉네임 수정", description = "닉네임을 수정합니다." ) - @PatchMapping("/me/nickname") public ApiResponse updateNickname( - @RequestBody MemberReqDto.Nickname dto + @RequestBody @Valid MemberReqDto.Nickname dto ); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index 7e73c88..2a4fc63 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -17,7 +17,7 @@ public static MemberResDto.Profile toProfile( .address(member.getAddress()) .fullAddress(member.getFullAddress()) .gender(member.getGender()) - .food(member.getMemberFoodList().stream().map(MemberFood::getFood).toList()) + .food(member.getMemberFoodList().stream().map(mf -> mf.getFood().getName()).toList()) .build(); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java index c2ee939..daf2e1a 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java @@ -3,6 +3,9 @@ import com.example.umc10th.domain.member.enums.FoodType; import com.example.umc10th.domain.member.enums.Gender; import com.example.umc10th.domain.mission.enums.Address; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.util.List; @@ -13,23 +16,29 @@ public record TermList ( List terms ) {} + @Schema(name = "Term", description = "약관별 동의 여부를 저장합니다.") public record Term ( Long termId, Boolean agreed ) {} + @Schema(name = "Profile", description = "수정할 프로필 정보를 저장합합니다.") public record Profile ( - Long id, - String nickname, - Gender gender, - LocalDate birth, - Address address, - String fullAddress, - List food + @NotNull(message = "사용자 ID는 필수입니다.") + Long id, + String nickname, + Gender gender, + LocalDate birth, + Address address, + String fullAddress, + List food ) {} + @Schema(name = "Nickname", description = "수정할 닉네임을 저장합합니다.") public record Nickname ( - Long id, - String nickname + @NotNull(message = "사용자 ID는 필수입니다.") + Long id, + @NotBlank(message = "닉네임 입력은 필수입니다.") + String nickname ) {} } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java index 126d6b3..6635d24 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java @@ -1,6 +1,7 @@ package com.example.umc10th.domain.member.dto; import com.example.umc10th.domain.member.entity.Food; +import com.example.umc10th.domain.member.enums.FoodType; import com.example.umc10th.domain.member.enums.Gender; import com.example.umc10th.domain.mission.enums.Address; import lombok.Builder; @@ -18,6 +19,6 @@ public record Profile ( LocalDate birth, Address address, String fullAddress, - List food + List food ) {} } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 0e12bde..9d42b23 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -47,6 +47,9 @@ public class Member extends BaseEntity { @Column(name = "email", unique = true) private String email; + @Column(name = "password") + private String password; + @Column(name = "phone_number", unique = true) private String phoneNumber; @@ -80,13 +83,14 @@ public class Member extends BaseEntity { @Column(name = "is_owner", nullable = false) private Boolean isOwner = false; - @OneToMany(mappedBy = "member") + @Builder.Default + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private List memberFoodList = new ArrayList<>(); - @OneToMany(mappedBy = "member") + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private List memberTermList = new ArrayList<>(); - @OneToMany(mappedBy = "member") + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private List memberMissionList = new ArrayList<>(); @OneToMany(mappedBy = "member") @@ -113,6 +117,7 @@ public void updateFullAddress(String fullAddress) { } public void updateFoodList(List foodList) { + if (foodList == null) return; List list = new ArrayList<>(); for (Food food : foodList) { list.add(MemberConverter.toMemberFood(food, this)); diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java index b14fe2d..54898d4 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/enums/SocialType.java @@ -1,5 +1,5 @@ package com.example.umc10th.domain.member.enums; public enum SocialType { - KAKAO, GOOGLE, NAVER, APPLE + KAKAO, GOOGLE, NAVER, APPLE, EMAIL } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index 17d5597..94e84b3 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -10,7 +10,15 @@ public enum MemberErrorCode implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", - "해당 아이디의 회원이 존재하지 않습니다."); + "해당 아이디의 회원이 존재하지 않습니다."), + + BAD_MEMBER_INFO(HttpStatus.BAD_REQUEST, + "MEMBER400_1", + "해당 정보를 저장할 수 없습니다."), + + NOT_SUPPORT_SOCIAL_PROVIDER(HttpStatus.BAD_REQUEST, + "MEMBER400_2", + "허용하지 않는 소셜 타입입니다."); private final HttpStatus status; private final String code; diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index b60402f..c2c893d 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -1,10 +1,22 @@ package com.example.umc10th.domain.member.repository; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; public interface MemberRepository extends JpaRepository { Optional findMemberById(Long id); + @Query(""" + select m from Member m + join fetch m.memberFoodList mf + join fetch mf.food + where m.id = :id + """) + Optional findMemberWithFoods(Long id); + Optional findByEmail(String email); + + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialUid); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index b9a2fed..2816491 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -26,16 +26,21 @@ public class MemberService { private final MemberRepository memberRepository; private final FoodRepository foodRepository; + public MemberResDto.Profile getMyProfile(Member member) { + Member memberWithFood = memberRepository.findMemberWithFoods(member.getId()) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return MemberConverter.toProfile(memberWithFood); + } + public MemberResDto.Profile getProfile(Long id) { - Member member = memberRepository.findMemberById(id) + Member memberWithFood = memberRepository.findMemberWithFoods(id) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - return MemberConverter.toProfile(member); + return MemberConverter.toProfile(memberWithFood); } @Transactional public void updateProfile(MemberReqDto.Profile dto) { - Member member = memberRepository.findMemberById(dto.id()) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + Member member = getMemberById(dto.id()); if (dto.nickname() != null) { member.updateNickname(dto.nickname()); @@ -71,11 +76,16 @@ public void updateProfile(MemberReqDto.Profile dto) { @Transactional public void updateNickname(MemberReqDto.Nickname dto) { - Member member = memberRepository.findMemberById(dto.id()) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + Member member = getMemberById(dto.id()); if (dto.nickname() != null) { member.updateNickname(dto.nickname()); } } + + private Member getMemberById(Long id) { + Member member = memberRepository.findMemberById(id) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return member; + } } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java index e1f3999..6906ac9 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java @@ -1,17 +1,15 @@ package com.example.umc10th.domain.mission.controller; -import com.example.umc10th.domain.mission.dto.Achievement; -import com.example.umc10th.domain.mission.dto.MemberMissionInfo; -import com.example.umc10th.domain.mission.dto.MissionInfo; -import com.example.umc10th.domain.mission.dto.OwnerNumber; +import com.example.umc10th.domain.mission.dto.*; import com.example.umc10th.domain.mission.enums.Address; import com.example.umc10th.domain.mission.enums.Status; import com.example.umc10th.domain.mission.exception.code.MissionSuccessCode; import com.example.umc10th.domain.mission.service.MissionService; import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.security.entity.AuthMember; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @@ -25,37 +23,41 @@ public class MissionController implements MissionControllerDocs{ @GetMapping("/missions/achievement-rate") public ApiResponse getMissionAchievement( @RequestParam Address location, - @RequestParam Long memberId - ) { - Achievement response = missionService.getMissionAchievement(location, memberId); + @AuthenticationPrincipal AuthMember member + ) { + Achievement response = missionService.getMissionAchievement(location, member.getMember().getId()); return ApiResponse.onSuccess(MissionSuccessCode.ACHIEVEMENT_GET_OK, response); } @GetMapping("/missions") - public ApiResponse> getMissionList( + public ApiResponse> getMissionList( @RequestParam Address location, - @RequestParam Long cursor + @RequestParam Integer pageSize, + @RequestParam Integer pageNumber, + @RequestParam (required = false) String sort ) { - Slice response = missionService.getMissionList(location, cursor); + MissionResDto.Pagination response = missionService.getMissionList(location, pageSize, pageNumber, sort); return ApiResponse.onSuccess(MissionSuccessCode.MISSION_LIST_GET_OK, response); } @PostMapping("/members/me/missions/{missionId}") public ApiResponse saveMemberMission( @PathVariable Long missionId, - @RequestParam Long memberId + @AuthenticationPrincipal AuthMember member ) { - missionService.saveMemberMission(missionId,memberId); + missionService.saveMemberMission(missionId,member.getMember().getId()); return ApiResponse.onSuccess(MissionSuccessCode.MEMBER_MISSION_POST_OK,null); } @GetMapping("/members/me/missions") - public ApiResponse> getMyMissions( + public ApiResponse> getMyMissions( @RequestParam Status status, - @RequestParam Long memberId, - @RequestParam Long cursor + @AuthenticationPrincipal AuthMember member, + @RequestParam Integer pageSize, + @RequestParam Integer pageNumber, + @RequestParam (required = false) String sort ) { - Slice response = missionService.getMyMissionList(status, memberId, cursor); + MissionResDto.Pagination response = missionService.getMyMissionList(status, member.getMember().getId(), pageSize, pageNumber, sort); return ApiResponse.onSuccess(MissionSuccessCode.MY_MISSION_LIST_GET_OK, response); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionControllerDocs.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionControllerDocs.java index 5492650..037d6b8 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionControllerDocs.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionControllerDocs.java @@ -1,14 +1,12 @@ package com.example.umc10th.domain.mission.controller; -import com.example.umc10th.domain.mission.dto.Achievement; -import com.example.umc10th.domain.mission.dto.MemberMissionInfo; -import com.example.umc10th.domain.mission.dto.MissionInfo; -import com.example.umc10th.domain.mission.dto.OwnerNumber; +import com.example.umc10th.domain.mission.dto.*; import com.example.umc10th.domain.mission.enums.Address; import com.example.umc10th.domain.mission.enums.Status; import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.security.entity.AuthMember; import io.swagger.v3.oas.annotations.Operation; -import org.springframework.data.domain.Slice; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @@ -20,16 +18,18 @@ public interface MissionControllerDocs { ) public ApiResponse getMissionAchievement( @RequestParam Address location, - @RequestParam Long memberId + @AuthenticationPrincipal AuthMember member ); @Operation( summary = "지역별 미션 조회", description = "해당 지역의 미션 목록을 조회합니다." ) - public ApiResponse> getMissionList( + public ApiResponse> getMissionList( @RequestParam Address location, - @RequestParam Long cursor + @RequestParam Integer pageSize, + @RequestParam Integer pageNumber, + @RequestParam (required = false) String sort ); @Operation( @@ -38,17 +38,19 @@ public ApiResponse> getMissionList( ) public ApiResponse saveMemberMission( @PathVariable Long missionId, - @RequestParam Long memberId + @AuthenticationPrincipal AuthMember member ); @Operation( summary = "진행중/진행완료 미션 조회", description = "내 미션 목록을 진행 상태별로 조회합니다." ) - public ApiResponse> getMyMissions( + public ApiResponse> getMyMissions( @RequestParam Status status, - @RequestParam Long missionId, - @RequestParam Long cursor + @AuthenticationPrincipal AuthMember member, + @RequestParam Integer pageSize, + @RequestParam Integer pageNumber, + @RequestParam (required = false) String sort ); @Operation( diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java index ada0bf5..df94b10 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java @@ -1,10 +1,14 @@ package com.example.umc10th.domain.mission.converter; +import com.example.umc10th.domain.member.dto.MemberResDto; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.mission.dto.MissionResDto; import com.example.umc10th.domain.mission.dto.OwnerNumber; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; +import java.util.List; + public class MissionConverter { public static MemberMission toMemberMission( Member member, @@ -23,4 +27,16 @@ public static OwnerNumber toOwnerNumber ( .ownerNumber(mission.getSuccessNumber()) .build(); } + + public static MissionResDto.Pagination toPagination( + List data, + Integer pageNumber, + Integer pageSize + ) { + return MissionResDto.Pagination.builder() + .data(data) + .pageNumber(pageNumber) + .pageSize(pageSize) + .build(); + } } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java index a688215..280bf67 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java @@ -1,4 +1,13 @@ package com.example.umc10th.domain.mission.dto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + public class MissionReqDto { + + @Schema(name = "MemberID", description = "회원 아이디를 입력합니다.") + public record MemberId( + @NotNull + Long id + ){} } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDto.java new file mode 100644 index 0000000..1cd694a --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDto.java @@ -0,0 +1,15 @@ +package com.example.umc10th.domain.mission.dto; + +import lombok.Builder; + +import java.util.List; + +public class MissionResDto { + + @Builder + public record Pagination( + List data, + Integer pageNumber, + Integer pageSize + ){} +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java index 49f7067..f956542 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Mission.java @@ -35,7 +35,7 @@ public class Mission { @Column(name = "rewards_point", nullable = false) private Integer rewardsPoint; - @OneToMany(mappedBy = "mission") + @OneToMany(mappedBy = "mission", cascade = CascadeType.ALL, orphanRemoval = true) private List memberMissionList = new ArrayList<>(); @ManyToOne(fetch = FetchType.LAZY) diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Store.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Store.java index 7b64918..42b0d85 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Store.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/entity/Store.java @@ -43,9 +43,9 @@ public class Store { @JoinColumn(name = "location_id") private Location location; - @OneToMany(mappedBy = "store") + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List reviewList = new ArrayList<>(); - @OneToMany(mappedBy = "store") + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List reviewPhotoList = new ArrayList<>(); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java index e4b1d88..613ae48 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java @@ -7,6 +7,8 @@ import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.enums.Address; import com.example.umc10th.domain.mission.enums.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -38,8 +40,6 @@ public interface MemberMissionRepository extends JpaRepository getMissionByStatus(Status status, Long cursor, Pageable pageable,Long memberId); + Page getMissionByStatus(Status status, Long memberId, PageRequest pageRequest); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java index 8f63f9e..6c5bd08 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java @@ -3,10 +3,10 @@ import com.example.umc10th.domain.mission.dto.MissionInfo; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.enums.Address; -import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.domain.Pageable; import java.util.Optional; @@ -21,8 +21,6 @@ public interface MissionRepository extends JpaRepository { FROM Mission m JOIN m.store s WHERE s.address = :address - AND (:cursor IS NULL OR m.id < :cursor) - ORDER BY m.id DESC """) - Slice getMissionByAddress(Address address, Long cursor, Pageable pageable); + Page getMissionByAddress(Address address, PageRequest pageRequest); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java index 41ea1cc..626fbbe 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java @@ -5,10 +5,7 @@ import com.example.umc10th.domain.member.exception.code.MemberErrorCode; import com.example.umc10th.domain.member.repository.MemberRepository; import com.example.umc10th.domain.mission.converter.MissionConverter; -import com.example.umc10th.domain.mission.dto.Achievement; -import com.example.umc10th.domain.mission.dto.MemberMissionInfo; -import com.example.umc10th.domain.mission.dto.MissionInfo; -import com.example.umc10th.domain.mission.dto.OwnerNumber; +import com.example.umc10th.domain.mission.dto.*; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.mission.entity.mapping.MemberMission; import com.example.umc10th.domain.mission.enums.Address; @@ -17,11 +14,10 @@ import com.example.umc10th.domain.mission.exception.code.MissionErrorCode; import com.example.umc10th.domain.mission.repository.MemberMissionRepository; import com.example.umc10th.domain.mission.repository.MissionRepository; +import com.example.umc10th.global.apiPayload.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; +import org.springframework.data.domain.*; import org.springframework.stereotype.Service; -import org.springframework.data.domain.Pageable; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -35,30 +31,69 @@ public class MissionService { private final MissionRepository missionRepository; public Achievement getMissionAchievement(Address address, Long memberId) { - Member member = memberRepository.findMemberById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + Member member = getMemberById(memberId); return memberMissionRepository.getTotalCountAndSuccessCount(address, member); } - public Slice getMissionList(Address address, Long cursor) { - Pageable pageable = PageRequest.of(0, 10); - return missionRepository.getMissionByAddress(address, cursor, pageable); + private Member getMemberById(Long memberId) { + Member member = memberRepository.findMemberById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return member; + } + + public MissionResDto.Pagination getMissionList( + Address address, + Integer pageSize, + Integer pageNumber, + String sort + ) { + Sort sortInfo; + if (sort != null){ + sortInfo = Sort.by(sort); + } else { + sortInfo = Sort.by("id").descending(); + } + + PageRequest pageRequest = PageRequest.of(pageNumber, pageSize, sortInfo); + Page missionList = missionRepository.getMissionByAddress(address, pageRequest); + return MissionConverter.toPagination( + missionList.stream().toList(), + missionList.getNumber(), + missionList.getSize() + ); } @Transactional public void saveMemberMission(Long missionId, Long memberId) { - Member member = memberRepository.findMemberById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + Member member = getMemberById(memberId); Mission mission = missionRepository.findMissionById(missionId) .orElseThrow(() -> new MissionException(MissionErrorCode.MISSION_NOT_FOUND)); MemberMission memberMission = MissionConverter.toMemberMission(member, mission); memberMissionRepository.save(memberMission); } - public Slice getMyMissionList(Status status, Long memberId, Long cursor) { - Pageable pageable = PageRequest.of(0, 10); - return memberMissionRepository.getMissionByStatus(status, cursor, pageable, memberId); + public MissionResDto.Pagination getMyMissionList( + Status status, + Long memberId, + Integer pageSize, + Integer pageNumber, + String sort + ) { + Sort sortInfo; + if (sort != null){ + sortInfo = Sort.by(sort); + } else { + sortInfo = Sort.by("id").descending(); + } + + PageRequest pageRequest = PageRequest.of(pageNumber, pageSize, sortInfo); + Page missionList = memberMissionRepository.getMissionByStatus(status, memberId, pageRequest); + return MissionConverter.toPagination( + missionList.stream().toList(), + missionList.getNumber(), + missionList.getSize() + ); } @Transactional diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java index 7fcc152..dd62a5c 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java @@ -3,11 +3,15 @@ import com.example.umc10th.domain.review.dto.ReviewInfo; import com.example.umc10th.domain.review.dto.ReviewPhotoUrl; import com.example.umc10th.domain.review.dto.ReviewReqDto; +import com.example.umc10th.domain.review.dto.ReviewResDto; import com.example.umc10th.domain.review.exception.code.ReviewSuccessCode; import com.example.umc10th.domain.review.service.ReviewService; import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.security.entity.AuthMember; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -23,26 +27,32 @@ public class ReviewController implements ReviewControllerDocs{ @PostMapping("/stores/{storeId}/reviews") public ApiResponse saveReview( @PathVariable Long storeId, - @RequestBody ReviewReqDto.Review dto, - @RequestParam Long memberId - ) { - reviewService.saveReview(storeId, memberId, dto); + @RequestBody @Valid ReviewReqDto.Review dto, + @AuthenticationPrincipal AuthMember member + ) { + reviewService.saveReview(storeId, member.getMember().getId(), dto); return ApiResponse.onSuccess(ReviewSuccessCode.REVIEW_POST_OK, null); } @GetMapping("/stores/{storeId}/reviews") - public ApiResponse> getReviewList( - @PathVariable Long storeId + public ApiResponse> getReviewList( + @PathVariable Long storeId, + @RequestParam Integer pageSize, + @RequestParam (required = false) String cursor, + @RequestParam String query ) { - List response = reviewService.getReviewList(storeId); + ReviewResDto.Pagination response = reviewService.getReviewList(storeId, pageSize, cursor, query); return ApiResponse.onSuccess(ReviewSuccessCode.REVIEW_LIST_GET_OK, response); } @GetMapping("/stores/{storeId}/reviews/photos") - public ApiResponse> getReviewPhotoList( - @PathVariable Long storeId + public ApiResponse> getReviewPhotoList( + @PathVariable @Valid Long storeId, + @RequestParam Integer pageSize, + @RequestParam (required = false) String cursor, + @RequestParam String query ) { - List response = reviewService.getReviewPhotoList(storeId); + ReviewResDto.Pagination response = reviewService.getReviewPhotoList(storeId, pageSize, cursor, query); return ApiResponse.onSuccess(ReviewSuccessCode.REVIEW_PHOTO_LIST_GET_OK, response); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewControllerDocs.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewControllerDocs.java index 4fccb18..1166168 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewControllerDocs.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewControllerDocs.java @@ -3,8 +3,11 @@ import com.example.umc10th.domain.review.dto.ReviewInfo; import com.example.umc10th.domain.review.dto.ReviewPhotoUrl; import com.example.umc10th.domain.review.dto.ReviewReqDto; +import com.example.umc10th.domain.review.dto.ReviewResDto; import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.security.entity.AuthMember; import io.swagger.v3.oas.annotations.Operation; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -20,22 +23,28 @@ public interface ReviewControllerDocs { public ApiResponse saveReview( @PathVariable Long storeId, @RequestBody ReviewReqDto.Review dto, - @RequestParam Long memberId + @AuthenticationPrincipal AuthMember member ); @Operation( summary = "리뷰 목록 조회", description = "리뷰 목록을 조회합니다." ) - public ApiResponse> getReviewList( - @PathVariable Long storeId - ); + public ApiResponse> getReviewList( + @PathVariable Long storeId, + @RequestParam Integer pageSize, + @RequestParam String cursor, + @RequestParam String query + ) ; @Operation( summary = "리뷰 사진 목록 조회", description = "리뷰 사진 목록을 조회합니다." ) - public ApiResponse> getReviewPhotoList( - @PathVariable Long storeId + public ApiResponse> getReviewPhotoList( + @PathVariable Long storeId, + @RequestParam Integer pageSize, + @RequestParam String cursor, + @RequestParam String query ); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java index ee38765..359bb32 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java @@ -5,9 +5,12 @@ import com.example.umc10th.domain.review.dto.ReviewInfo; import com.example.umc10th.domain.review.dto.ReviewPhotoUrl; import com.example.umc10th.domain.review.dto.ReviewReqDto; +import com.example.umc10th.domain.review.dto.ReviewResDto; import com.example.umc10th.domain.review.entity.Review; import com.example.umc10th.domain.review.entity.ReviewPhoto; +import java.util.List; + public class ReviewConverter { public static Review toReview( @@ -27,6 +30,7 @@ public static ReviewInfo toReviewInfo( Review review ) { return ReviewInfo.builder() + .reviewId(review.getId()) .rate(review.getRate()) .content(review.getContent()) .reviewerNickname(review.getMember().getNickname()) @@ -39,7 +43,23 @@ public static ReviewPhotoUrl toReviewPhotoUrl( ReviewPhoto reviewPhoto ) { return ReviewPhotoUrl.builder() + .reviewPhotoId(reviewPhoto.getId()) + .reviewRate(reviewPhoto.getReview().getRate()) .reviewPhoto(reviewPhoto.getImageUrl()) .build(); } + + public static ReviewResDto.Pagination toPagination( + List data, + boolean hasNext, + String nextCursor, + Integer pageSize + ) { + return ReviewResDto.Pagination.builder() + .data(data) + .hasNext(hasNext) + .pageSize(pageSize) + .nextCursor(nextCursor) + .build(); + } } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewInfo.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewInfo.java index 3aa43f5..9275035 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewInfo.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewInfo.java @@ -7,6 +7,7 @@ @Builder public record ReviewInfo( + Long reviewId, double rate, String content, List reviewPhotoList, diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewPhotoUrl.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewPhotoUrl.java index b4e7134..169d88c 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewPhotoUrl.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewPhotoUrl.java @@ -4,5 +4,7 @@ @Builder public record ReviewPhotoUrl( + Long reviewPhotoId, + Double reviewRate, String reviewPhoto ) {} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java index 0c1edd1..a8afe30 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java @@ -1,16 +1,26 @@ package com.example.umc10th.domain.review.dto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + import java.util.List; public class ReviewReqDto { + @Schema(name = "Review", description = "등록할 리뷰 정보를 저장합합니다.") public record Review( - Long rate, - String content, - List reviewPhotoList + @NotNull + Long rate, + @NotBlank + String content, + @NotNull + List reviewPhotoList ) {} + @Schema(name = "ReviewPhoto", description = "등록할 리뷰 사진들을 저장합합니다.") public record ReviewPhoto( - String reviewPhoto + @NotBlank + String reviewPhoto ) {} } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDto.java new file mode 100644 index 0000000..fd77259 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDto.java @@ -0,0 +1,16 @@ +package com.example.umc10th.domain.review.dto; + +import lombok.Builder; + +import java.util.List; + +public class ReviewResDto { + + @Builder + public record Pagination( + List data, + Boolean hasNext, + String nextCursor, + Integer pageSize + ){} +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/entity/Review.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/entity/Review.java index b1e431c..4839aad 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/entity/Review.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/entity/Review.java @@ -39,10 +39,10 @@ public class Review extends BaseEntity { @JoinColumn(name = "store_id") private Store store; - @OneToOne(fetch = FetchType.LAZY) + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "reply_id") private Reply reply; - @OneToMany(mappedBy = "review") + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true) List reviewPhotoList = new ArrayList<>(); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java index f55abb1..ca5c935 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java @@ -1,7 +1,11 @@ package com.example.umc10th.domain.review.exception; -public class ReviewException extends RuntimeException { - public ReviewException(String message) { - super(message); +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.exception.BaseException; + +public class ReviewException extends BaseException { + + public ReviewException(BaseErrorCode errorCode) { + super(errorCode); } } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java index c3ff795..6053ac8 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java @@ -1,4 +1,18 @@ package com.example.umc10th.domain.review.exception.code; -public enum ReviewErrorCode { +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ReviewErrorCode implements BaseErrorCode { + INVALID_QUERY(HttpStatus.BAD_REQUEST, + "REVIEW400_1", + "잘못된 쿼리 입니다."); + + private final HttpStatus status; + private final String code; + private final String message; } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java index 387dc3e..75cb716 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewSuccessCode.java @@ -9,15 +9,15 @@ @RequiredArgsConstructor public enum ReviewSuccessCode implements BaseSuccessCode { REVIEW_POST_OK(HttpStatus.OK, - "REVIEW200_1", + "REVIEW201_1", "리뷰가 작성되었습니다."), REVIEW_LIST_GET_OK(HttpStatus.OK, - "REVIEW200_2", + "REVIEW200_1", "리뷰 목록이 조회되었습니다."), REVIEW_PHOTO_LIST_GET_OK(HttpStatus.OK, - "REVIEW200_3", + "REVIEW200_2", "리뷰 사진 목록이 조회되었습니다."); private final HttpStatus status; diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewPhotoRepository.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewPhotoRepository.java index 79baebd..b46997d 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewPhotoRepository.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewPhotoRepository.java @@ -1,6 +1,9 @@ package com.example.umc10th.domain.review.repository; +import com.example.umc10th.domain.review.entity.Review; import com.example.umc10th.domain.review.entity.ReviewPhoto; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -15,4 +18,30 @@ public interface ReviewPhotoRepository extends JpaRepository WHERE s.id = :storeId """) List getReviewPhotoByStore(Long storeId); + + Slice findReviewPhotosByStore_IdAndIdLessThanOrderByIdDesc(Long storeId, Long idCursor, PageRequest pageRequest); + Slice findReviewPhotosByStore_IdOrderByIdDesc(Long storeId, PageRequest pageRequest); + + @Query(""" + SELECT rp + FROM ReviewPhoto rp + JOIN rp.store s + JOIN rp.review r + WHERE s.id = :storeId + AND ( + r.rate < :rateCursor + OR (r.rate = :rateCursor AND rp.id < :idCursor) + ) + ORDER BY r.rate DESC, rp.id DESC + """) + Slice findReviewPhotosByRateCursor(Long storeId, Double rateCursor, Long idCursor, PageRequest pageRequest); + @Query(""" + SELECT rp + FROM ReviewPhoto rp + JOIN rp.store s + JOIN rp.review r + WHERE s.id = :storeId + ORDER BY r.rate DESC, rp.id DESC + """) + Slice findReviewPhotosOrderByRate(Long storeId, PageRequest pageRequest); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java index fc85243..2c8486f 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java @@ -2,10 +2,43 @@ import com.example.umc10th.domain.mission.entity.Store; import com.example.umc10th.domain.review.entity.Review; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; public interface ReviewRepository extends JpaRepository { + @Query(""" + select distinct r from Review r + left join fetch r.reviewPhotoList rp + where r.store = :store + """) List getReviewsByStore(Store store); + + Store store(Store store); + + Slice findReviewsByStore_IdAndIdLessThanOrderByIdDesc(Long storeId, Long idCursor, PageRequest pageRequest); + Slice findReviewsByStore_IdOrderByIdDesc(Long storeId, PageRequest pageRequest); + + @Query(""" + SELECT r + FROM Review r + WHERE r.store.id = :storeId + AND ( + r.rate < :rateCursor + OR (r.rate = :rateCursor AND r.id < :idCursor) + ) + ORDER BY r.rate DESC, r.id DESC + """) + Slice findReviewsByRateCursor(Long storeId, Double rateCursor, Long idCursor, PageRequest pageRequest); + + @Query(""" + SELECT r + FROM Review r + WHERE r.store.id = :storeId + ORDER BY r.rate DESC, r.id DESC + """) + Slice findReviewsOrderByRate(Long storeId, PageRequest pageRequest); } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java index 15dec97..dd849dd 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java @@ -12,11 +12,16 @@ import com.example.umc10th.domain.review.dto.ReviewInfo; import com.example.umc10th.domain.review.dto.ReviewPhotoUrl; import com.example.umc10th.domain.review.dto.ReviewReqDto; +import com.example.umc10th.domain.review.dto.ReviewResDto; import com.example.umc10th.domain.review.entity.Review; import com.example.umc10th.domain.review.entity.ReviewPhoto; +import com.example.umc10th.domain.review.exception.ReviewException; +import com.example.umc10th.domain.review.exception.code.ReviewErrorCode; import com.example.umc10th.domain.review.repository.ReviewPhotoRepository; import com.example.umc10th.domain.review.repository.ReviewRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,23 +40,131 @@ public class ReviewService { public void saveReview(Long storeId, Long memberId, ReviewReqDto.Review dto) { Member member = memberRepository.findMemberById(memberId) .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); - Store store = storeRepository.findStoreById(storeId) - .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); + Store store = storeRepository.findStoreById(storeId).orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); Review review = ReviewConverter.toReview(member, store, dto); reviewRepository.save(review); } - public List getReviewList(Long storeId) { - Store store = storeRepository.findStoreById(storeId) - .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); - List reviewList = reviewRepository.getReviewsByStore(store); - return reviewList.stream().map(ReviewConverter::toReviewInfo).toList(); + public ReviewResDto.Pagination getReviewList(Long storeId, Integer pageSize, String cursor, String query) { + if (cursor == null) cursor = "-1"; + PageRequest pageRequest = PageRequest.of(0, pageSize); + long idCursor; + double rateCursor; + Slice reviewList; + String nextCursor; + + if (!cursor.equals("-1")){ + String[] cursorSplit = cursor.split(":"); + switch (query.toLowerCase()){ + case "id": + idCursor = Long.parseLong(cursorSplit[1]); + reviewList = reviewRepository.findReviewsByStore_IdAndIdLessThanOrderByIdDesc(storeId, idCursor, pageRequest); + break; + case "rate": + rateCursor = Double.parseDouble(cursorSplit[0]); + idCursor = Long.parseLong(cursorSplit[1]); + reviewList = reviewRepository.findReviewsByRateCursor(storeId, rateCursor, idCursor, pageRequest); + break; + default: + throw new ReviewException(ReviewErrorCode.INVALID_QUERY); + } + } else { + switch (query.toLowerCase()){ + case "id": + reviewList = reviewRepository.findReviewsByStore_IdOrderByIdDesc(storeId, pageRequest); + break; + case "rate": + reviewList = reviewRepository.findReviewsOrderByRate(storeId, pageRequest); + break; + default: + throw new ReviewException(ReviewErrorCode.INVALID_QUERY); + } + } + nextCursor = "-1"; + + if (!reviewList.isEmpty()) { + + Review lastReview = reviewList.getContent() + .get(reviewList.getNumberOfElements() - 1); + + switch (query.toLowerCase()) { + + case "id": + nextCursor = lastReview.getId() + ":" + lastReview.getId(); + break; + + case "rate": + nextCursor = lastReview.getRate() + ":" + lastReview.getId(); + break; + } + } + + return ReviewConverter.toPagination( + reviewList.map(ReviewConverter::toReviewInfo).toList(), + reviewList.hasNext(), + nextCursor, + reviewList.getSize()); } - public List getReviewPhotoList(Long storeId) { - Store store = storeRepository.findStoreById(storeId) - .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); - List reviewPhotoList = reviewPhotoRepository.getReviewPhotoByStore(storeId); - return reviewPhotoList.stream().map(ReviewConverter::toReviewPhotoUrl).toList(); + public ReviewResDto.Pagination getReviewPhotoList(Long storeId, Integer pageSize, String cursor, String query) { + if (cursor == null) cursor = "-1"; + PageRequest pageRequest = PageRequest.of(0, pageSize); + + long idCursor; + double rateCursor; + Slice reviewPhotoList; + String nextCursor; + + if (!cursor.equals("-1")){ + String[] cursorSplit = cursor.split(":"); + switch (query.toLowerCase()){ + case "id": + idCursor = Long.parseLong(cursorSplit[1]); + reviewPhotoList = reviewPhotoRepository.findReviewPhotosByStore_IdAndIdLessThanOrderByIdDesc(storeId, idCursor, pageRequest); + break; + case "rate": + rateCursor = Double.parseDouble(cursorSplit[0]); + idCursor = Long.parseLong(cursorSplit[1]); + reviewPhotoList = reviewPhotoRepository.findReviewPhotosByRateCursor(storeId, rateCursor, idCursor, pageRequest); + break; + default: + throw new ReviewException(ReviewErrorCode.INVALID_QUERY); + } + } else { + switch (query.toLowerCase()){ + case "id": + reviewPhotoList = reviewPhotoRepository.findReviewPhotosByStore_IdOrderByIdDesc(storeId, pageRequest); + break; + case "rate": + reviewPhotoList = reviewPhotoRepository.findReviewPhotosOrderByRate(storeId, pageRequest); + break; + default: + throw new ReviewException(ReviewErrorCode.INVALID_QUERY); + } + } + nextCursor = "-1"; + + if (!reviewPhotoList.isEmpty()) { + + ReviewPhoto lastReviewPhoto = reviewPhotoList.getContent() + .get(reviewPhotoList.getNumberOfElements() - 1); + + switch (query.toLowerCase()) { + + case "id": + nextCursor = lastReviewPhoto.getId() + ":" + lastReviewPhoto.getId(); + break; + + case "rate": + nextCursor = lastReviewPhoto.getReview().getRate() + ":" + lastReviewPhoto.getId(); + break; + } + } + + return ReviewConverter.toPagination( + reviewPhotoList.map(ReviewConverter::toReviewPhotoUrl).toList(), + reviewPhotoList.hasNext(), + nextCursor, + reviewPhotoList.getSize()); } } diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java index 6e030e6..411526c 100644 --- a/leeseo/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/GeneralErrorCode.java @@ -22,7 +22,11 @@ public enum GeneralErrorCode implements BaseErrorCode{ NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404_1", - "해당 리소스를 찾을 수 없습니다."); + "해당 리소스를 찾을 수 없습니다."), + + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, + "COMMON500_1", + "서버가 응답하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java new file mode 100644 index 0000000..4ac3ada --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -0,0 +1,66 @@ +package com.example.umc10th.global.apiPayload.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.apiPayload.exception.BaseException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GeneralExceptionAdvice { + + // 애플리케이션에서 발생하는 커스텀 예외를 처리 + @ExceptionHandler(BaseException.class) + public ResponseEntity> handleException( + BaseException ex + ) { + + return ResponseEntity.status(ex.getErrorCode().getStatus()) + .body(ApiResponse.onFailure( + ex.getErrorCode(), + null + ) + ); + } + + // Valid 오류 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationException( + MethodArgumentNotValidException e + ) { + + Map errors = new HashMap<>(); + + e.getBindingResult() + .getFieldErrors() + .forEach(error -> + errors.put( + error.getField(), + error.getDefaultMessage() + ) + ); + BaseErrorCode code = GeneralErrorCode.BAD_REQUEST; + return ResponseEntity.badRequest().body(ApiResponse.onFailure(code, errors)); + } + + // 그 외의 정의되지 않은 모든 예외 처리 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException( + Exception ex + ) { + + BaseErrorCode code = GeneralErrorCode.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(code.getStatus()) + .body(ApiResponse.onFailure( + code, + ex.getMessage() + ) + ); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java new file mode 100644 index 0000000..25a83dd --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -0,0 +1,98 @@ +package com.example.umc10th.global.config; + +import com.example.umc10th.global.security.filter.JwtAuthFilter; +import com.example.umc10th.global.security.handler.CustomAccessDenied; +import com.example.umc10th.global.security.handler.CustomEntryPoint; +import com.example.umc10th.global.security.handler.OAuthSuccessHandler; +import com.example.umc10th.global.security.service.CustomOAuthService; +import com.example.umc10th.global.security.service.CustomUserDetailsService; +import com.example.umc10th.global.security.util.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; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + private final String[] allowUris = { + // Swagger 허용 + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + "/api/v1/auth/**", + "/login", + "/oauth/**" + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuthService customOAuthService) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .anyRequest().authenticated() + ) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(AbstractHttpConfigurer::disable) + .addFilterBefore(jwtAuthFilter(), org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) + .oauth2Login(oauth -> oauth + .authorizationEndpoint(auth -> auth + .baseUri("/oauth/authorization") + ) + .redirectionEndpoint(redirect -> redirect + .baseUri("/oauth/callback/**") + ) + .userInfoEndpoint(userInfo -> { + userInfo.userService(customOAuthService); + }) + .successHandler(oAuthSuccessHandler()) + ) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + .exceptionHandling(exception -> exception + .accessDeniedHandler(customAccessDenied()) + .authenticationEntryPoint(customEntryPoint()) + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CustomAccessDenied customAccessDenied() { + return new CustomAccessDenied(); + } + + @Bean + public CustomEntryPoint customEntryPoint() { + return new CustomEntryPoint(); + } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public OAuthSuccessHandler oAuthSuccessHandler() { + return new OAuthSuccessHandler(jwtUtil); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/dto/KakaoDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/dto/KakaoDto.java new file mode 100644 index 0000000..0806fef --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/dto/KakaoDto.java @@ -0,0 +1,32 @@ +package com.example.umc10th.global.security.dto; + +import com.example.umc10th.domain.member.enums.SocialType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class KakaoDto implements OAuthDto{ + + private final String id; + private final String email; + private final String name; + + @Override + public SocialType getSocialType() { + return SocialType.KAKAO; + } + + @Override + public String getSocialUid() { + return id; + } + + @Override + public String getSocialEmail() { + return email; + } + + @Override + public String getName() { + return name; + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/dto/OAuthDto.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/dto/OAuthDto.java new file mode 100644 index 0000000..c7bbb03 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/dto/OAuthDto.java @@ -0,0 +1,10 @@ +package com.example.umc10th.global.security.dto; + +import com.example.umc10th.domain.member.enums.SocialType; + +public interface OAuthDto { + SocialType getSocialType(); + String getSocialUid(); + String getSocialEmail(); + String getName(); +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java new file mode 100644 index 0000000..78b0e18 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/entity/AuthMember.java @@ -0,0 +1,33 @@ +package com.example.umc10th.global.security.entity; + +import com.example.umc10th.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public @Nullable String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java new file mode 100644 index 0000000..9fb4231 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/entity/OAuthMember.java @@ -0,0 +1,37 @@ +package com.example.umc10th.global.security.entity; + +import com.example.umc10th.domain.member.entity.Member; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +public class OAuthMember implements OAuth2User { + + @Getter + private final Member member; + private final Map attributes; + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return this.member.getSocialId(); + } + + @Override + public Collection getAuthorities() { + return List.of(); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java new file mode 100644 index 0000000..f31aed0 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/filter/JwtAuthFilter.java @@ -0,0 +1,73 @@ +package com.example.umc10th.global.security.filter; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc10th.global.security.service.CustomUserDetailsService; +import com.example.umc10th.global.security.util.JwtUtil; +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) { + ObjectMapper mapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + ApiResponse errorResponse = ApiResponse.onFailure(code,null); + + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java new file mode 100644 index 0000000..09aa713 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomAccessDenied.java @@ -0,0 +1,35 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +public class CustomAccessDenied implements AccessDeniedHandler { + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + ObjectMapper objectMapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + // 응답 Content-Type, HTTP 상태코드 정의 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // Response Body에 응답 통일한 객체 넣기 + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + + // 실제 Response로 덮어쓰기 + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java new file mode 100644 index 0000000..ff78e01 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/CustomEntryPoint.java @@ -0,0 +1,35 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +public class CustomEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + ObjectMapper objectMapper = new ObjectMapper(); + BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; + + // 응답 Content-Type, HTTP 상태코드 정의 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // Response Body에 응답 통일한 객체 넣기 + ApiResponse errorResponse = ApiResponse.onFailure(code, null); + + // 실제 Response로 덮어쓰기 + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java new file mode 100644 index 0000000..b14a863 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/handler/OAuthSuccessHandler.java @@ -0,0 +1,57 @@ +package com.example.umc10th.global.security.handler; + +import com.example.umc10th.domain.auth.converter.AuthConverter; +import com.example.umc10th.domain.auth.dto.AuthResDto; +import com.example.umc10th.domain.auth.exception.code.AuthSuccessCode; +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.dto.MemberResDto; +import com.example.umc10th.domain.member.exception.code.MemberSuccessCode; +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.security.entity.AuthMember; +import com.example.umc10th.global.security.entity.OAuthMember; +import com.example.umc10th.global.security.util.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import java.io.IOException; + +@RequiredArgsConstructor +public class OAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException, ServletException { + ObjectMapper objectMapper = new ObjectMapper(); + BaseSuccessCode code = AuthSuccessCode.LOGIN_OK; + + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(code.getStatus().value()); + + // 1. OAuth 멤버 추출 및 토큰 생성 + OAuthMember member = (OAuthMember) authentication.getPrincipal(); + String accessToken = jwtUtil.createAccessToken(new AuthMember(member.getMember())); + + // 2. 💡 [필수 추가] 현재 인증 객체를 SecurityContext에 확실하게 박아줍니다. + // 이렇게 해야 필터 체인이 끝날 때 '인증 안 됨(401)'으로 오해하지 않습니다. + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 3. 응답 출력 + ApiResponse responseBody = ApiResponse.onSuccess( + code, + AuthConverter.toAccessToken(accessToken) + ); + objectMapper.writeValue(response.getOutputStream(), responseBody); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java new file mode 100644 index 0000000..4023ff4 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomOAuthService.java @@ -0,0 +1,71 @@ +package com.example.umc10th.global.security.service; + +import com.example.umc10th.domain.auth.converter.AuthConverter; +import com.example.umc10th.domain.member.converter.MemberConverter; +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.enums.SocialType; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.dto.KakaoDto; +import com.example.umc10th.global.security.dto.OAuthDto; +import com.example.umc10th.global.security.entity.OAuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuthService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser( + OAuth2UserRequest userRequest + ) throws OAuth2AuthenticationException { + // (필수) 인증 서버의 일회성 토큰을 이용해 정보 조회 & 유저 객체 생성 + OAuth2User oAuthMember = super.loadUser(userRequest); + + Map rootAttributes = oAuthMember.getAttributes(); + + // 유저 객체에서 정보 추출 + SocialType providerId; + String socialUid; + Map kakaoAccount = (Map) rootAttributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + try { + providerId = SocialType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + socialUid = String.valueOf(rootAttributes.get("id")); + } catch (IllegalArgumentException e) { + throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + // OAuth 공통 정보 DTO로 매핑 + OAuthDto dto; + switch (providerId) { + case KAKAO -> { + String email = kakaoAccount.get("email").toString(); + String name = profile.get("nickname").toString(); + dto = new KakaoDto(socialUid, email, name); + } + default -> throw new MemberException(MemberErrorCode.NOT_SUPPORT_SOCIAL_PROVIDER); + } + + System.out.println("socialId:" + socialUid); + + // DB 저장: 있다면 그 데이터 가져오고 없으면 새로 저장 + Member member = memberRepository.findBySocialTypeAndSocialId(providerId, socialUid) + .orElseGet(() -> { + Member newMember = AuthConverter.toSocialMember(dto); + memberRepository.save(newMember); + return newMember; + }); + return new OAuthMember(member, oAuthMember.getAttributes()); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java new file mode 100644 index 0000000..28e49ef --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.security.service; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.exception.MemberException; +import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.global.security.entity.AuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + public UserDetails loadUserByUsername( + String username + ) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + return new AuthMember(member); + } +} diff --git a/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java new file mode 100644 index 0000000..b6d3f11 --- /dev/null +++ b/leeseo/umc10th/src/main/java/com/example/umc10th/global/security/util/JwtUtil.java @@ -0,0 +1,92 @@ +package com.example.umc10th.global.security.util; + +import com.example.umc10th.global.security.entity.AuthMember; +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(AuthMember member) { + return createToken(member, 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(AuthMember member, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = member.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(member.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", member.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/leeseo/umc10th/src/main/resources/application.yml b/leeseo/umc10th/src/main/resources/application.yml index 5678389..3febb84 100644 --- a/leeseo/umc10th/src/main/resources/application.yml +++ b/leeseo/umc10th/src/main/resources/application.yml @@ -7,6 +7,26 @@ spring: username: ${DB_USER} password: ${DB_PW} + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_REST_API_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/oauth/callback/{registrationId}" + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + user-name-attribute: id + jpa: database: mysql database-platform: org.hibernate.dialect.MySQLDialect @@ -15,4 +35,14 @@ spring: ddl-auto: update properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true + +jwt: + token: + secretKey: ${JWT_SECRET_KEY} + expiration: + access: 1800000 # 30분 + +logging: + level: + org.springframework.security: DEBUG \ No newline at end of file