diff --git a/src/main/java/com/picnee/travel/api/ReportController.java b/src/main/java/com/picnee/travel/api/ReportController.java new file mode 100644 index 0000000..dd93b2a --- /dev/null +++ b/src/main/java/com/picnee/travel/api/ReportController.java @@ -0,0 +1,71 @@ +package com.picnee.travel.api; + +import com.picnee.travel.api.in.ReportApi; +import com.picnee.travel.domain.report.dto.req.CreateReportReq; +import com.picnee.travel.domain.report.dto.res.FindReportRes; +import com.picnee.travel.domain.report.service.ReportService; +import com.picnee.travel.domain.user.dto.req.AuthenticatedUserReq; +import com.picnee.travel.global.security.annotation.AuthenticatedUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.OK; + +@Slf4j +@RestController +@RequestMapping("/reports") +@RequiredArgsConstructor +public class ReportController implements ReportApi { + + private final ReportService reportService; + + @PostMapping + public ResponseEntity createReport(@Valid @RequestBody CreateReportReq dto, + @AuthenticatedUser AuthenticatedUserReq auth) { + + return ResponseEntity.status(CREATED).body(reportService.create(dto, auth).getId().toString()); + } + + @DeleteMapping("/{reportId}") + public ResponseEntity deleteReport(@PathVariable("reportId") UUID reportId, + @AuthenticatedUser AuthenticatedUserReq auth) { + reportService.delete(reportId, auth); + return ResponseEntity.status(OK).build(); + } + + @GetMapping("/{reportId}") + public ResponseEntity findReport(@PathVariable("reportId") UUID reportId, + @AuthenticatedUser AuthenticatedUserReq auth) { + FindReportRes findReportRes = reportService.find(reportId, auth); + return ResponseEntity.status(OK).body(findReportRes); + } + + @GetMapping + public ResponseEntity> findReports(@RequestParam(name = "targetId", required = false) String targetId, + @RequestParam(name = "reportTargetType", required = false) String reportTargetType, + @RequestParam(name = "reportType", required = false) String reportType, + @RequestParam(name = "is_visible", required = false) String isVisible, + @RequestParam(name = "sort", required = false) String sort, + @RequestParam(name = "page", defaultValue = "0") int page, + @AuthenticatedUser AuthenticatedUserReq auth) { + Page reports = reportService.findReports(auth, targetId, reportTargetType, reportType, isVisible, sort, page); + return ResponseEntity.status(OK).body(reports); + } + + @PatchMapping("/{reportId}") + public ResponseEntity processReport(@PathVariable("reportId") UUID reportId, + @AuthenticatedUser AuthenticatedUserReq auth) { + reportService.processReport(reportId, auth); + return ResponseEntity.status(OK).build(); + } + + + +} diff --git a/src/main/java/com/picnee/travel/api/in/ReportApi.java b/src/main/java/com/picnee/travel/api/in/ReportApi.java new file mode 100644 index 0000000..b377d00 --- /dev/null +++ b/src/main/java/com/picnee/travel/api/in/ReportApi.java @@ -0,0 +1,31 @@ +package com.picnee.travel.api.in; + +import com.picnee.travel.domain.report.dto.req.CreateReportReq; +import com.picnee.travel.domain.report.dto.res.FindReportRes; +import com.picnee.travel.domain.user.dto.req.AuthenticatedUserReq; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; + +import java.util.UUID; + +@Tag(name = "reports", description = "report API") +public interface ReportApi { + @Operation(summary = "신고", description = "신고를 진행한다") + ResponseEntity createReport(CreateReportReq dto, AuthenticatedUserReq auth); + + @Operation(summary = "신고 삭제(어드민)", description = "신고된 것을 삭제한다") + public ResponseEntity deleteReport(UUID reportId, AuthenticatedUserReq auth); + + @Operation(summary = "신고 단건 조회(어드민)", description = "신고한 내역 단건 조회") + public ResponseEntity findReport(UUID reportId, AuthenticatedUserReq auth); + + @Operation(summary = "신고 다중 건 조회(어드민)", description = "신고한 내역 리스트 조회") + public ResponseEntity> findReports(String targetId, String reportTargetType, String reportType, String isVisible, String sort, int page, AuthenticatedUserReq auth); + + @Operation(summary = "신고 제재(어드민)", description = "신고 제재처리") + public ResponseEntity processReport(UUID reportTargetId, AuthenticatedUserReq auth); + + +} diff --git a/src/main/java/com/picnee/travel/domain/post/dto/req/CreatePostReq.java b/src/main/java/com/picnee/travel/domain/post/dto/req/CreatePostReq.java index 2bd417b..76def2a 100644 --- a/src/main/java/com/picnee/travel/domain/post/dto/req/CreatePostReq.java +++ b/src/main/java/com/picnee/travel/domain/post/dto/req/CreatePostReq.java @@ -33,6 +33,7 @@ public static Post toEntity(CreatePostReq dto, Board board, User user) { .title(dto.getTitle()) .content(dto.getContent()) .viewed(0L) + .reportSanctionCount(0) .user(user) .board(board) .build(); diff --git a/src/main/java/com/picnee/travel/domain/post/entity/Post.java b/src/main/java/com/picnee/travel/domain/post/entity/Post.java index e1b8531..cb3d4fd 100644 --- a/src/main/java/com/picnee/travel/domain/post/entity/Post.java +++ b/src/main/java/com/picnee/travel/domain/post/entity/Post.java @@ -43,6 +43,8 @@ public class Post extends SoftDeleteBaseEntity { private String content; @Column(name = "viewed") private Long viewed; + @Column(name = "report_sanction_count") + private Integer reportSanctionCount; @ManyToOne(fetch = LAZY) @JoinColumn(name = "user_id") private User user; @@ -75,4 +77,18 @@ public void softDelete() { public void incrementViewCount() { this.viewed++; } + + /** + * 신고 횟수 누적 + */ + public void reportSanctionCountPlus(){ + this.reportSanctionCount++; + } + + /** + * 게시글 제재 가능여부 확인 + */ + public boolean isRequiringSanctions() { + return this.reportSanctionCount >= 5; + } } diff --git a/src/main/java/com/picnee/travel/domain/post/service/PostService.java b/src/main/java/com/picnee/travel/domain/post/service/PostService.java index 380753c..4f6134d 100644 --- a/src/main/java/com/picnee/travel/domain/post/service/PostService.java +++ b/src/main/java/com/picnee/travel/domain/post/service/PostService.java @@ -120,7 +120,7 @@ public Page findPosts(String boardCategory, String region, String s * 내가 작성한 게시글 조회 */ public Page getMyPosts(AuthenticatedUserReq auth, int page) { - if(!isUserAuthenticated(auth)) { + if (!isUserAuthenticated(auth)) { throw new NotAuthException(NOT_AUTH_EXCEPTION); } @@ -161,4 +161,21 @@ public Post findById(UUID postId) { return postRepository.findById(postId) .orElseThrow(() -> new NotFoundPostException(NOT_FOUND_POST_EXCEPTION)); } + + /** + * 신고된 댓글 제재 + */ + @Transactional + public User sanction(UUID reportTargetId) { + Post post = postRepository.findById(reportTargetId) + .orElseThrow(() -> new NotFoundPostException(NOT_FOUND_POST_EXCEPTION)); + + post.reportSanctionCountPlus(); + if(post.isRequiringSanctions()){ + post.softDelete(); + boardService.delete(post); + } + + return post.getUser(); + } } diff --git a/src/main/java/com/picnee/travel/domain/postComment/dto/req/CreatePostCommentReq.java b/src/main/java/com/picnee/travel/domain/postComment/dto/req/CreatePostCommentReq.java index b2068dd..005e8bb 100644 --- a/src/main/java/com/picnee/travel/domain/postComment/dto/req/CreatePostCommentReq.java +++ b/src/main/java/com/picnee/travel/domain/postComment/dto/req/CreatePostCommentReq.java @@ -27,6 +27,7 @@ public static PostComment toEntity(CreatePostCommentReq dto, User user, Post pos .user(user) .content(dto.content) .likes(0L) + .reportSanctionCount(0) .post(post) .build(); } @@ -36,6 +37,7 @@ public static PostComment toEntityCoComment(Post post, PostComment postComment, .user(user) .content(dto.content) .likes(0L) + .reportSanctionCount(0) .commentParent(postComment) .post(post) .build(); diff --git a/src/main/java/com/picnee/travel/domain/postComment/entity/PostComment.java b/src/main/java/com/picnee/travel/domain/postComment/entity/PostComment.java index 4966a66..24e9aba 100644 --- a/src/main/java/com/picnee/travel/domain/postComment/entity/PostComment.java +++ b/src/main/java/com/picnee/travel/domain/postComment/entity/PostComment.java @@ -42,6 +42,8 @@ public class PostComment extends SoftDeleteBaseEntity { private String content; @Column(name = "likes") private Long likes; + @Column(name = "report_sanction_count") + private Integer reportSanctionCount; @ManyToOne(fetch = LAZY) @JoinColumn(name = "post_comment_parent_id") private PostComment commentParent; @@ -75,4 +77,25 @@ public void addLike() { public void deleteLike() { this.likes--; } + + /** + * 댓글 삭제 + */ + public void softDelete() { + super.delete(); + } + + /** + * 신고 횟수 누적 + */ + public void reportSanctionCountPlus(){ + this.reportSanctionCount++; + } + + /** + * 댓글 제재 가능여부 확인 + */ + public boolean isRequiringSanctions() { + return this.reportSanctionCount >= 5; + } } diff --git a/src/main/java/com/picnee/travel/domain/postComment/service/PostCommentService.java b/src/main/java/com/picnee/travel/domain/postComment/service/PostCommentService.java index e7b6295..e573d27 100644 --- a/src/main/java/com/picnee/travel/domain/postComment/service/PostCommentService.java +++ b/src/main/java/com/picnee/travel/domain/postComment/service/PostCommentService.java @@ -184,4 +184,19 @@ public PostComment findById(UUID commentId) { return postCommentRepository.findById(commentId) .orElseThrow(() -> new NotFoundCommentException(NOT_FOUND_COMMENT_EXCEPTION)); } + + /** + * 신고된 댓글 제재 + */ + @Transactional + public User sanction(UUID reportTargetId) { + PostComment postComment = findById(reportTargetId); + + postComment.reportSanctionCountPlus(); + if(postComment.isRequiringSanctions()){ + postComment.softDelete(); + } + + return postComment.getUser(); + } } diff --git a/src/main/java/com/picnee/travel/domain/report/dto/req/CreateReportReq.java b/src/main/java/com/picnee/travel/domain/report/dto/req/CreateReportReq.java new file mode 100644 index 0000000..75c47ff --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/report/dto/req/CreateReportReq.java @@ -0,0 +1,41 @@ +package com.picnee.travel.domain.report.dto.req; + +import com.picnee.travel.domain.report.entity.Report; +import com.picnee.travel.domain.report.entity.ReportTargetType; +import com.picnee.travel.domain.report.entity.ReportType; +import com.picnee.travel.domain.user.entity.User; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +import static lombok.AccessLevel.PROTECTED; + +@Getter +@Builder +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class CreateReportReq { + + @NotNull(message = "targetId는 필수입니다.") + private UUID targetId; + + @NotNull(message = "신고 대상은 타입은 필수입니다.(ex : REVIEW)") + private ReportTargetType reportTargetType; + + @NotNull(message = "신고 유형은 필수입니다.(ex : ADVERTISEMENT)") + private ReportType reportType; + + public Report toEntity(CreateReportReq dto, User user) { + return Report.builder() + .targetId(dto.getTargetId()) + .reportTargetType(dto.getReportTargetType()) + .reportType(dto.getReportType()) + .isVisible(false) + .user(user) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/picnee/travel/domain/report/dto/res/FindReportRes.java b/src/main/java/com/picnee/travel/domain/report/dto/res/FindReportRes.java new file mode 100644 index 0000000..2cb0585 --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/report/dto/res/FindReportRes.java @@ -0,0 +1,53 @@ +package com.picnee.travel.domain.report.dto.res; + +import com.picnee.travel.domain.report.entity.Report; +import com.picnee.travel.domain.report.entity.ReportTargetType; +import com.picnee.travel.domain.report.entity.ReportType; +import com.picnee.travel.domain.user.entity.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static lombok.AccessLevel.PROTECTED; + +@Getter +@Builder +@NoArgsConstructor(access = PROTECTED) +@AllArgsConstructor +public class FindReportRes { + + private UUID reportId; + private UUID targetId; + private ReportTargetType reportTargetType; + private ReportType reportType; + private Boolean isVisible; + private LocalDateTime createAt; + private UUID userId; + + public static FindReportRes from(Report report){ + return FindReportRes.builder() + .reportId(report.getId()) + .targetId(report.getTargetId()) + .reportTargetType(report.getReportTargetType()) + .reportType(report.getReportType()) + .isVisible(report.getIsVisible()) + .createAt(report.getCreatedAt()) + .userId(report.getUser().getId()) + .build(); + } + + public static Page paging(Page reports) { + List reportResList = reports.stream() + .map(FindReportRes::from) + .toList(); + + return new PageImpl<>(reportResList, reports.getPageable(), reports.getTotalElements()); + } +} diff --git a/src/main/java/com/picnee/travel/domain/report/entity/Report.java b/src/main/java/com/picnee/travel/domain/report/entity/Report.java index 9438b26..5cfbfb1 100644 --- a/src/main/java/com/picnee/travel/domain/report/entity/Report.java +++ b/src/main/java/com/picnee/travel/domain/report/entity/Report.java @@ -33,7 +33,6 @@ public class Report extends BaseEntity { @JdbcTypeCode(SqlTypes.VARCHAR) @Column(name = "report_id", columnDefinition = "VARCHAR(36)") private UUID id; - @UuidGenerator(style = RANDOM) @JdbcTypeCode(SqlTypes.VARCHAR) @Column(name = "target_id", columnDefinition = "VARCHAR(36)") private UUID targetId; @@ -48,4 +47,8 @@ public class Report extends BaseEntity { @ManyToOne(fetch = LAZY) @JoinColumn(name = "user_id") private User user; + + public void softDelete() { + this.isVisible = true; + } } diff --git a/src/main/java/com/picnee/travel/domain/report/entity/ReportType.java b/src/main/java/com/picnee/travel/domain/report/entity/ReportType.java index bf53a40..b95d7b8 100644 --- a/src/main/java/com/picnee/travel/domain/report/entity/ReportType.java +++ b/src/main/java/com/picnee/travel/domain/report/entity/ReportType.java @@ -1,6 +1,6 @@ package com.picnee.travel.domain.report.entity; public enum ReportType { - ADVERTISEMENT, - SWEAR + ADVERTISEMENT, // 스팸 + SWEAR // 욕설 } diff --git a/src/main/java/com/picnee/travel/domain/report/exception/NotFoundReportException.java b/src/main/java/com/picnee/travel/domain/report/exception/NotFoundReportException.java new file mode 100644 index 0000000..b2981c4 --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/report/exception/NotFoundReportException.java @@ -0,0 +1,10 @@ +package com.picnee.travel.domain.report.exception; + +import com.picnee.travel.global.exception.BusinessException; +import com.picnee.travel.global.exception.ErrorCode; + +public class NotFoundReportException extends BusinessException { + public NotFoundReportException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/picnee/travel/domain/report/repository/ReportRepository.java b/src/main/java/com/picnee/travel/domain/report/repository/ReportRepository.java new file mode 100644 index 0000000..d99acdc --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/report/repository/ReportRepository.java @@ -0,0 +1,11 @@ +package com.picnee.travel.domain.report.repository; + +import com.picnee.travel.domain.report.entity.Report; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface ReportRepository extends JpaRepository, ReportRepositoryCustom { +} diff --git a/src/main/java/com/picnee/travel/domain/report/repository/ReportRepositoryCustom.java b/src/main/java/com/picnee/travel/domain/report/repository/ReportRepositoryCustom.java new file mode 100644 index 0000000..38ee9dd --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/report/repository/ReportRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.picnee.travel.domain.report.repository; + +import com.picnee.travel.domain.report.entity.Report; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.UUID; + +public interface ReportRepositoryCustom { + + Page findReports(String targetId, String reportTargetType, String reportType, String isVisible, String sort, Pageable pageable); +} diff --git a/src/main/java/com/picnee/travel/domain/report/repository/ReportRepositoryImpl.java b/src/main/java/com/picnee/travel/domain/report/repository/ReportRepositoryImpl.java new file mode 100644 index 0000000..787fadf --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/report/repository/ReportRepositoryImpl.java @@ -0,0 +1,103 @@ +package com.picnee.travel.domain.report.repository; + +import com.picnee.travel.domain.report.entity.QReport; +import com.picnee.travel.domain.report.entity.Report; +import com.picnee.travel.domain.report.entity.ReportTargetType; +import com.picnee.travel.domain.report.entity.ReportType; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.hibernate.tool.schema.TargetType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +public class ReportRepositoryImpl implements ReportRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Page findReports(String targetId, String reportTargetType, String reportType, String isVisible, String sort, Pageable pageable) { + QReport report = QReport.report; + + // 조건 빌딩 메서드 호출 + BooleanBuilder builder = buildCondition(report, targetId, reportTargetType, reportType, isVisible); + + JPAQuery query = jpaQueryFactory + .selectFrom(report) + .where(builder); + + // 정렬 조건 추가 + if (sort != null) { + OrderSpecifier orderSpecifier = getOrderSpecifier(report, sort); + query.orderBy(orderSpecifier); + } + + List reports = query.offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = Optional.ofNullable(jpaQueryFactory + .select(report.count()) + .from(report).fetchOne()) + .orElse(0L); + + return new PageImpl<>(reports, pageable, total); + } + + // 조건 빌딩 + private BooleanBuilder buildCondition(QReport report, String targetId, String reportTargetType, String reportType, String isVisible) { + BooleanBuilder builder = new BooleanBuilder(); + + // 기본 조건 추가 + if ("true".equalsIgnoreCase(isVisible)) { + builder.and(report.isVisible.isTrue()); + } else { + builder.and(report.isVisible.isFalse()); + } + + // 조건별 동적 빌딩 + if (targetId != null) { + builder.and(report.targetId.eq(UUID.fromString(targetId))); + } + if (reportTargetType != null) { + try { + builder.and(report.reportTargetType.eq(ReportTargetType.valueOf(reportTargetType))); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid reportTargetType: " + reportTargetType); + } + } + if (reportType != null) { + builder.and(report.reportType.eq(ReportType.valueOf(reportType))); + } + + return builder; + } + + private OrderSpecifier getOrderSpecifier(QReport report, String sort) { + switch (sort.toLowerCase()) { + case "asc": + return report.createdAt.asc(); + case "desc": + return report.createdAt.desc(); + default: + throw new IllegalArgumentException("Invalid sort order: " + sort); + } + } +} diff --git a/src/main/java/com/picnee/travel/domain/report/service/ReportService.java b/src/main/java/com/picnee/travel/domain/report/service/ReportService.java new file mode 100644 index 0000000..66c2027 --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/report/service/ReportService.java @@ -0,0 +1,123 @@ +package com.picnee.travel.domain.report.service; + +import com.picnee.travel.domain.post.service.PostService; +import com.picnee.travel.domain.postComment.service.PostCommentService; +import com.picnee.travel.domain.report.dto.req.CreateReportReq; +import com.picnee.travel.domain.report.dto.res.FindReportRes; +import com.picnee.travel.domain.report.entity.Report; +import com.picnee.travel.domain.report.exception.NotFoundReportException; +import com.picnee.travel.domain.report.repository.ReportRepository; +import com.picnee.travel.domain.review.service.ReviewService; +import com.picnee.travel.domain.user.dto.req.AuthenticatedUserReq; +import com.picnee.travel.domain.user.entity.User; +import com.picnee.travel.domain.user.exception.NotAdminException; +import com.picnee.travel.domain.user.service.UserService; +import com.picnee.travel.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static com.picnee.travel.global.exception.ErrorCode.*; + +@Slf4j +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ReportService { + + private final ReportRepository reportRepository; + private final UserService userService; + private final PostService postService; + private final PostCommentService postCommentService; + private final ReviewService reviewService; + + /** + * 신고 생성 + */ + @Transactional + public Report create(CreateReportReq dto, AuthenticatedUserReq auth) { + User user = userService.findByEmail(auth.getEmail()); + + return reportRepository.save(dto.toEntity(dto, user)); + } + + /** + * 신고 삭제 + * 권한 : 어드민 + */ + @Transactional + public void delete(UUID reportId, AuthenticatedUserReq auth) { + validateAdmin(auth); + + reportRepository.deleteById(reportId); + } + + /** + * 신고 단건 조회 + */ + public FindReportRes find(UUID reportId, AuthenticatedUserReq auth) { + validateAdmin(auth); + Report report = reportRepository.findById(reportId).orElseThrow(() -> new IllegalArgumentException("신고 건이 존재하지않습니다.")); + + return FindReportRes.from(report); + } + + /** + * 신고 전체 조회 + * 권한 : 어드민 + */ + public Page findReports(AuthenticatedUserReq auth, String targetId, String reportTargetType, String reportType, String isVisible, String sort, int page) { + validateAdmin(auth); + + Pageable pageable = PageRequest.of(page, 10); + Page reports = reportRepository.findReports(targetId, reportTargetType, reportType, isVisible, sort, pageable); + + return FindReportRes.paging(reports); + } + + /** + * 신고 처리 + * 권한 : 어드민 + */ + @Transactional + public void processReport(UUID reportId, AuthenticatedUserReq auth) { + validateAdmin(auth); + Report report = findById(reportId); + UUID reportTargetId = report.getTargetId(); + User reportedUser = switch (report.getReportTargetType()) { + case REVIEW -> reviewService.sanction(reportTargetId); + case POST -> postService.sanction(reportTargetId); + case COMMENT -> postCommentService.sanction(reportTargetId); + }; + + //리포트 누적 5회 시 유저 계정 정지 BLOCKED 처리 + reportedUser.reportSanctionCountPlus(); + if(reportedUser.isRequiringSanctions()){ + reportedUser.updateBlockedStatus(); + } + + report.softDelete(); + + } + + /** + * 어드민 권한 확인 + */ + private void validateAdmin(AuthenticatedUserReq auth) { + if (!auth.isAdmin()) { + throw new NotAdminException(NOT_ADMIN_EXCEPTION); + } + } + + private Report findById(UUID reportId) { + return reportRepository.findById(reportId) + .orElseThrow(() -> new NotFoundReportException(NOT_FOUND_REPORT_EXCEPTION)); + } + +} diff --git a/src/main/java/com/picnee/travel/domain/review/dto/req/BaseReviewReq.java b/src/main/java/com/picnee/travel/domain/review/dto/req/BaseReviewReq.java index 5046444..f4ef24c 100644 --- a/src/main/java/com/picnee/travel/domain/review/dto/req/BaseReviewReq.java +++ b/src/main/java/com/picnee/travel/domain/review/dto/req/BaseReviewReq.java @@ -26,6 +26,7 @@ public Review toEntity(BaseReviewReq dto, User user, Place place) { .placeTips(dto.getPlaceTips()) .rating(dto.getRating()) .likes(0L) + .reportSanctionCount(0) .user(user) .place(place) .build(); diff --git a/src/main/java/com/picnee/travel/domain/review/entity/Review.java b/src/main/java/com/picnee/travel/domain/review/entity/Review.java index 41e2609..feb12df 100644 --- a/src/main/java/com/picnee/travel/domain/review/entity/Review.java +++ b/src/main/java/com/picnee/travel/domain/review/entity/Review.java @@ -45,6 +45,8 @@ public class Review extends SoftDeleteBaseEntity { private String placeTips; @Column(name = "likes") private Long likes; + @Column(name = "report_sanction_count") + private Integer reportSanctionCount; @Column(name = "rating") private Double rating; @ManyToOne(fetch = LAZY) @@ -95,4 +97,18 @@ public void addLike() { public void deleteLike() { this.likes--; } + + /** + * 신고 횟수 누적 + */ + public void reportSanctionCountPlus(){ + this.reportSanctionCount++; + } + + /** + * 리뷰 제재 가능여부 확인 + */ + public boolean isRequiringSanctions() { + return this.reportSanctionCount >= 5; + } } diff --git a/src/main/java/com/picnee/travel/domain/review/service/ReviewService.java b/src/main/java/com/picnee/travel/domain/review/service/ReviewService.java index e6a7460..0354d38 100644 --- a/src/main/java/com/picnee/travel/domain/review/service/ReviewService.java +++ b/src/main/java/com/picnee/travel/domain/review/service/ReviewService.java @@ -323,4 +323,19 @@ public Review findById(UUID reviewId) { private boolean isUserAuthenticated(AuthenticatedUserReq auth) { return auth == null; } + + /** + * 신고된 리뷰 제재 + */ + @Transactional + public User sanction(UUID reportTargetId) { + Review review = findByIdNotDeletedReview(reportTargetId); + + review.reportSanctionCountPlus(); + if(review.isRequiringSanctions()){ + review.softDelete(); + } + + return review.getUser(); + } } diff --git a/src/main/java/com/picnee/travel/domain/user/dto/req/AuthenticatedUserReq.java b/src/main/java/com/picnee/travel/domain/user/dto/req/AuthenticatedUserReq.java index 148c30b..ee99583 100644 --- a/src/main/java/com/picnee/travel/domain/user/dto/req/AuthenticatedUserReq.java +++ b/src/main/java/com/picnee/travel/domain/user/dto/req/AuthenticatedUserReq.java @@ -24,4 +24,8 @@ public static AuthenticatedUserReq of(User user) { .role(user.getRole()) .build(); } + + public boolean isAdmin() { + return role == Role.ADMIN; + } } diff --git a/src/main/java/com/picnee/travel/domain/user/entity/User.java b/src/main/java/com/picnee/travel/domain/user/entity/User.java index c525108..9548629 100644 --- a/src/main/java/com/picnee/travel/domain/user/entity/User.java +++ b/src/main/java/com/picnee/travel/domain/user/entity/User.java @@ -50,6 +50,8 @@ public class User extends SoftDeleteBaseEntity { private Gender gender; @Column(name = "social_root") private String socialRoot; + @Column(name = "report_sanction_count") + private Integer reportSanctionCount; @JsonProperty("password_count") @Column(name = "password_count") private Integer passwordCount; @@ -83,10 +85,22 @@ public void resetPasswordCount() { this.passwordCount = 0; } + public void reportSanctionCountPlus(){ + this.reportSanctionCount++; + } + + public boolean isRequiringSanctions() { + return this.reportSanctionCount >= 5; + } + public void updateLockedStatus() { this.state = State.LOCKED; } + public void updateBlockedStatus() { + this.state = State.BLOCKED; + } + public void changeNullState() { this.state = null; } diff --git a/src/main/java/com/picnee/travel/domain/user/exception/NotAdminException.java b/src/main/java/com/picnee/travel/domain/user/exception/NotAdminException.java new file mode 100644 index 0000000..42183f4 --- /dev/null +++ b/src/main/java/com/picnee/travel/domain/user/exception/NotAdminException.java @@ -0,0 +1,10 @@ +package com.picnee.travel.domain.user.exception; + +import com.picnee.travel.global.exception.BusinessException; +import com.picnee.travel.global.exception.ErrorCode; + +public class NotAdminException extends BusinessException { + public NotAdminException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/picnee/travel/domain/user/service/UserService.java b/src/main/java/com/picnee/travel/domain/user/service/UserService.java index 2057db4..66120c5 100644 --- a/src/main/java/com/picnee/travel/domain/user/service/UserService.java +++ b/src/main/java/com/picnee/travel/domain/user/service/UserService.java @@ -47,6 +47,7 @@ public User create(CreateUserReq dto) { .nickname(dto.getNickname()) .password(passwordEncoder.encode(dto.getPassword())) .passwordCount(0) + .reportSanctionCount(0) .accountLock(false) .lastPasswordExpired(LocalDateTime.now()) .profileImage(null) @@ -166,5 +167,9 @@ public void validateUser(User user) { if (user.getState() == LOCKED) { throw new LoginLockedException(LOGIN_LOCKED_EXCEPTION); } + + if(user.getState() == BLOCKED) { + throw new LoginLockedException(LOGIN_BLOCKED_EXCEPTION); + } } } diff --git a/src/main/java/com/picnee/travel/global/exception/ErrorCode.java b/src/main/java/com/picnee/travel/global/exception/ErrorCode.java index ed6febf..16f61be 100644 --- a/src/main/java/com/picnee/travel/global/exception/ErrorCode.java +++ b/src/main/java/com/picnee/travel/global/exception/ErrorCode.java @@ -21,16 +21,18 @@ public enum ErrorCode { */ LOGIN_FAILED_EXCEPTION(UNAUTHORIZED, "G001", "회 로그인에 실패했습니다. 5회 실패 시 계정이 차단됩니다."), LOGIN_LOCKED_EXCEPTION(UNAUTHORIZED, "G002", "비밀번호를 오입력으로 계정이 잠겼습니다. 비밀번호를 변경해주세요."), - NOT_POST_AUTHOR_EXCEPTION(UNAUTHORIZED, "G003", "게시글의 작성자가 아닙니다. 본인이 작성한 게시글만 수정/삭제가 가능합니다."), - NOT_LOGIN_EXCEPTION(UNAUTHORIZED, "G004", "로그인이 필요한 서비스입니다."), - NOT_VALID_REFRESH_TOKEN_EXCEPTION(UNAUTHORIZED, "005", "유효하지 않은 토큰입니다."), - NOT_VALID_OWNER_EXCEPTION(UNAUTHORIZED, "G006", "작성자만 삭제/수정이 가능합니다."), - NOT_NOTIFICATION_RECIPIENT_EXCEPTION(UNAUTHORIZED, "G007", "알림 수신자가 아닙니다. 본인의 알림만 읽을 수 있습니다."), - NOT_PROVIDE_OAUTH_EXCEPTION(UNAUTHORIZED, "G008", "올바르지 않은 소셜 로그인입니다."), - NOT_PROVIDE_COMMENT_LIKE_EXCEPTION(UNAUTHORIZED, "G009", "댓글 좋아요는 로그인시 가능합니다."), - NOT_AUTH_EXCEPTION(UNAUTHORIZED, "G010", "로그인한 유저만 접근가능합니다."), - NOT_REVIEW_AUTHOR_EXCEPTION(UNAUTHORIZED, "G011", "리뷰의 작성자가 아닙니다. 본인이 작성한 리뷰만 수정/삭제가 가능합니다."), - EXISTS_ALREADY_REVIEW_EXCEPTION(UNAUTHORIZED, "G012", "이미 해당 리뷰에 평가를 하였습니다."), + LOGIN_BLOCKED_EXCEPTION(UNAUTHORIZED, "G003", "누적된 신고로 계정이 잠겼습니다."), + NOT_POST_AUTHOR_EXCEPTION(UNAUTHORIZED, "G004", "게시글의 작성자가 아닙니다. 본인이 작성한 게시글만 수정/삭제가 가능합니다."), + NOT_LOGIN_EXCEPTION(UNAUTHORIZED, "G005", "로그인이 필요한 서비스입니다."), + NOT_VALID_REFRESH_TOKEN_EXCEPTION(UNAUTHORIZED, "006", "유효하지 않은 토큰입니다."), + NOT_VALID_OWNER_EXCEPTION(UNAUTHORIZED, "G007", "작성자만 삭제/수정이 가능합니다."), + NOT_NOTIFICATION_RECIPIENT_EXCEPTION(UNAUTHORIZED, "G008", "알림 수신자가 아닙니다. 본인의 알림만 읽을 수 있습니다."), + NOT_PROVIDE_OAUTH_EXCEPTION(UNAUTHORIZED, "G009", "올바르지 않은 소셜 로그인입니다."), + NOT_PROVIDE_COMMENT_LIKE_EXCEPTION(UNAUTHORIZED, "G010", "댓글 좋아요는 로그인시 가능합니다."), + NOT_AUTH_EXCEPTION(UNAUTHORIZED, "G011", "로그인한 유저만 접근가능합니다."), + NOT_REVIEW_AUTHOR_EXCEPTION(UNAUTHORIZED, "G012", "리뷰의 작성자가 아닙니다. 본인이 작성한 리뷰만 수정/삭제가 가능합니다."), + EXISTS_ALREADY_REVIEW_EXCEPTION(UNAUTHORIZED, "G013", "이미 해당 리뷰에 평가를 하였습니다."), + NOT_ADMIN_EXCEPTION(UNAUTHORIZED, "G014", "어드민 권한이 아닙니다"), /** * 403 @@ -52,7 +54,8 @@ public enum ErrorCode { NOT_FOUND_NOTIFICATION_EXCEPTION(NOT_FOUND, "G008", "존재하지 않는 알림입니다."), NOT_FOUND_REVIEW_EXCEPTION(NOT_FOUND, "G009", "존재하지 않는 리뷰입니다."), NOT_FOUND_REVIEW_CATEGORY_EXCEPTION(NOT_FOUND, "G010", "존재하지 않는 리뷰 카테고리입니다."), - NOT_FOUND_PLACE_EXCEPTION(NOT_FOUND, "G010", "존재하지 않는 장소 입니다."), + NOT_FOUND_PLACE_EXCEPTION(NOT_FOUND, "G011", "존재하지 않는 장소 입니다."), + NOT_FOUND_REPORT_EXCEPTION(NOT_FOUND, "G012", "존재하지 않는 리뷰 입니다."), /** * 500 diff --git a/src/main/java/com/picnee/travel/global/oauth/OAuthAttributes.java b/src/main/java/com/picnee/travel/global/oauth/OAuthAttributes.java index 16ef473..9fc5f9e 100644 --- a/src/main/java/com/picnee/travel/global/oauth/OAuthAttributes.java +++ b/src/main/java/com/picnee/travel/global/oauth/OAuthAttributes.java @@ -49,6 +49,7 @@ public User toEntity() { .isDefaultNickname(isDefaultNickname) .socialRoot(socialRoot) .passwordCount(0) + .reportSanctionCount(0) .accountLock(false) .lastPasswordExpired(LocalDateTime.now()) .profileImage(null) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index d3bc46b..6df8660 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -25,6 +25,7 @@ CREATE TABLE `users` ( `nickname` VARCHAR(255) NOT NULL UNIQUE, `gender` VARCHAR(10), `social_root` VARCHAR(20), + `report_sanction_count` INT NOT NULL DEFAULT 0, `password_count` INT NOT NULL DEFAULT 0, `account_lock` BOOLEAN NOT NULL, `last_password_expired` TIMESTAMP NOT NULL, @@ -84,6 +85,7 @@ CREATE TABLE `post` ( `title` VARCHAR(255) NOT NULL, `content` VARCHAR(255) NOT NULL, `viewed` BIGINT DEFAULT 0, + `report_sanction_count` INT NOT NULL DEFAULT 0, `created_at` TIMESTAMP NOT NULL, `modified_at` TIMESTAMP NOT NULL, `deleted_at` TIMESTAMP NULL, @@ -97,6 +99,7 @@ CREATE TABLE `post_comment` ( `post_comment_id` VARCHAR(36) NOT NULL, `content` LONGTEXT NOT NULL, `likes` BIGINT DEFAULT 0, + `report_sanction_count` INT NOT NULL DEFAULT 0, `created_at` TIMESTAMP NOT NULL, `modified_at` TIMESTAMP NOT NULL, `deleted_at` TIMESTAMP NULL, @@ -179,6 +182,7 @@ CREATE TABLE `review` ( `place_tips` LONGTEXT NULL, `rating` DOUBLE NOT NULL, `likes` BIGINT DEFAULT 0, + `report_sanction_count` INT NOT NULL DEFAULT 0, `created_at` TIMESTAMP NOT NULL, `modified_at` TIMESTAMP NOT NULL, `deleted_at` TIMESTAMP NULL, @@ -230,7 +234,7 @@ CREATE TABLE `review_vote_accommodation` ( `has_good_soundproofing` BOOLEAN NOT NULL, `has_delicious_breakfast` BOOLEAN NOT NULL, `has_friendly_service` BOOLEAN NOT NULL, - `is_easy_public_transport` BOOLEAN NOT NULL + `is_easy_public_transport` BOOLEAN NOT NULL, PRIMARY KEY (`review_id`), FOREIGN KEY (`review_id`) REFERENCES `review`(`review_id`) ); @@ -238,7 +242,7 @@ CREATE TABLE `review_vote_accommodation` ( CREATE TABLE `review_vote_touristspot` ( `review_id` VARCHAR(36) NOT NULL, `is_paid_entry` BOOLEAN NOT NULL, - `is_reservation_required` BOOLEAN NOT NULL, + `is_reservation_required` BOOLEAN `` NOT NULL, `is_korean_guide_available` BOOLEAN NOT NULL, `is_bike_parking_available` BOOLEAN NOT NULL, `is_car_parking_available` BOOLEAN NOT NULL, @@ -251,7 +255,7 @@ CREATE TABLE `review_vote_touristspot` ( `has_experience_programs` BOOLEAN NOT NULL, `has_clean_restrooms` BOOLEAN NOT NULL, `is_easy_public_transport` BOOLEAN NOT NULL, - `is_quiet_and_peaceful` BOOLEAN NOT NULL + `is_quiet_and_peaceful` BOOLEAN NOT NULL, PRIMARY KEY (`review_id`), FOREIGN KEY (`review_id`) REFERENCES `review`(`review_id`) );