diff --git a/src/main/java/com/juu/juulabel/admin/AdminController.java b/src/main/java/com/juu/juulabel/admin/AdminController.java index d6023c29..6e8fedb7 100644 --- a/src/main/java/com/juu/juulabel/admin/AdminController.java +++ b/src/main/java/com/juu/juulabel/admin/AdminController.java @@ -9,10 +9,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; @Tag( name = "관리자 API", @@ -35,4 +33,8 @@ public ResponseEntity> assignBadge( return CommonResponse.success(SuccessCode.SUCCESS); } + @GetMapping("/permission/test") + public ResponseEntity test(@AuthenticationPrincipal Member member) { + return ResponseEntity.ok(member); + } } diff --git a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java b/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java index 79553e63..dda372b8 100644 --- a/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java +++ b/src/main/java/com/juu/juulabel/admin/TestAccessTokenController.java @@ -2,6 +2,8 @@ import com.juu.juulabel.common.provider.JwtTokenProvider; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -18,6 +20,7 @@ public class TestAccessTokenController { private final JwtTokenProvider jwtTokenProvider; + private final MemberService memberService; @Operation( summary = "JWT 테스트용 토큰 발급 API", @@ -25,7 +28,8 @@ public class TestAccessTokenController { ) @GetMapping("/token") public String testAccessToken(@RequestParam(defaultValue = "rldh11111@naver.com") String email) { - return jwtTokenProvider.createAccessToken(email); + Member member = memberService.getMemberByEmail(email); + return jwtTokenProvider.createAccessToken(member); } } diff --git a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java index c01eab2b..1b2d8dd3 100644 --- a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java @@ -2,21 +2,21 @@ import com.juu.juulabel.common.filter.JwtAuthorizationFilter; import com.juu.juulabel.common.filter.JwtExceptionFilter; +import com.juu.juulabel.member.domain.MemberRole; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; 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; import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.Arrays; import java.util.List; import static org.springframework.http.HttpMethod.OPTIONS; @@ -29,58 +29,57 @@ public class SecurityConfig { private final JwtExceptionFilter jwtExceptionFilter; private static final String[] PERMIT_PATHS = { - "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", - "/v1/api/alcohols/**", "/v1/api/terms/**", "/v1/api/images", - "/v1/api/members/**", "/v1/api/shared-space/tasting-notes/**", "/v1/api/notifications/**", - "/v1/api/daily-lives/**", "/v1/api/alcoholicDrinks/**", "v1/api/follow" , "/**" + "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", + "/v1/api/alcohols/**", "/v1/api/terms/**", "/v1/api/images", + "/v1/api/members/**", "/v1/api/shared-space/tasting-notes/**", "/v1/api/notifications/**", + "/v1/api/daily-lives/**", "/v1/api/alcoholicDrinks/**", "v1/api/follow", + "/**" }; private static final String[] ALLOW_ORIGINS = { - "http://localhost:8084", - "http://localhost:8080", - "http://localhost:5173", - "http://localhost:3000", - "https://api.juulabel.com", - "https://qa.juulabel.com", - "https://juulabel.com", - "https://juulabel.shop", - "https://juulabel-front.vercel.app/", - "https://juulabel-front-seven.vercel.app/", - "https://d3jwyw9rpnxu8p.cloudfront.net" + "http://localhost:8084", + "http://localhost:8080", + "http://localhost:5173", + "http://localhost:3000", + "https://api.juulabel.com", + "https://qa.juulabel.com", + "https://juulabel.com", + "https://juulabel.shop", + "https://juulabel-front.vercel.app/", + "https://juulabel-front-seven.vercel.app/", + "https://d3jwyw9rpnxu8p.cloudfront.net" }; - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http - .csrf(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .formLogin(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/v1/api/members/logout").authenticated() - .requestMatchers(OPTIONS, "**").permitAll() - .requestMatchers(PERMIT_PATHS).permitAll() - .anyRequest().authenticated() - ) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/v1/api/members/logout").authenticated() + .requestMatchers(OPTIONS, "**").permitAll() + .requestMatchers(PERMIT_PATHS).permitAll() + .requestMatchers("/v1/api/admins/permission/test").hasAnyAuthority(MemberRole.ROLE_ADMIN.name()) + .anyRequest().authenticated() + ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) - .build(); + .build(); } @Bean - CorsConfigurationSource corsConfigurationSource() { + public UrlBasedCorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedHeader("*"); config.addAllowedMethod("*"); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE")); config.setAllowedOrigins(List.of(ALLOW_ORIGINS)); config.addExposedHeader(HttpHeaders.AUTHORIZATION); config.setAllowCredentials(true); diff --git a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java index 27443bf6..79b57075 100644 --- a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package com.juu.juulabel.common.exception.handler; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.juu.juulabel.common.exception.BaseException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.response.CommonResponse; @@ -8,11 +9,13 @@ import io.sentry.Sentry; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.servlet.resource.NoResourceFoundException; +import java.util.Arrays; import java.util.Objects; @Slf4j @@ -60,8 +63,25 @@ public ResponseEntity> handle(MalformedJwtException e) { @ExceptionHandler(NoResourceFoundException.class) public void handle(NoResourceFoundException e) { - // 이거 키면 출력이 너무 많이 됨 - //log.warn("NoResourceFoundException : {}", e.getMessage()); + // 이거 키면 출력이 너무 많이 됨 + //log.warn("NoResourceFoundException : {}", e.getMessage()); } + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleValidationException(HttpMessageNotReadableException exception) { + String errorDetails = ""; + log.error("HttpMessageNotReadableException :", exception); + if (exception.getCause() instanceof InvalidFormatException invalidFormatException) { + if (invalidFormatException.getTargetType() != null && invalidFormatException.getTargetType().isEnum()) { + errorDetails = String.format("'%s'. 값은 다음 중 하나여야 합니다: %s.", + invalidFormatException.getPath().getLast().getFieldName(), + Arrays.toString(invalidFormatException.getTargetType().getEnumConstants()) + ); + } + } + if (errorDetails.isEmpty()) { + errorDetails = exception.getMessage(); + } + return CommonResponse.fail(ErrorCode.VALIDATION_ERROR, errorDetails); + } } diff --git a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java index 5224856a..788bea12 100644 --- a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java @@ -1,63 +1,71 @@ package com.juu.juulabel.common.provider; -import com.juu.juulabel.common.principal.CustomUserDetailsService; -import com.juu.juulabel.common.principal.JuulabelMember; import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.exception.CustomJwtException; import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import javax.crypto.SecretKey; import java.time.Duration; -import java.util.Base64; -import java.util.Date; -import java.util.Optional; +import java.util.*; @Component public class JwtTokenProvider { - public static final Long ACCESS_TOKEN_EXPIRE_TIME = Duration.ofHours(6).toMillis(); + private static final long ACCESS_TOKEN_EXPIRE_TIME = Duration.ofDays(1).toMillis(); + private static final String ISSUER = "juulabel"; + private static final String ROLE_CLAIM = "role"; private final SecretKey key; - private final CustomUserDetailsService customUserDetailsService; - public JwtTokenProvider( - @Value("${spring.jwt.secret}") String key, - CustomUserDetailsService customUserDetailsService - ) { + public JwtTokenProvider(@Value("${spring.jwt.secret}") String key) { byte[] keyBytes = Base64.getDecoder().decode(key); this.key = Keys.hmacShaKeyFor(keyBytes); - this.customUserDetailsService = customUserDetailsService; } - public String createAccessToken(String email) { - Date now = new Date(); - long accessTokenExpireTime = now.getTime() + ACCESS_TOKEN_EXPIRE_TIME; - + public String createAccessToken(Member member) { return Jwts.builder() - .subject(email) - .issuedAt(now) - .expiration(new Date(accessTokenExpireTime)) + .subject(String.valueOf(member.getId())) + .claim(ROLE_CLAIM, member.getRole().name()) + .issuedAt(new Date()) + .issuer(ISSUER) + .expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME)) .signWith(key) .compact(); } - public Authentication getAuthentication(String token) { - JuulabelMember userDetails = (JuulabelMember) customUserDetailsService.loadUserByUsername(getEmailByToken(token)); - return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + + public Authentication getAuthentication(String accessToken) { + Claims claims = parseClaims(accessToken); + + Collection roles = + Collections.singletonList(new SimpleGrantedAuthority(claims.get(ROLE_CLAIM, String.class))); + + Member member = Member.builder() + .id(Long.parseLong(claims.getSubject())) + .build(); + + return new UsernamePasswordAuthenticationToken( + member, + null, + roles + ); } public String resolveToken(String header) { return Optional.ofNullable(header) - .orElseThrow(() -> new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION)) - .replace(AuthConstants.TOKEN_PREFIX, ""); + .orElseThrow(() -> new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION)) + .replace(AuthConstants.TOKEN_PREFIX, ""); } public boolean isValidateToken(String token) { @@ -68,10 +76,6 @@ public boolean isValidateToken(String token) { return !getExpirationByToken(token).before(new Date()); } - public String getEmailByToken(String token) { - return parseClaims(token).getSubject(); - } - public Date getExpirationByToken(String token) { return parseClaims(token).getExpiration(); } diff --git a/src/main/java/com/juu/juulabel/member/service/MemberService.java b/src/main/java/com/juu/juulabel/member/service/MemberService.java index 51979731..d7b31b39 100644 --- a/src/main/java/com/juu/juulabel/member/service/MemberService.java +++ b/src/main/java/com/juu/juulabel/member/service/MemberService.java @@ -9,6 +9,7 @@ import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.dto.request.*; import com.juu.juulabel.common.dto.response.*; +import com.juu.juulabel.common.exception.BaseException; import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.factory.OAuthProviderFactory; @@ -20,6 +21,7 @@ import com.juu.juulabel.follow.repository.FollowReader; import com.juu.juulabel.member.domain.*; import com.juu.juulabel.member.repository.*; +import com.juu.juulabel.member.repository.jpa.MemberJpaRepository; import com.juu.juulabel.member.request.OAuthLoginInfo; import com.juu.juulabel.member.request.OAuthUser; import com.juu.juulabel.member.request.OAuthUserInfo; @@ -63,8 +65,13 @@ public class MemberService { private final WithdrawalRecordWriter withdrawalRecordWriter; private final WithdrawalRecordReader withdrawalRecordReader; private final FollowReader followReader; + private final MemberJpaRepository memberJpaRepository; + public Member getMemberByEmail(String email) { + return memberReader.getByEmail(email); + } + @Transactional public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); @@ -72,9 +79,9 @@ public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { // 인가 코드를 이용해 토큰 발급 요청 String accessToken = providerFactory.getAccessToken( - provider, - authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), - authLoginInfo.propertyMap().get(AuthConstants.CODE) + provider, + authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), + authLoginInfo.propertyMap().get(AuthConstants.CODE) ); // 토큰을 이용해 사용자 정보 가져오기 @@ -86,12 +93,17 @@ public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { validateNotWithdrawnMember(email); - Token token = createTokenForMember(isNewMember, email); // TODO : 카카오와 구글 이메일이 같다면 토큰 중복 사용 가능 여부 확인 - + String generatedToken = jwtTokenProvider.createAccessToken(getMemberByEmail(email)); + Token token; + if (isNewMember) { + token = new Token(null, null); + }else{ + token = new Token(generatedToken, jwtTokenProvider.getExpirationByToken(generatedToken)); + } return new LoginResponse( - token, - isNewMember, - new OAuthUserInfo(email, oAuthUser.id(), provider) + token, + isNewMember, + new OAuthUserInfo(email, oAuthUser.id(), provider) ); } @@ -105,42 +117,34 @@ public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { // TODO // 선호전통주 주종 등록 List memberAlcoholTypeList = - getMemberAlcoholTypeList(member, signUpRequest.alcoholTypeIds()); + getMemberAlcoholTypeList(member, signUpRequest.alcoholTypeIds()); if (!memberAlcoholTypeList.isEmpty()) { memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); } // 약관 등록 List memberTerms = - getAndValidateTermsWithMapping(member, signUpRequest.termsAgreements()); + getAndValidateTermsWithMapping(member, signUpRequest.termsAgreements()); if (!memberTerms.isEmpty()) { memberTermsWriter.storeAll(memberTerms); } - String token = jwtTokenProvider.createAccessToken(member.getEmail()); + String token = jwtTokenProvider.createAccessToken(member); return new SignUpMemberResponse( - member.getId(), - new Token(token, jwtTokenProvider.getExpirationByToken(token)) + member.getId(), + new Token(token, jwtTokenProvider.getExpirationByToken(token)) ); } - private Token createTokenForMember(boolean isNewMember, String email) { - if (isNewMember) { - return new Token(null, null); - } else { - String generatedToken = jwtTokenProvider.createAccessToken(email); - return new Token(generatedToken, jwtTokenProvider.getExpirationByToken(generatedToken)); - } - } private List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList) { return alcoholTypeIdList.stream() - .map(alcoholTypeId -> { - AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); - return MemberAlcoholType.create(member, alcoholType); - }) - .toList(); + .map(alcoholTypeId -> { + AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); + return MemberAlcoholType.create(member, alcoholType); + }) + .toList(); } private List getAndValidateTermsWithMapping(Member member, List termsAgreements) { @@ -165,9 +169,9 @@ private List getMemberTermsList(Member member, List usedTerm usedTermsList.forEach(terms -> { TermsAgreement termsAgreement = termsAgreements.stream() - .filter(agreement -> agreement.termsId().equals(terms.getId())) - .findFirst() - .orElseThrow(() -> new InvalidParamException(ErrorCode.NOT_FOUND_TERMS)); + .filter(agreement -> agreement.termsId().equals(terms.getId())) + .findFirst() + .orElseThrow(() -> new InvalidParamException(ErrorCode.NOT_FOUND_TERMS)); final boolean isAgreed = termsAgreement.isAgreed(); @@ -210,7 +214,7 @@ public UpdateProfileResponse updateProfile(Member loginMember, UpdateProfileRequ @Transactional(readOnly = true) public MyDailyLifeListResponse loadMyDailyLifeList(Member member, DailyLifeListRequest request) { Slice myDailyLifeList = - dailyLifeReader.getAllMyDailyLives(member, request.lastDailyLifeId(), request.pageSize()); + dailyLifeReader.getAllMyDailyLives(member, request.lastDailyLifeId(), request.pageSize()); return new MyDailyLifeListResponse(myDailyLifeList); } @@ -218,7 +222,7 @@ public MyDailyLifeListResponse loadMyDailyLifeList(Member member, DailyLifeListR @Transactional(readOnly = true) public MyTastingNoteListResponse loadMyTastingNoteList(Member member, TastingNoteListRequest request) { Slice myTastingNoteList = - tastingNoteReader.getAllMyTastingNotes(member, request.lastTastingNoteId(), request.pageSize()); + tastingNoteReader.getAllMyTastingNotes(member, request.lastTastingNoteId(), request.pageSize()); return new MyTastingNoteListResponse(myTastingNoteList); } @@ -227,24 +231,24 @@ public MyTastingNoteListResponse loadMyTastingNoteList(Member member, TastingNot public boolean saveAlcoholicDrinks(Member member, Long alcoholicDrinksId) { AlcoholicDrinks alcoholicDrinks = alcoholicDrinksReader.getById(alcoholicDrinksId); Optional memberAlcoholicDrinks = - memberAlcoholicDrinksReader.findByMemberAndAlcoholicDrinks(member, alcoholicDrinks); + memberAlcoholicDrinksReader.findByMemberAndAlcoholicDrinks(member, alcoholicDrinks); // 전통주가 이미 저장되어 있다면 삭제, 저장되어 있지 않다면 등록 return memberAlcoholicDrinks - .map(save -> { - memberAlcoholicDrinksWriter.delete(save); - return false; - }) - .orElseGet(() -> { - memberAlcoholicDrinksWriter.store(member, alcoholicDrinks); - return true; - }); + .map(save -> { + memberAlcoholicDrinksWriter.delete(save); + return false; + }) + .orElseGet(() -> { + memberAlcoholicDrinksWriter.store(member, alcoholicDrinks); + return true; + }); } @Transactional(readOnly = true) public MyAlcoholicDrinksListResponse loadMyAlcoholicDrinks(Member member, MyAlcoholicDrinksListRequest request) { Slice alcoholicDrinksSummaries = - alcoholicDrinksReader.getAllMyAlcoholicDrinks(member, request.lastAlcoholicDrinksId(), request.pageSize()); + alcoholicDrinksReader.getAllMyAlcoholicDrinks(member, request.lastAlcoholicDrinksId(), request.pageSize()); return new MyAlcoholicDrinksListResponse(alcoholicDrinksSummaries); } @@ -257,16 +261,16 @@ public MySpaceResponse getMySpace(Member member) { long followerCount = followReader.countFollower(member); return new MySpaceResponse( - member.getId(), - member.getProfileImage(), - member.getNickname(), - member.getIntroduction(), - member.isHasBadge(), - tastingNoteCount, - dailyLifeCount, - followingCount, - followerCount, - 0 // TODO : 시음노트 저장 기능 추가 시 수정 필요 + member.getId(), + member.getProfileImage(), + member.getNickname(), + member.getIntroduction(), + member.isHasBadge(), + tastingNoteCount, + dailyLifeCount, + followingCount, + followerCount, + 0 // TODO : 시음노트 저장 기능 추가 시 수정 필요 ); } @@ -274,22 +278,22 @@ public MySpaceResponse getMySpace(Member member) { public MyInfoResponse getMyInfo(Member member) { List alcoholTypeIdList = memberAlcoholTypeReader.getIdListByMember(member); return new MyInfoResponse( - member.getId(), - member.getNickname(), - member.getEmail(), - member.isHasBadge(), - member.isNotificationsAllowed(), - member.getIntroduction(), - member.getProfileImage(), - member.getGender(), - alcoholTypeIdList + member.getId(), + member.getNickname(), + member.getEmail(), + member.isHasBadge(), + member.isNotificationsAllowed(), + member.getIntroduction(), + member.getProfileImage(), + member.getGender(), + alcoholTypeIdList ); } @Transactional(readOnly = true) public DailyLifeListResponse loadMemberDailyLifeList(Member loginMember, DailyLifeListRequest request, Long memberId) { Slice dailyLifeList = - dailyLifeReader.getAllDailyLivesByMember(loginMember, memberId, request.lastDailyLifeId(), request.pageSize()); + dailyLifeReader.getAllDailyLivesByMember(loginMember, memberId, request.lastDailyLifeId(), request.pageSize()); return new DailyLifeListResponse(dailyLifeList); } @@ -298,7 +302,7 @@ public DailyLifeListResponse loadMemberDailyLifeList(Member loginMember, DailyLi public TastingNoteListResponse loadMemberTastingNoteList(Member loginMember, TastingNoteListRequest request, Long memberId) { // TODO : 해당 회원(loginMember) 차단 여부 검증 로직 Slice tastingNoteList = - tastingNoteReader.getAllTastingNotesByMember(loginMember, memberId, request.lastTastingNoteId(), request.pageSize()); + tastingNoteReader.getAllTastingNotesByMember(loginMember, memberId, request.lastTastingNoteId(), request.pageSize()); return new TastingNoteListResponse(tastingNoteList); } @@ -314,15 +318,15 @@ public MemberProfileResponse getMemberProfile(Member loginMember, Long memberId) boolean isFollowing = followReader.isFollowing(loginMember, member); return new MemberProfileResponse( - member.getId(), - member.getNickname(), - member.getProfileImage(), - member.getIntroduction(), - member.isHasBadge(), - tastingNoteCount, - dailyLifeCount, - followingCount, - followerCount, + member.getId(), + member.getNickname(), + member.getProfileImage(), + member.getIntroduction(), + member.isHasBadge(), + tastingNoteCount, + dailyLifeCount, + followingCount, + followerCount, isFollowing ); } @@ -349,8 +353,14 @@ private void validateEmail(String email) { public void deleteAccount(Member loginMember, WithdrawalRequest request) { loginMember.deleteAccount(); withdrawalRecordWriter.store( - WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname()) + WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname()) ); } + + @Transactional(readOnly = true) + public Member findById(Long memberId) { + return memberJpaRepository.findById(memberId) + .orElseThrow(() -> new BaseException(ErrorCode.NOT_FOUND_MEMBER)); + } } diff --git a/src/main/java/com/juu/juulabel/report/Report.java b/src/main/java/com/juu/juulabel/report/Report.java new file mode 100644 index 00000000..9b89854f --- /dev/null +++ b/src/main/java/com/juu/juulabel/report/Report.java @@ -0,0 +1,48 @@ +package com.juu.juulabel.report; + +import com.juu.juulabel.common.base.BaseTimeEntity; +import com.juu.juulabel.member.domain.Member; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +@Table(name = "report") +public class Report extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private Member reporter; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_user_id", nullable = false) + private Member reportedUser; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reviewer") + private Member reviewer; + + @Column(name = "reason", nullable = false, length = 255) + private String reason; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private ReportType type; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private ReportStatus status; + + @Column(name = "reviewed_at") + private LocalDateTime reviewedAt; +} diff --git a/src/main/java/com/juu/juulabel/report/ReportController.java b/src/main/java/com/juu/juulabel/report/ReportController.java new file mode 100644 index 00000000..b0bf060b --- /dev/null +++ b/src/main/java/com/juu/juulabel/report/ReportController.java @@ -0,0 +1,28 @@ +package com.juu.juulabel.report; + +import com.juu.juulabel.common.annotation.LoginMember; +import com.juu.juulabel.common.exception.code.SuccessCode; +import com.juu.juulabel.common.response.CommonResponse; +import com.juu.juulabel.member.domain.Member; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "신고 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/api/reports") +public class ReportController { + private final ReportService reportService; + + @PostMapping + public ResponseEntity> createReport( + @Parameter(hidden = true) @LoginMember Member member, + @Valid @RequestBody ReportCreateRequest request) { + reportService.createReport(member.getId(), request); + return CommonResponse.success(SuccessCode.SUCCESS_INSERT); + } +} diff --git a/src/main/java/com/juu/juulabel/report/ReportCreateRequest.java b/src/main/java/com/juu/juulabel/report/ReportCreateRequest.java new file mode 100644 index 00000000..c5257429 --- /dev/null +++ b/src/main/java/com/juu/juulabel/report/ReportCreateRequest.java @@ -0,0 +1,18 @@ +package com.juu.juulabel.report; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + + +public record ReportCreateRequest( + @NotNull(message = "신고 대상을 넣어주세요") + @Min(value = 1, message = "ID는 1 이상이어야 합니다.") + Long reportedUserId, + + @NotBlank(message = "신고 사유를 넣어주세요.") + String reason, + + ReportType type +) { +} diff --git a/src/main/java/com/juu/juulabel/report/ReportRepository.java b/src/main/java/com/juu/juulabel/report/ReportRepository.java new file mode 100644 index 00000000..2d7c2cbb --- /dev/null +++ b/src/main/java/com/juu/juulabel/report/ReportRepository.java @@ -0,0 +1,6 @@ +package com.juu.juulabel.report; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { +} diff --git a/src/main/java/com/juu/juulabel/report/ReportService.java b/src/main/java/com/juu/juulabel/report/ReportService.java new file mode 100644 index 00000000..66334750 --- /dev/null +++ b/src/main/java/com/juu/juulabel/report/ReportService.java @@ -0,0 +1,31 @@ +package com.juu.juulabel.report; + +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final ReportRepository reportRepository; + private final MemberService MemberService; + + @Transactional + public void createReport(Long reporterId, ReportCreateRequest request) { + Member reporter = MemberService.findById(reporterId); + Member reportedUser = MemberService.findById(request.reportedUserId()); + + Report report = Report.builder() + .reporter(reporter) + .reportedUser(reportedUser) + .reason(request.reason()) + .type(request.type()) + .status(ReportStatus.PENDING) + .build(); + + reportRepository.save(report); + } +} diff --git a/src/main/java/com/juu/juulabel/report/ReportStatus.java b/src/main/java/com/juu/juulabel/report/ReportStatus.java new file mode 100644 index 00000000..b92bd9ed --- /dev/null +++ b/src/main/java/com/juu/juulabel/report/ReportStatus.java @@ -0,0 +1,13 @@ +package com.juu.juulabel.report; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum ReportStatus { + PENDING("대기중"), + REVIEWING("검토중"), + REJECTED("거절"), + APPROVED("승인"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/report/ReportType.java b/src/main/java/com/juu/juulabel/report/ReportType.java new file mode 100644 index 00000000..01838879 --- /dev/null +++ b/src/main/java/com/juu/juulabel/report/ReportType.java @@ -0,0 +1,13 @@ +package com.juu.juulabel.report; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum ReportType { + USER("유저"), + TASTING_NOTE("시음노트"), + DAILY_LIFE("일상생활"), + COMMENT("댓글"); + + private final String description; +} \ No newline at end of file