diff --git a/bean/umc10th/.gitignore b/bean/umc10th/.gitignore index ac2055b..5cbf6be 100644 --- a/bean/umc10th/.gitignore +++ b/bean/umc10th/.gitignore @@ -1,7 +1,6 @@ HELP.md .gradle -.gradle-user-home/ -.gradle-user-home-2/ +.gradle-user-home*/ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ diff --git a/bean/umc10th/build.gradle b/bean/umc10th/build.gradle index 43915b1..90fd4b8 100644 --- a/bean/umc10th/build.gradle +++ b/bean/umc10th/build.gradle @@ -14,6 +14,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java index a223397..dabe97a 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/controller/MemberController.java @@ -2,31 +2,68 @@ import com.example.umc10th.domain.member.dto.MemberReqDto; import com.example.umc10th.domain.member.dto.MemberResDto; +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.AuthMember; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/members") @RequiredArgsConstructor +@Tag(name = "Member", description = "회원 API") public class MemberController { private final MemberService memberService; - @GetMapping("/{memberId}") - public ResponseEntity getMember(@PathVariable Long memberId) { - return ResponseEntity.ok(memberService.getMember(memberId)); + @Operation(summary = "회원가입", description = "회원 정보를 저장하고 JWT access token을 발급합니다.") + @PostMapping("/api/v1/users/signup") + public ResponseEntity> signUp( + @Valid @RequestBody MemberReqDto.SignUpRequest request + ) { + MemberResDto.SignUpResponse result = memberService.signUp(request); + return ResponseEntity.ok(ApiResponse.of(MemberSuccessCode.SIGN_UP_SUCCESS, result)); } - @PostMapping - public ResponseEntity createMember(@Valid @RequestBody MemberReqDto.Create request) { - return ResponseEntity.ok(memberService.createMember(request)); + @Operation(summary = "로그인", description = "이메일과 비밀번호를 검증하고 JWT access token을 발급합니다.") + @PostMapping("/api/v1/users/login") + public ResponseEntity> login( + @Valid @RequestBody MemberReqDto.LoginRequest request + ) { + MemberResDto.LoginResponse result = memberService.login(request); + return ResponseEntity.ok(ApiResponse.of(MemberSuccessCode.LOGIN_SUCCESS, result)); } -} \ No newline at end of file + + @Operation(summary = "마이페이지 조회(v1)", description = "요청 body의 회원 ID로 마이페이지 정보를 조회합니다.") + @PostMapping("/api/v1/users/me") + public ResponseEntity> getMyPage( + @Valid @RequestBody MemberReqDto.GetMyPageRequest request + ) { + MemberResDto.GetMyPageResponse result = memberService.getMyPage(request.id()); + + return ResponseEntity.ok(ApiResponse.of(MemberSuccessCode.GET_MY_PAGE_SUCCESS, result)); + } + + @Operation( + summary = "마이페이지 조회(v2)", + description = "Authorization 헤더의 Bearer 토큰으로 인증된 회원의 마이페이지 정보를 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @GetMapping("/api/v2/users/me") + public ResponseEntity> getMyPageV2( + @AuthenticationPrincipal AuthMember authMember + ) { + MemberResDto.GetMyPageResponse result = memberService.getMyPage(authMember.getMember()); + + return ResponseEntity.ok(ApiResponse.of(MemberSuccessCode.GET_MY_PAGE_SUCCESS, result)); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java index 479a2d4..4fa7047 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/converter/MemberConverter.java @@ -3,6 +3,9 @@ import com.example.umc10th.domain.member.dto.MemberReqDto; import com.example.umc10th.domain.member.dto.MemberResDto; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.store.entity.FoodCategory; +import com.example.umc10th.global.security.jwt.JwtTokenProvider; +import java.util.List; public final class MemberConverter { @@ -19,6 +22,23 @@ public static Member toEntity(MemberReqDto.Create request) { .build(); } + public static Member toEntity(MemberReqDto.SignUpRequest request, String encodedPassword) { + return Member.builder() + .name(request.name()) + .gender(request.gender()) + .birthDate(request.birthDate()) + .address(request.address()) + .nickname(request.nickname()) + .email(request.email()) + .password(encodedPassword) + .ageOver14Agreed(Boolean.TRUE.equals(request.termsAgreement().ageOver14Agreed())) + .serviceTermsAgreed(Boolean.TRUE.equals(request.termsAgreement().serviceTermsAgreed())) + .privacyPolicyAgreed(Boolean.TRUE.equals(request.termsAgreement().privacyPolicyAgreed())) + .locationAgreed(Boolean.TRUE.equals(request.termsAgreement().locationAgreed())) + .marketingAgreed(Boolean.TRUE.equals(request.termsAgreement().marketingAgreed())) + .build(); + } + public static MemberResDto.MemberInfo toMemberInfo(Member member) { return new MemberResDto.MemberInfo( member.getId(), @@ -29,4 +49,48 @@ public static MemberResDto.MemberInfo toMemberInfo(Member member) { member.getNickname() ); } -} \ No newline at end of file + + public static MemberResDto.GetMyPageResponse toMyPageResponse(Member member, Integer point) { + return new MemberResDto.GetMyPageResponse( + member.getName(), + "", + member.getEmail(), + null, + point == null ? 0 : point + ); + } + + public static MemberResDto.SignUpResponse toSignUpResponse( + Member member, + List categories, + JwtTokenProvider.TokenInfo tokenInfo + ) { + List preferCategoryIds = categories.stream() + .map(FoodCategory::getId) + .toList(); + + return new MemberResDto.SignUpResponse( + member.getId(), + member.getEmail(), + member.getNickname(), + preferCategoryIds, + tokenInfo.accessToken(), + tokenInfo.tokenType(), + tokenInfo.expiresIn() + ); + } + + public static MemberResDto.LoginResponse toLoginResponse( + Member member, + JwtTokenProvider.TokenInfo tokenInfo + ) { + return new MemberResDto.LoginResponse( + member.getId(), + member.getEmail(), + member.getNickname(), + tokenInfo.accessToken(), + tokenInfo.tokenType(), + tokenInfo.expiresIn() + ); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java index 71fad6a..0be2b90 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberReqDto.java @@ -1,19 +1,104 @@ package com.example.umc10th.domain.member.dto; import com.example.umc10th.domain.member.enums.Gender; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.time.LocalDate; +import java.util.List; public class MemberReqDto { public record Create( + @Schema(description = "회원 이름", example = "김철수") @NotBlank @Size(max = 20) String name, + + @Schema(description = "성별", example = "MALE", allowableValues = {"MALE", "FEMALE", "NONE"}) @NotNull Gender gender, + + @Schema(description = "생년월일", example = "2000-03-24") @NotNull LocalDate birthDate, + + @Schema(description = "주소", example = "서울특별시 성동구 왕십리로 222") @NotBlank @Size(max = 255) String address, + + @Schema(description = "닉네임", example = "bean") @NotBlank @Size(max = 20) String nickname ) { } -} \ No newline at end of file + + public record GetMyPageRequest( + @Schema(description = "회원 ID", example = "1") + @NotNull @Positive Long id + ) { + } + + public record SignUpRequest( + @Schema(description = "회원 이름", example = "김철수") + @NotBlank @Size(max = 20) String name, + + @Schema(description = "성별", example = "MALE", allowableValues = {"MALE", "FEMALE", "NONE"}) + @NotNull Gender gender, + + @Schema(description = "생년월일", example = "2000-03-24") + @NotNull LocalDate birthDate, + + @Schema(description = "주소", example = "서울특별시 성동구 왕십리로 222") + @NotBlank @Size(max = 255) String address, + + @Schema(description = "닉네임", example = "bean") + @NotBlank @Size(max = 20) String nickname, + + @Schema(description = "이메일", example = "bean@example.com") + @NotBlank @Email @Size(max = 100) String email, + + @Schema(description = "비밀번호", example = "Password1!") + @NotBlank + @Size(min = 8, max = 30) + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/]).+$", + message = "비밀번호는 영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다." + ) + String password, + + @Schema(description = "선호 음식 카테고리 ID 목록", example = "[1, 2]") + @NotEmpty List<@NotNull @Positive Long> preferCategoryIds, + + @Schema(description = "약관 동의 정보") + @Valid @NotNull TermsAgreement termsAgreement + ) { + } + + public record LoginRequest( + @Schema(description = "이메일", example = "bean@example.com") + @NotBlank @Email @Size(max = 100) String email, + + @Schema(description = "비밀번호", example = "Password1!") + @NotBlank String password + ) { + } + + public record TermsAgreement( + @Schema(description = "만 14세 이상 동의 여부", example = "true") + @NotNull Boolean ageOver14Agreed, + + @Schema(description = "서비스 이용약관 필수 동의 여부", example = "true") + @NotNull Boolean serviceTermsAgreed, + + @Schema(description = "개인정보 처리방침 필수 동의 여부", example = "true") + @NotNull Boolean privacyPolicyAgreed, + + @Schema(description = "위치정보 제공 선택 동의 여부", example = "false") + @NotNull Boolean locationAgreed, + + @Schema(description = "마케팅 수신 선택 동의 여부", example = "false") + @NotNull Boolean marketingAgreed + ) { + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java index 26d1817..89c3fd6 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/dto/MemberResDto.java @@ -2,6 +2,7 @@ import com.example.umc10th.domain.member.enums.Gender; import java.time.LocalDate; +import java.util.List; public class MemberResDto { @@ -14,4 +15,34 @@ public record MemberInfo( String nickname ) { } -} \ No newline at end of file + + public record GetMyPageResponse( + String name, + String profileUrl, + String email, + String phoneNumber, + Integer point + ) { + } + + public record SignUpResponse( + Long memberId, + String email, + String nickname, + List preferCategoryIds, + String accessToken, + String tokenType, + Long expiresIn + ) { + } + + public record LoginResponse( + Long memberId, + String email, + String nickname, + String accessToken, + String tokenType, + Long expiresIn + ) { + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java index 92e388f..e4e7cfe 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/entity/Member.java @@ -53,6 +53,27 @@ public class Member { @Column(name = "nickname", nullable = false, length = 20) private String nickname; + @Column(name = "email", unique = true, length = 100) + private String email; + + @Column(name = "password", length = 255) + private String password; + + @Column(name = "age_over_14_agreed", nullable = false) + private boolean ageOver14Agreed; + + @Column(name = "service_terms_agreed", nullable = false) + private boolean serviceTermsAgreed; + + @Column(name = "privacy_policy_agreed", nullable = false) + private boolean privacyPolicyAgreed; + + @Column(name = "location_agreed", nullable = false) + private boolean locationAgreed; + + @Column(name = "marketing_agreed", nullable = false) + private boolean marketingAgreed; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; @@ -85,4 +106,4 @@ protected void onCreate() { protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} \ No newline at end of file +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/MemberException.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/MemberException.java index be0bbb2..5e0d1f9 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/MemberException.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/MemberException.java @@ -1,15 +1,10 @@ package com.example.umc10th.domain.member.exception; import com.example.umc10th.domain.member.exception.code.MemberErrorCode; -import lombok.Getter; - -@Getter -public class MemberException extends RuntimeException { - - private final MemberErrorCode errorCode; +import com.example.umc10th.global.apiPayload.exception.GeneralException; +public class MemberException extends GeneralException { public MemberException(MemberErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; + super(errorCode); } } \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java index 8d59239..cc7eeb0 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberErrorCode.java @@ -1,15 +1,31 @@ package com.example.umc10th.domain.member.exception.code; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor -public enum MemberErrorCode { - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", "����ڸ� ã�� �� �����ϴ�."); +public enum MemberErrorCode implements BaseErrorCode { + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "User not found."), + EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "MEMBER400_1", "Email already exists."), + FOOD_CATEGORY_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_2", "Food category not found."), + REQUIRED_TERMS_NOT_AGREED(HttpStatus.BAD_REQUEST, "MEMBER400_3", "Required terms must be agreed."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "Password does not match."); - private final HttpStatus status; + private final HttpStatus httpStatus; private final String code; private final String message; -} \ No newline at end of file + + @Override + public ReasonDto getReason() { + return new ReasonDto(httpStatus, false, code, message); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return getReason(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java new file mode 100644 index 0000000..7b04aad --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/exception/code/MemberSuccessCode.java @@ -0,0 +1,29 @@ +package com.example.umc10th.domain.member.exception.code; + +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MemberSuccessCode implements BaseSuccessCode { + SIGN_UP_SUCCESS(HttpStatus.OK, "MEMBER2000", "Sign-up completed successfully."), + LOGIN_SUCCESS(HttpStatus.OK, "MEMBER2002", "Login completed successfully."), + GET_MY_PAGE_SUCCESS(HttpStatus.OK, "MEMBER2001", "Successfully retrieved user information."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return new ReasonDto(httpStatus, true, code, message); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return getReason(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java index e878bbb..33d6a76 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/repository/MemberRepository.java @@ -1,7 +1,11 @@ package com.example.umc10th.domain.member.repository; import com.example.umc10th.domain.member.entity.Member; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { -} \ No newline at end of file + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java index 86dbcad..87ef9ce 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberService.java @@ -2,9 +2,18 @@ import com.example.umc10th.domain.member.dto.MemberReqDto; import com.example.umc10th.domain.member.dto.MemberResDto; +import com.example.umc10th.domain.member.entity.Member; public interface MemberService { MemberResDto.MemberInfo getMember(Long memberId); MemberResDto.MemberInfo createMember(MemberReqDto.Create request); -} \ No newline at end of file + + MemberResDto.GetMyPageResponse getMyPage(Long memberId); + + MemberResDto.GetMyPageResponse getMyPage(Member member); + + MemberResDto.SignUpResponse signUp(MemberReqDto.SignUpRequest request); + + MemberResDto.LoginResponse login(MemberReqDto.LoginRequest request); +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberServiceImpl.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberServiceImpl.java index 76794fe..a926315 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberServiceImpl.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/member/service/MemberServiceImpl.java @@ -4,10 +4,19 @@ import com.example.umc10th.domain.member.dto.MemberReqDto; import com.example.umc10th.domain.member.dto.MemberResDto; import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.entity.MemberPrefer; import com.example.umc10th.domain.member.exception.MemberException; import com.example.umc10th.domain.member.exception.code.MemberErrorCode; +import com.example.umc10th.domain.member.repository.MemberPreferRepository; import com.example.umc10th.domain.member.repository.MemberRepository; +import com.example.umc10th.domain.mission.enums.MemberMissionStatus; +import com.example.umc10th.domain.mission.repository.MemberMissionRepository; +import com.example.umc10th.domain.store.entity.FoodCategory; +import com.example.umc10th.domain.store.repository.FoodCategoryRepository; +import com.example.umc10th.global.security.jwt.JwtTokenProvider; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +26,11 @@ public class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; + private final MemberMissionRepository memberMissionRepository; + private final MemberPreferRepository memberPreferRepository; + private final FoodCategoryRepository foodCategoryRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; @Override public MemberResDto.MemberInfo getMember(Long memberId) { @@ -32,4 +46,79 @@ public MemberResDto.MemberInfo createMember(MemberReqDto.Create request) { Member savedMember = memberRepository.save(MemberConverter.toEntity(request)); return MemberConverter.toMemberInfo(savedMember); } -} \ No newline at end of file + + @Override + public MemberResDto.GetMyPageResponse getMyPage(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + return getMyPage(member); + } + + @Override + public MemberResDto.GetMyPageResponse getMyPage(Member member) { + Integer totalPoint = memberMissionRepository.sumRewardPointByMemberIdAndStatus( + member.getId(), + MemberMissionStatus.COMPLETE + ); + + return MemberConverter.toMyPageResponse(member, totalPoint); + } + + @Override + @Transactional + public MemberResDto.SignUpResponse signUp(MemberReqDto.SignUpRequest request) { + validateRequiredTerms(request.termsAgreement()); + + if (memberRepository.existsByEmail(request.email())) { + throw new MemberException(MemberErrorCode.EMAIL_ALREADY_EXISTS); + } + + List distinctCategoryIds = request.preferCategoryIds().stream() + .distinct() + .toList(); + + List categories = foodCategoryRepository.findAllById(distinctCategoryIds); + if (categories.size() != distinctCategoryIds.size()) { + throw new MemberException(MemberErrorCode.FOOD_CATEGORY_NOT_FOUND); + } + + String encodedPassword = passwordEncoder.encode(request.password()); + Member member = memberRepository.save(MemberConverter.toEntity(request, encodedPassword)); + + List memberPrefers = categories.stream() + .map(category -> MemberPrefer.builder() + .member(member) + .category(category) + .build()) + .toList(); + memberPreferRepository.saveAll(memberPrefers); + + JwtTokenProvider.TokenInfo tokenInfo = jwtTokenProvider.generateAccessToken(member); + return MemberConverter.toSignUpResponse(member, categories, tokenInfo); + } + + @Override + public MemberResDto.LoginResponse login(MemberReqDto.LoginRequest request) { + Member member = memberRepository.findByEmail(request.email()) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + if (!passwordEncoder.matches(request.password(), member.getPassword())) { + throw new MemberException(MemberErrorCode.INVALID_PASSWORD); + } + + JwtTokenProvider.TokenInfo tokenInfo = jwtTokenProvider.generateAccessToken(member); + return MemberConverter.toLoginResponse(member, tokenInfo); + } + + private void validateRequiredTerms(MemberReqDto.TermsAgreement termsAgreement) { + boolean requiredTermsAgreed = termsAgreement != null + && Boolean.TRUE.equals(termsAgreement.ageOver14Agreed()) + && Boolean.TRUE.equals(termsAgreement.serviceTermsAgreed()) + && Boolean.TRUE.equals(termsAgreement.privacyPolicyAgreed()); + + if (!requiredTermsAgreed) { + throw new MemberException(MemberErrorCode.REQUIRED_TERMS_NOT_AGREED); + } + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java index f26e710..d14d7f3 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/controller/MissionController.java @@ -2,31 +2,63 @@ import com.example.umc10th.domain.mission.dto.MissionReqDto; import com.example.umc10th.domain.mission.dto.MissionResDto; +import com.example.umc10th.domain.mission.enums.MemberMissionStatus; import com.example.umc10th.domain.mission.service.MissionService; +import com.example.umc10th.global.apiPayload.ApiResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/missions") +@RequestMapping("/api/v1/missions") @RequiredArgsConstructor +@Validated public class MissionController { private final MissionService missionService; @GetMapping("/{missionId}") - public ResponseEntity getMission(@PathVariable Long missionId) { - return ResponseEntity.ok(missionService.getMission(missionId)); + public ResponseEntity> getMission(@PathVariable Long missionId) { + return ResponseEntity.ok(ApiResponse.onSuccess(missionService.getMission(missionId))); } @PostMapping - public ResponseEntity createMission(@Valid @RequestBody MissionReqDto.Create request) { - return ResponseEntity.ok(missionService.createMission(request)); + public ResponseEntity> createMission(@Valid @RequestBody MissionReqDto.Create request) { + return ResponseEntity.ok(ApiResponse.onSuccess(missionService.createMission(request))); } -} \ No newline at end of file + + @PostMapping("/my/challenging") + public ResponseEntity>> getMyChallengingMissions( + @Valid @RequestBody MissionReqDto.MyChallengingMissionsRequest request + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(missionService.getMyChallengingMissions(request))); + } + + @GetMapping("/members/{memberId}") + public ResponseEntity>> getMemberMissions( + @PathVariable Long memberId, + @RequestParam MemberMissionStatus status, + @RequestParam(defaultValue = "1") @Min(1) Integer page + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(missionService.getMemberMissions(memberId, status, page))); + } + + @GetMapping("/home") + public ResponseEntity>> getHomeMissions( + @RequestParam @NotNull Long regionId, + @RequestParam(required = false) Long memberId, + @RequestParam(defaultValue = "1") @Min(1) Integer page + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(missionService.getHomeMissions(regionId, memberId, page))); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java index c3c7f68..d4300da 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/converter/MissionConverter.java @@ -2,8 +2,12 @@ import com.example.umc10th.domain.mission.dto.MissionReqDto; import com.example.umc10th.domain.mission.dto.MissionResDto; +import com.example.umc10th.domain.mission.entity.MemberMission; import com.example.umc10th.domain.mission.entity.Mission; import com.example.umc10th.domain.store.entity.Store; +import java.util.List; +import java.util.function.Function; +import org.springframework.data.domain.Slice; public final class MissionConverter { @@ -26,4 +30,57 @@ public static MissionResDto.MissionInfo toMissionInfo(Mission mission) { mission.getRewardPoint() ); } -} \ No newline at end of file + + public static MissionResDto.MemberMissionPageItem toMemberMissionPageItem(MemberMission memberMission) { + Mission mission = memberMission.getMission(); + + return new MissionResDto.MemberMissionPageItem( + memberMission.getId(), + mission.getId(), + mission.getStore().getName(), + mission.getContent(), + mission.getRewardPoint(), + memberMission.getStatus() + ); + } + + public static MissionResDto.HomeMissionPageItem toHomeMissionPageItem(Mission mission) { + return new MissionResDto.HomeMissionPageItem( + mission.getId(), + mission.getStore().getName(), + mission.getContent(), + mission.getRewardPoint(), + mission.getStore().getScore() + ); + } + + public static MissionResDto.MissionPageResponse toMemberMissionPageResponse( + Slice slice + ) { + return toMissionPageResponse(slice, MissionConverter::toMemberMissionPageItem); + } + + public static MissionResDto.MissionPageResponse toHomeMissionPageResponse( + Slice slice + ) { + return toMissionPageResponse(slice, MissionConverter::toHomeMissionPageItem); + } + + private static MissionResDto.MissionPageResponse toMissionPageResponse( + Slice slice, + Function mapper + ) { + List missions = slice.getContent() + .stream() + .map(mapper) + .toList(); + + return new MissionResDto.MissionPageResponse<>( + missions, + missions.size(), + slice.isFirst(), + slice.isLast(), + slice.hasNext() + ); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java index 5c39322..73c0fd4 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionReqDto.java @@ -3,6 +3,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public class MissionReqDto { @@ -12,4 +13,11 @@ public record Create( @Min(0) int rewardPoint ) { } -} \ No newline at end of file + + public record MyChallengingMissionsRequest( + @NotNull @Positive Long memberId, + @NotNull @Min(1) Integer page, + @NotNull @Min(1) Integer size + ) { + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDto.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDto.java index fc37061..228327b 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDto.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/dto/MissionResDto.java @@ -1,5 +1,8 @@ package com.example.umc10th.domain.mission.dto; +import com.example.umc10th.domain.mission.enums.MemberMissionStatus; +import java.util.List; + public class MissionResDto { public record MissionInfo( @@ -9,4 +12,32 @@ public record MissionInfo( int rewardPoint ) { } -} \ No newline at end of file + + public record MemberMissionPageItem( + Long memberMissionId, + Long missionId, + String storeName, + String missionContent, + Integer rewardPoint, + MemberMissionStatus status + ) { + } + + public record HomeMissionPageItem( + Long missionId, + String storeName, + String missionContent, + Integer rewardPoint, + Float storeScore + ) { + } + + public record MissionPageResponse( + List missions, + Integer listSize, + Boolean isFirst, + Boolean isLast, + Boolean hasNext + ) { + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java index 26a61fe..41fdeaa 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/MissionException.java @@ -1,15 +1,10 @@ package com.example.umc10th.domain.mission.exception; import com.example.umc10th.domain.mission.exception.code.MissionErrorCode; -import lombok.Getter; - -@Getter -public class MissionException extends RuntimeException { - - private final MissionErrorCode errorCode; +import com.example.umc10th.global.apiPayload.exception.GeneralException; +public class MissionException extends GeneralException { public MissionException(MissionErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; + super(errorCode); } } \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java index 4d71999..2d4757d 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/exception/code/MissionErrorCode.java @@ -1,16 +1,27 @@ package com.example.umc10th.domain.mission.exception.code; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor -public enum MissionErrorCode { - MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "MISSION404", "�̼��� ã�� �� �����ϴ�."), - STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "���Ը� ã�� �� �����ϴ�."); +public enum MissionErrorCode implements BaseErrorCode { + MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "MISSION404_1", "Mission not found."); - private final HttpStatus status; + private final HttpStatus httpStatus; private final String code; private final String message; -} \ No newline at end of file + + @Override + public ReasonDto getReason() { + return new ReasonDto(httpStatus, false, code, message); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return getReason(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java index d9d5164..ce744a8 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MemberMissionRepository.java @@ -1,7 +1,38 @@ package com.example.umc10th.domain.mission.repository; import com.example.umc10th.domain.mission.entity.MemberMission; +import com.example.umc10th.domain.mission.enums.MemberMissionStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MemberMissionRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"mission", "mission.store"}) + @Query(""" + select mm + from MemberMission mm + where mm.member.id = :memberId + and mm.status = :status + order by mm.id desc + """) + Slice findMemberMissionsByStatus( + @Param("memberId") Long memberId, + @Param("status") MemberMissionStatus status, + Pageable pageable + ); + + @Query(""" + select coalesce(sum(mm.mission.rewardPoint), 0) + from MemberMission mm + where mm.member.id = :memberId + and mm.status = :status + """) + Integer sumRewardPointByMemberIdAndStatus( + @Param("memberId") Long memberId, + @Param("status") MemberMissionStatus status + ); } diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java index 71f7528..cbb623b 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/repository/MissionRepository.java @@ -1,7 +1,34 @@ package com.example.umc10th.domain.mission.repository; import com.example.umc10th.domain.mission.entity.Mission; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MissionRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"store"}) + @Query(""" + select m + from Mission m + where m.store.region.id = :regionId + and ( + :memberId is null + or not exists ( + select 1 + from MemberMission mm + where mm.member.id = :memberId + and mm.mission.id = m.id + ) + ) + order by m.id desc + """) + Slice findChallengeableMissionsByRegion( + @Param("regionId") Long regionId, + @Param("memberId") Long memberId, + Pageable pageable + ); } diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java index 594e659..af1453b 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionService.java @@ -2,9 +2,26 @@ import com.example.umc10th.domain.mission.dto.MissionReqDto; import com.example.umc10th.domain.mission.dto.MissionResDto; +import com.example.umc10th.domain.mission.enums.MemberMissionStatus; public interface MissionService { MissionResDto.MissionInfo getMission(Long missionId); MissionResDto.MissionInfo createMission(MissionReqDto.Create request); -} \ No newline at end of file + + MissionResDto.MissionPageResponse getMemberMissions( + Long memberId, + MemberMissionStatus status, + Integer page + ); + + MissionResDto.MissionPageResponse getHomeMissions( + Long regionId, + Long memberId, + Integer page + ); + + MissionResDto.MissionPageResponse getMyChallengingMissions( + MissionReqDto.MyChallengingMissionsRequest request + ); +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionServiceImpl.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionServiceImpl.java index 42873ba..607b710 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionServiceImpl.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/mission/service/MissionServiceImpl.java @@ -3,13 +3,20 @@ import com.example.umc10th.domain.mission.converter.MissionConverter; import com.example.umc10th.domain.mission.dto.MissionReqDto; import com.example.umc10th.domain.mission.dto.MissionResDto; +import com.example.umc10th.domain.mission.entity.MemberMission; import com.example.umc10th.domain.mission.entity.Mission; -import com.example.umc10th.domain.store.entity.Store; +import com.example.umc10th.domain.mission.enums.MemberMissionStatus; import com.example.umc10th.domain.mission.exception.MissionException; import com.example.umc10th.domain.mission.exception.code.MissionErrorCode; +import com.example.umc10th.domain.mission.repository.MemberMissionRepository; import com.example.umc10th.domain.mission.repository.MissionRepository; +import com.example.umc10th.domain.store.entity.Store; +import com.example.umc10th.domain.store.exception.StoreException; +import com.example.umc10th.domain.store.exception.code.StoreErrorCode; import com.example.umc10th.domain.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,7 +25,10 @@ @Transactional(readOnly = true) public class MissionServiceImpl implements MissionService { + private static final int DEFAULT_PAGE_SIZE = 10; + private final MissionRepository missionRepository; + private final MemberMissionRepository memberMissionRepository; private final StoreRepository storeRepository; @Override @@ -33,9 +43,56 @@ public MissionResDto.MissionInfo getMission(Long missionId) { @Transactional public MissionResDto.MissionInfo createMission(MissionReqDto.Create request) { Store store = storeRepository.findById(request.storeId()) - .orElseThrow(() -> new MissionException(MissionErrorCode.STORE_NOT_FOUND)); + .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); Mission savedMission = missionRepository.save(MissionConverter.toEntity(request, store)); return MissionConverter.toMissionInfo(savedMission); } -} \ No newline at end of file + + @Override + public MissionResDto.MissionPageResponse getMemberMissions( + Long memberId, + MemberMissionStatus status, + Integer page + ) { + int pageNumber = Math.max(page, 1) - 1; + PageRequest pageRequest = PageRequest.of(pageNumber, DEFAULT_PAGE_SIZE); + + Slice memberMissionSlice = memberMissionRepository.findMemberMissionsByStatus( + memberId, + status, + pageRequest + ); + + return MissionConverter.toMemberMissionPageResponse(memberMissionSlice); + } + + @Override + public MissionResDto.MissionPageResponse getHomeMissions( + Long regionId, + Long memberId, + Integer page + ) { + int pageNumber = Math.max(page, 1) - 1; + PageRequest pageRequest = PageRequest.of(pageNumber, DEFAULT_PAGE_SIZE); + + Slice missionSlice = missionRepository.findChallengeableMissionsByRegion(regionId, memberId, pageRequest); + return MissionConverter.toHomeMissionPageResponse(missionSlice); + } + + @Override + public MissionResDto.MissionPageResponse getMyChallengingMissions( + MissionReqDto.MyChallengingMissionsRequest request + ) { + int pageNumber = Math.max(request.page(), 1) - 1; + PageRequest pageRequest = PageRequest.of(pageNumber, request.size()); + + Slice memberMissionSlice = memberMissionRepository.findMemberMissionsByStatus( + request.memberId(), + MemberMissionStatus.CHALLENGING, + pageRequest + ); + + return MissionConverter.toMemberMissionPageResponse(memberMissionSlice); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java index f3cfdfd..033d245 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/controller/ReviewController.java @@ -3,6 +3,7 @@ import com.example.umc10th.domain.review.dto.ReviewReqDto; import com.example.umc10th.domain.review.dto.ReviewResDto; import com.example.umc10th.domain.review.service.ReviewService; +import com.example.umc10th.global.apiPayload.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -21,12 +22,26 @@ public class ReviewController { private final ReviewService reviewService; @GetMapping("/{reviewId}") - public ResponseEntity getReview(@PathVariable Long reviewId) { - return ResponseEntity.ok(reviewService.getReview(reviewId)); + public ResponseEntity> getReview(@PathVariable Long reviewId) { + return ResponseEntity.ok(ApiResponse.onSuccess(reviewService.getReview(reviewId))); } @PostMapping - public ResponseEntity createReview(@Valid @RequestBody ReviewReqDto.Create request) { - return ResponseEntity.ok(reviewService.createReview(request)); + public ResponseEntity> createReview(@Valid @RequestBody ReviewReqDto.Create request) { + return ResponseEntity.ok(ApiResponse.onSuccess(reviewService.createReview(request))); } -} \ No newline at end of file + + @PostMapping("/my/id-cursor") + public ResponseEntity> getMyReviewsByIdCursor( + @Valid @RequestBody ReviewReqDto.MyReviewsByIdCursorRequest request + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(reviewService.getMyReviewsByIdCursor(request))); + } + + @PostMapping("/my/score-cursor") + public ResponseEntity> getMyReviewsByScoreCursor( + @Valid @RequestBody ReviewReqDto.MyReviewsByScoreCursorRequest request + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(reviewService.getMyReviewsByScoreCursor(request))); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java index 99f6080..2739b84 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/converter/ReviewConverter.java @@ -5,6 +5,8 @@ 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 java.util.List; +import org.springframework.data.domain.Slice; public final class ReviewConverter { @@ -29,4 +31,51 @@ public static ReviewResDto.ReviewInfo toReviewInfo(Review review) { review.getScore() ); } -} \ No newline at end of file + + public static ReviewResDto.MyReviewItem toMyReviewItem(Review review) { + return new ReviewResDto.MyReviewItem( + review.getId(), + review.getStore().getId(), + review.getStore().getName(), + review.getContent(), + review.getScore() + ); + } + + public static ReviewResDto.MyReviewsByIdCursorResponse toMyReviewsByIdCursorResponse( + Slice slice + ) { + List reviews = slice.getContent(); + List items = reviews.stream() + .map(ReviewConverter::toMyReviewItem) + .toList(); + + Long nextCursorId = slice.hasNext() + ? reviews.get(reviews.size() - 1).getId() + : null; + + return new ReviewResDto.MyReviewsByIdCursorResponse(items, nextCursorId, slice.hasNext()); + } + + public static ReviewResDto.MyReviewsByScoreCursorResponse toMyReviewsByScoreCursorResponse( + Slice slice + ) { + List reviews = slice.getContent(); + List items = reviews.stream() + .map(ReviewConverter::toMyReviewItem) + .toList(); + + Review lastReview = slice.hasNext() + ? reviews.get(reviews.size() - 1) + : null; + Float nextCursorScore = lastReview == null ? null : lastReview.getScore(); + Long nextCursorId = lastReview == null ? null : lastReview.getId(); + + return new ReviewResDto.MyReviewsByScoreCursorResponse( + items, + nextCursorScore, + nextCursorId, + slice.hasNext() + ); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java index d9b49b7..45754f5 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewReqDto.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; public class ReviewReqDto { @@ -14,4 +15,19 @@ public record Create( @Min(0) @Max(5) float score ) { } -} \ No newline at end of file + + public record MyReviewsByIdCursorRequest( + @NotNull @Positive Long memberId, + @Positive Long cursorId, + @NotNull @Min(1) @Max(50) Integer size + ) { + } + + public record MyReviewsByScoreCursorRequest( + @NotNull @Positive Long memberId, + @Min(0) @Max(5) Float cursorScore, + @Positive Long cursorId, + @NotNull @Min(1) @Max(50) Integer size + ) { + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDto.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDto.java index 5ca1e5d..1832470 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDto.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/dto/ReviewResDto.java @@ -1,5 +1,7 @@ package com.example.umc10th.domain.review.dto; +import java.util.List; + public class ReviewResDto { public record ReviewInfo( @@ -10,4 +12,28 @@ public record ReviewInfo( float score ) { } -} \ No newline at end of file + + public record MyReviewItem( + Long reviewId, + Long storeId, + String storeName, + String content, + Float score + ) { + } + + public record MyReviewsByIdCursorResponse( + List reviews, + Long nextCursorId, + Boolean hasNext + ) { + } + + public record MyReviewsByScoreCursorResponse( + List reviews, + Float nextCursorScore, + Long nextCursorId, + Boolean hasNext + ) { + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java index e4d509f..10f3491 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/ReviewException.java @@ -1,15 +1,10 @@ package com.example.umc10th.domain.review.exception; import com.example.umc10th.domain.review.exception.code.ReviewErrorCode; -import lombok.Getter; - -@Getter -public class ReviewException extends RuntimeException { - - private final ReviewErrorCode errorCode; +import com.example.umc10th.global.apiPayload.exception.GeneralException; +public class ReviewException extends GeneralException { public ReviewException(ReviewErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; + super(errorCode); } } \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java index 84140b6..9b6eb24 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/exception/code/ReviewErrorCode.java @@ -1,17 +1,28 @@ package com.example.umc10th.domain.review.exception.code; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor -public enum ReviewErrorCode { - REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW404", "���並 ã�� �� �����ϴ�."), - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", "����ڸ� ã�� �� �����ϴ�."), - STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "���Ը� ã�� �� �����ϴ�."); +public enum ReviewErrorCode implements BaseErrorCode { + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW404_1", "Review not found."), + INVALID_CURSOR(HttpStatus.BAD_REQUEST, "REVIEW400_1", "Invalid cursor request."); - private final HttpStatus status; + private final HttpStatus httpStatus; private final String code; private final String message; -} \ No newline at end of file + + @Override + public ReasonDto getReason() { + return new ReasonDto(httpStatus, false, code, message); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return getReason(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java index 73627b1..c55b23d 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/repository/ReviewRepository.java @@ -1,7 +1,65 @@ package com.example.umc10th.domain.review.repository; import com.example.umc10th.domain.review.entity.Review; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ReviewRepository extends JpaRepository { -} \ No newline at end of file + + @EntityGraph(attributePaths = {"store"}) + @Query(""" + select r + from Review r + where r.member.id = :memberId + order by r.id desc + """) + Slice findMyReviewsByIdCursorFirst( + @Param("memberId") Long memberId, + Pageable pageable + ); + + @EntityGraph(attributePaths = {"store"}) + @Query(""" + select r + from Review r + where r.member.id = :memberId + and r.id < :cursorId + order by r.id desc + """) + Slice findMyReviewsByIdCursorNext( + @Param("memberId") Long memberId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + @EntityGraph(attributePaths = {"store"}) + @Query(""" + select r + from Review r + where r.member.id = :memberId + order by r.score desc, r.id desc + """) + Slice findMyReviewsByScoreCursorFirst( + @Param("memberId") Long memberId, + Pageable pageable + ); + + @EntityGraph(attributePaths = {"store"}) + @Query(""" + select r + from Review r + where r.member.id = :memberId + and (r.score < :cursorScore or (r.score = :cursorScore and r.id < :cursorId)) + order by r.score desc, r.id desc + """) + Slice findMyReviewsByScoreCursorNext( + @Param("memberId") Long memberId, + @Param("cursorScore") Float cursorScore, + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java index 0d29ce7..aab5d6e 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewService.java @@ -7,4 +7,8 @@ public interface ReviewService { ReviewResDto.ReviewInfo getReview(Long reviewId); ReviewResDto.ReviewInfo createReview(ReviewReqDto.Create request); -} \ No newline at end of file + + ReviewResDto.MyReviewsByIdCursorResponse getMyReviewsByIdCursor(ReviewReqDto.MyReviewsByIdCursorRequest request); + + ReviewResDto.MyReviewsByScoreCursorResponse getMyReviewsByScoreCursor(ReviewReqDto.MyReviewsByScoreCursorRequest request); +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewServiceImpl.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewServiceImpl.java index 1cb21bb..2d05cf4 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewServiceImpl.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/review/service/ReviewServiceImpl.java @@ -1,9 +1,9 @@ package com.example.umc10th.domain.review.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.domain.store.entity.Store; -import com.example.umc10th.domain.store.repository.StoreRepository; import com.example.umc10th.domain.review.converter.ReviewConverter; import com.example.umc10th.domain.review.dto.ReviewReqDto; import com.example.umc10th.domain.review.dto.ReviewResDto; @@ -11,7 +11,13 @@ import com.example.umc10th.domain.review.exception.ReviewException; import com.example.umc10th.domain.review.exception.code.ReviewErrorCode; import com.example.umc10th.domain.review.repository.ReviewRepository; +import com.example.umc10th.domain.store.entity.Store; +import com.example.umc10th.domain.store.exception.StoreException; +import com.example.umc10th.domain.store.exception.code.StoreErrorCode; +import com.example.umc10th.domain.store.repository.StoreRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,13 +41,54 @@ public ReviewResDto.ReviewInfo getReview(Long reviewId) { @Override @Transactional public ReviewResDto.ReviewInfo createReview(ReviewReqDto.Create request) { - Member member = memberRepository.findById(request.memberId()) - .orElseThrow(() -> new ReviewException(ReviewErrorCode.MEMBER_NOT_FOUND)); + Member member = getMember(request.memberId()); Store store = storeRepository.findById(request.storeId()) - .orElseThrow(() -> new ReviewException(ReviewErrorCode.STORE_NOT_FOUND)); + .orElseThrow(() -> new StoreException(StoreErrorCode.STORE_NOT_FOUND)); Review savedReview = reviewRepository.save(ReviewConverter.toEntity(request, member, store)); return ReviewConverter.toReviewInfo(savedReview); } -} \ No newline at end of file + + @Override + public ReviewResDto.MyReviewsByIdCursorResponse getMyReviewsByIdCursor(ReviewReqDto.MyReviewsByIdCursorRequest request) { + getMember(request.memberId()); + + PageRequest pageRequest = PageRequest.of(0, request.size()); + + Slice reviewSlice = request.cursorId() == null + ? reviewRepository.findMyReviewsByIdCursorFirst(request.memberId(), pageRequest) + : reviewRepository.findMyReviewsByIdCursorNext(request.memberId(), request.cursorId(), pageRequest); + + return ReviewConverter.toMyReviewsByIdCursorResponse(reviewSlice); + } + + @Override + public ReviewResDto.MyReviewsByScoreCursorResponse getMyReviewsByScoreCursor(ReviewReqDto.MyReviewsByScoreCursorRequest request) { + getMember(request.memberId()); + + boolean bothCursorProvided = request.cursorScore() != null && request.cursorId() != null; + boolean bothCursorEmpty = request.cursorScore() == null && request.cursorId() == null; + if (!bothCursorProvided && !bothCursorEmpty) { + throw new ReviewException(ReviewErrorCode.INVALID_CURSOR); + } + + PageRequest pageRequest = PageRequest.of(0, request.size()); + + Slice reviewSlice = bothCursorEmpty + ? reviewRepository.findMyReviewsByScoreCursorFirst(request.memberId(), pageRequest) + : reviewRepository.findMyReviewsByScoreCursorNext( + request.memberId(), + request.cursorScore(), + request.cursorId(), + pageRequest + ); + + return ReviewConverter.toMyReviewsByScoreCursorResponse(reviewSlice); + } + + private Member getMember(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java index 22afbfa..ed3fdb5 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/store/controller/StoreController.java @@ -2,6 +2,7 @@ import com.example.umc10th.domain.store.dto.StoreResDto; import com.example.umc10th.domain.store.service.StoreService; +import com.example.umc10th.global.apiPayload.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -17,7 +18,7 @@ public class StoreController { private final StoreService storeService; @GetMapping("/{storeId}") - public ResponseEntity getStore(@PathVariable Long storeId) { - return ResponseEntity.ok(storeService.getStore(storeId)); + public ResponseEntity> getStore(@PathVariable Long storeId) { + return ResponseEntity.ok(ApiResponse.onSuccess(storeService.getStore(storeId))); } -} \ No newline at end of file +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java index 3d33f25..6574d25 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/StoreException.java @@ -1,15 +1,10 @@ package com.example.umc10th.domain.store.exception; import com.example.umc10th.domain.store.exception.code.StoreErrorCode; -import lombok.Getter; - -@Getter -public class StoreException extends RuntimeException { - - private final StoreErrorCode errorCode; +import com.example.umc10th.global.apiPayload.exception.GeneralException; +public class StoreException extends GeneralException { public StoreException(StoreErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; + super(errorCode); } } \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java index dffd006..3a7a23c 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/store/exception/code/StoreErrorCode.java @@ -1,15 +1,27 @@ package com.example.umc10th.domain.store.exception.code; +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor -public enum StoreErrorCode { - STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "Store not found."); +public enum StoreErrorCode implements BaseErrorCode { + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404_1", "Store not found."); - private final HttpStatus status; + private final HttpStatus httpStatus; private final String code; private final String message; -} \ No newline at end of file + + @Override + public ReasonDto getReason() { + return new ReasonDto(httpStatus, false, code, message); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return getReason(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/test/controller/TestController.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/test/controller/TestController.java new file mode 100644 index 0000000..7fcc448 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/test/controller/TestController.java @@ -0,0 +1,20 @@ +package com.example.umc10th.domain.test.controller; + +import com.example.umc10th.domain.test.dto.TestResDto; +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.status.SuccessStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/test") +public class TestController { + + @GetMapping("/health") + public ResponseEntity> health() { + TestResDto.HealthResponse result = new TestResDto.HealthResponse("UP", "Test API is working."); + return ResponseEntity.ok(ApiResponse.of(SuccessStatus.OK, result)); + } +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/domain/test/dto/TestResDto.java b/bean/umc10th/src/main/java/com/example/umc10th/domain/test/dto/TestResDto.java new file mode 100644 index 0000000..bd56486 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/domain/test/dto/TestResDto.java @@ -0,0 +1,10 @@ +package com.example.umc10th.domain.test.dto; + +public class TestResDto { + + public record HealthResponse( + String status, + String message + ) { + } +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/ApiResponse.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/ApiResponse.java new file mode 100644 index 0000000..7912d32 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/ApiResponse.java @@ -0,0 +1,50 @@ +package com.example.umc10th.global.apiPayload; + +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.apiPayload.code.status.SuccessStatus; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +public class ApiResponse { + + @JsonProperty("isSuccess") + private final Boolean isSuccess; + private final String code; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; + + public static ApiResponse onSuccess(T result) { + return of(SuccessStatus.OK, result); + } + + public static ApiResponse of(BaseSuccessCode code, T result) { + return new ApiResponse<>( + true, + code.getReason().code(), + code.getReason().message(), + result + ); + } + + public static ApiResponse onFailure(BaseErrorCode code, T result) { + return new ApiResponse<>( + false, + code.getReason().code(), + code.getReason().message(), + result + ); + } + + public static ApiResponse onFailure(String code, String message, T result) { + return new ApiResponse<>(false, code, message, result); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseCode.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseCode.java new file mode 100644 index 0000000..07573cd --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseCode.java @@ -0,0 +1,9 @@ +package com.example.umc10th.global.apiPayload.code; + +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; + +public interface BaseCode { + ReasonDto getReason(); + + ReasonDto getReasonHttpStatus(); +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseErrorCode.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseErrorCode.java new file mode 100644 index 0000000..da096a5 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,4 @@ +package com.example.umc10th.global.apiPayload.code; + +public interface BaseErrorCode extends BaseCode { +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseSuccessCode.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseSuccessCode.java new file mode 100644 index 0000000..35b18d3 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/BaseSuccessCode.java @@ -0,0 +1,4 @@ +package com.example.umc10th.global.apiPayload.code; + +public interface BaseSuccessCode extends BaseCode { +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/dto/ReasonDto.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/dto/ReasonDto.java new file mode 100644 index 0000000..92b3568 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/dto/ReasonDto.java @@ -0,0 +1,11 @@ +package com.example.umc10th.global.apiPayload.code.dto; + +import org.springframework.http.HttpStatus; + +public record ReasonDto( + HttpStatus httpStatus, + boolean isSuccess, + String code, + String message +) { +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/status/ErrorStatus.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/status/ErrorStatus.java new file mode 100644 index 0000000..ccee76a --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/status/ErrorStatus.java @@ -0,0 +1,31 @@ +package com.example.umc10th.global.apiPayload.code.status; + +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorStatus implements BaseErrorCode { + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "Server error."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "Bad request."), + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "COMMON4001", "Validation failed."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "Authentication is required."), + FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "Access is denied."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return new ReasonDto(httpStatus, false, code, message); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return getReason(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/status/SuccessStatus.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/status/SuccessStatus.java new file mode 100644 index 0000000..75662f9 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/code/status/SuccessStatus.java @@ -0,0 +1,27 @@ +package com.example.umc10th.global.apiPayload.code.status; + +import com.example.umc10th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum SuccessStatus implements BaseSuccessCode { + OK(HttpStatus.OK, "COMMON200", "Success"); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return new ReasonDto(httpStatus, true, code, message); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return getReason(); + } +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/exception/GeneralException.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/exception/GeneralException.java new file mode 100644 index 0000000..2021214 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/exception/GeneralException.java @@ -0,0 +1,21 @@ +package com.example.umc10th.global.apiPayload.exception; + +import com.example.umc10th.global.apiPayload.code.BaseErrorCode; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class GeneralException extends RuntimeException { + + private final BaseErrorCode code; + + public ReasonDto getErrorReason() { + return code.getReason(); + } + + public ReasonDto getErrorReasonHttpStatus() { + return code.getReasonHttpStatus(); + } +} \ No newline at end of file diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/exception/GeneralExceptionAdvice.java b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/exception/GeneralExceptionAdvice.java new file mode 100644 index 0000000..9ff7c5a --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/apiPayload/exception/GeneralExceptionAdvice.java @@ -0,0 +1,64 @@ +package com.example.umc10th.global.apiPayload.exception; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.dto.ReasonDto; +import com.example.umc10th.global.apiPayload.code.status.ErrorStatus; +import jakarta.validation.ConstraintViolationException; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GeneralExceptionAdvice { + + @ExceptionHandler(GeneralException.class) + public ResponseEntity> handleGeneralException(GeneralException exception) { + ReasonDto reason = exception.getErrorReasonHttpStatus(); + return ResponseEntity + .status(reason.httpStatus()) + .body(ApiResponse.onFailure(reason.code(), reason.message(), null)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) { + Map errors = new LinkedHashMap<>(); + + for (FieldError fieldError : exception.getBindingResult().getFieldErrors()) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + ErrorStatus status = ErrorStatus.VALIDATION_ERROR; + return ResponseEntity + .status(status.getHttpStatus()) + .body(ApiResponse.onFailure(status.getCode(), status.getMessage(), errors)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException exception) { + ErrorStatus status = ErrorStatus.BAD_REQUEST; + return ResponseEntity + .status(status.getHttpStatus()) + .body(ApiResponse.onFailure(status.getCode(), status.getMessage(), exception.getMessage())); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException exception) { + ErrorStatus status = ErrorStatus.BAD_REQUEST; + return ResponseEntity + .status(status.getHttpStatus()) + .body(ApiResponse.onFailure(status.getCode(), status.getMessage(), "Request body is invalid.")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception exception) { + ErrorStatus status = ErrorStatus.INTERNAL_SERVER_ERROR; + return ResponseEntity + .status(status.getHttpStatus()) + .body(ApiResponse.onFailure(status.getCode(), status.getMessage(), exception.getMessage())); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/config/JacksonConfig.java b/bean/umc10th/src/main/java/com/example/umc10th/global/config/JacksonConfig.java new file mode 100644 index 0000000..793a076 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/config/JacksonConfig.java @@ -0,0 +1,14 @@ +package com.example.umc10th.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java b/bean/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java new file mode 100644 index 0000000..99e6af3 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/config/SecurityConfig.java @@ -0,0 +1,53 @@ +package com.example.umc10th.global.config; + +import com.example.umc10th.global.security.CustomAccessDeniedHandler; +import com.example.umc10th.global.security.CustomAuthenticationEntryPoint; +import com.example.umc10th.global.security.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + // JWT는 매 요청의 Authorization 헤더로 인증하므로 서버 세션을 만들지 않는다. + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/users/signup", "/api/v1/users/login").permitAll() + .requestMatchers("/error").permitAll() + .anyRequest().authenticated() + ) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/config/SwaggerConfig.java b/bean/umc10th/src/main/java/com/example/umc10th/global/config/SwaggerConfig.java index b0ab9b2..e1bdb78 100644 --- a/bean/umc10th/src/main/java/com/example/umc10th/global/config/SwaggerConfig.java +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/config/SwaggerConfig.java @@ -16,14 +16,14 @@ public class SwaggerConfig { public OpenAPI swagger() { Info info = new Info().title("UMC10th").description("10th Swagger").version("0.0.1"); - String securityScheme = "JWT TOKEN"; + String securityScheme = "bearerAuth"; SecurityRequirement securityRequirement = new SecurityRequirement().addList(securityScheme); Components components = new Components() .addSecuritySchemes(securityScheme, new SecurityScheme() .name(securityScheme) .type(SecurityScheme.Type.HTTP) - .scheme("Bearer") + .scheme("bearer") .bearerFormat("JWT")); return new OpenAPI() @@ -32,4 +32,4 @@ public OpenAPI swagger() { .addSecurityItem(securityRequirement) .components(components); } -} \ No newline at end of file +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/security/AuthMember.java b/bean/umc10th/src/main/java/com/example/umc10th/global/security/AuthMember.java new file mode 100644 index 0000000..392b3b1 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/security/AuthMember.java @@ -0,0 +1,32 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import java.util.Collection; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +@Getter +@RequiredArgsConstructor +public class AuthMember implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDeniedHandler.java b/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..ceb14c9 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + ErrorStatus status = ErrorStatus.FORBIDDEN; + response.setStatus(status.getHttpStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString( + ApiResponse.onFailure(status.getCode(), status.getMessage(), null) + )); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomAuthenticationEntryPoint.java b/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..fbc5489 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.global.apiPayload.ApiResponse; +import com.example.umc10th.global.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + ErrorStatus status = ErrorStatus.UNAUTHORIZED; + response.setStatus(status.getHttpStatus().value()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString( + ApiResponse.onFailure(status.getCode(), status.getMessage(), null) + )); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java b/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java new file mode 100644 index 0000000..555af30 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/security/CustomUserDetailsService.java @@ -0,0 +1,24 @@ +package com.example.umc10th.global.security; + +import com.example.umc10th.domain.member.entity.Member; +import com.example.umc10th.domain.member.repository.MemberRepository; +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; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + return new AuthMember(member); + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtAuthenticationFilter.java b/bean/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..2f69269 --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,61 @@ +package com.example.umc10th.global.security.jwt; + +import com.example.umc10th.global.security.CustomUserDetailsService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String token = resolveToken(request); + + if (StringUtils.hasText(token) + && jwtTokenProvider.validateToken(token) + && SecurityContextHolder.getContext().getAuthentication() == null) { + String email = jwtTokenProvider.getSubject(token); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/bean/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtTokenProvider.java b/bean/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..6384f2d --- /dev/null +++ b/bean/umc10th/src/main/java/com/example/umc10th/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,138 @@ +package com.example.umc10th.global.security.jwt; + +import com.example.umc10th.domain.member.entity.Member; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 외부 라이브러리 없이 HMAC-SHA256 기반 JWT access token을 발급하고 검증합니다. + */ +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String TOKEN_TYPE = "Bearer"; + + private final ObjectMapper objectMapper; + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration-millis}") + private long accessTokenExpirationMillis; + + /** + * 인증 성공 또는 회원가입 직후 클라이언트가 Authorization 헤더에 담아 사용할 토큰을 만듭니다. + */ + public TokenInfo generateAccessToken(Member member) { + Instant now = Instant.now(); + Instant expiresAt = now.plusMillis(accessTokenExpirationMillis); + + Map header = new LinkedHashMap<>(); + header.put("alg", "HS256"); + header.put("typ", "JWT"); + + Map payload = new LinkedHashMap<>(); + payload.put("sub", member.getEmail()); + payload.put("memberId", member.getId()); + payload.put("iat", now.getEpochSecond()); + payload.put("exp", expiresAt.getEpochSecond()); + + String unsignedToken = base64UrlEncode(writeJson(header)) + + "." + + base64UrlEncode(writeJson(payload)); + String signature = base64UrlEncode(sign(unsignedToken)); + + return new TokenInfo(unsignedToken + "." + signature, TOKEN_TYPE, accessTokenExpirationMillis); + } + + /** + * 서명과 만료 시간을 함께 검증해 필터에서 신뢰 가능한 토큰만 SecurityContext에 올립니다. + */ + public boolean validateToken(String token) { + try { + Map claims = parseAndVerify(token); + Number expiresAt = (Number) claims.get("exp"); + return expiresAt != null && Instant.now().getEpochSecond() < expiresAt.longValue(); + } catch (RuntimeException exception) { + return false; + } + } + + public String getSubject(String token) { + Object subject = parseAndVerify(token).get("sub"); + if (subject == null) { + throw new IllegalArgumentException("JWT subject is missing."); + } + return subject.toString(); + } + + private Map parseAndVerify(String token) { + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new IllegalArgumentException("JWT must have 3 parts."); + } + + String unsignedToken = parts[0] + "." + parts[1]; + byte[] expectedSignature = sign(unsignedToken); + byte[] actualSignature = base64UrlDecode(parts[2]); + if (!MessageDigest.isEqual(expectedSignature, actualSignature)) { + throw new IllegalArgumentException("JWT signature is invalid."); + } + + try { + return objectMapper.readValue( + base64UrlDecode(parts[1]), + new TypeReference>() { + } + ); + } catch (Exception exception) { + throw new IllegalArgumentException("JWT payload is invalid.", exception); + } + } + + private byte[] writeJson(Map value) { + try { + return objectMapper.writeValueAsBytes(value); + } catch (Exception exception) { + throw new IllegalStateException("Failed to write JWT json.", exception); + } + } + + private byte[] sign(String value) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (Exception exception) { + throw new IllegalStateException("Failed to sign JWT.", exception); + } + } + + private String base64UrlEncode(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } + + private byte[] base64UrlDecode(String value) { + return Base64.getUrlDecoder().decode(value); + } + + public record TokenInfo( + String accessToken, + String tokenType, + Long expiresIn + ) { + } +} diff --git a/bean/umc10th/src/main/resources/application.yml b/bean/umc10th/src/main/resources/application.yml index d5eb749..562ba83 100644 --- a/bean/umc10th/src/main/resources/application.yml +++ b/bean/umc10th/src/main/resources/application.yml @@ -16,4 +16,8 @@ spring: ddl-auto: update properties: hibernate: - format_sql: true \ No newline at end of file + format_sql: true + +jwt: + secret: ${JWT_SECRET:umc10th-jwt-secret-key-for-local-development-needs-at-least-32-bytes} + access-token-expiration-millis: ${JWT_ACCESS_TOKEN_EXPIRATION_MILLIS:3600000}