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..da7dc336 100644 --- a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java @@ -32,7 +32,7 @@ public class SecurityConfig { "/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" , "/**" + "/v1/api/daily-lives/**", "/v1/api/alcoholicDrinks/**", "v1/api/follow" , "/**", "/v1/api/reports" }; private static final String[] ALLOW_ORIGINS = { @@ -41,6 +41,7 @@ public class SecurityConfig { "http://localhost:5173", "http://localhost:3000", "https://api.juulabel.com", + "https://dev.juulabel.com", "https://qa.juulabel.com", "https://juulabel.com", "https://juulabel.shop", 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/member/service/MemberService.java b/src/main/java/com/juu/juulabel/member/service/MemberService.java index 51979731..71ba0f1e 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,6 +65,7 @@ public class MemberService { private final WithdrawalRecordWriter withdrawalRecordWriter; private final WithdrawalRecordReader withdrawalRecordReader; private final FollowReader followReader; + private final MemberJpaRepository memberJpaRepository; @Transactional @@ -72,9 +75,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) ); // 토큰을 이용해 사용자 정보 가져오기 @@ -89,9 +92,9 @@ public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { Token token = createTokenForMember(isNewMember, email); // TODO : 카카오와 구글 이메일이 같다면 토큰 중복 사용 가능 여부 확인 return new LoginResponse( - token, - isNewMember, - new OAuthUserInfo(email, oAuthUser.id(), provider) + token, + isNewMember, + new OAuthUserInfo(email, oAuthUser.id(), provider) ); } @@ -105,14 +108,14 @@ 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); } @@ -120,8 +123,8 @@ public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { // TODO String token = jwtTokenProvider.createAccessToken(member.getEmail()); return new SignUpMemberResponse( - member.getId(), - new Token(token, jwtTokenProvider.getExpirationByToken(token)) + member.getId(), + new Token(token, jwtTokenProvider.getExpirationByToken(token)) ); } @@ -136,11 +139,11 @@ private Token createTokenForMember(boolean isNewMember, String email) { 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 +168,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 +213,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 +221,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 +230,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 +260,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 +277,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 +301,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 +317,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 +352,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