diff --git a/src/main/java/com/sb/web/auth/mapper/WithdrawMapper.java b/src/main/java/com/sb/web/auth/mapper/WithdrawMapper.java index 60e652a..afef6fc 100644 --- a/src/main/java/com/sb/web/auth/mapper/WithdrawMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/WithdrawMapper.java @@ -27,7 +27,8 @@ public interface WithdrawMapper { int deletePostsByAuthor(@Param("memberId") Long memberId); int deleteBoardImagesByMember(@Param("memberId") Long memberId); - // 포인트 / 결제 / 세션 + // 신고 / 포인트 / 결제 / 세션 + int deleteReportsByMember(@Param("memberId") Long memberId); int deletePointHistory(@Param("memberId") Long memberId); int deleteIapPurchases(@Param("memberId") Long memberId); int deleteAuthSessions(@Param("memberId") Long memberId); diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 8eca1d9..381cddd 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -324,7 +324,8 @@ public class AuthService { backupMapper.deleteCategories(memberId); backupMapper.deleteTags(memberId); backupMapper.deleteWallets(memberId); - // 3) 포인트 / 결제 / 세션 + // 3) 신고 / 포인트 / 결제 / 세션 + withdrawMapper.deleteReportsByMember(memberId); withdrawMapper.deletePointHistory(memberId); withdrawMapper.deleteIapPurchases(memberId); withdrawMapper.deleteAuthSessions(memberId); diff --git a/src/main/java/com/sb/web/board/controller/BoardController.java b/src/main/java/com/sb/web/board/controller/BoardController.java index 09f4c89..cbc38f0 100644 --- a/src/main/java/com/sb/web/board/controller/BoardController.java +++ b/src/main/java/com/sb/web/board/controller/BoardController.java @@ -178,4 +178,22 @@ public class BoardController { @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { return boardService.voteComment(commentId, req.getType(), current); } + + /** 게시글 신고 (누적 5건 시 블라인드) */ + @PostMapping("/posts/{id}/report") + public ResponseEntity reportPost( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.reportPost(id, current); + return ResponseEntity.noContent().build(); + } + + /** 댓글 신고 (누적 5건 시 블라인드) */ + @PostMapping("/comments/{commentId}/report") + public ResponseEntity reportComment( + @PathVariable Long commentId, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.reportComment(commentId, current); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/sb/web/board/domain/Comment.java b/src/main/java/com/sb/web/board/domain/Comment.java index b2a3398..e4954ce 100644 --- a/src/main/java/com/sb/web/board/domain/Comment.java +++ b/src/main/java/com/sb/web/board/domain/Comment.java @@ -24,5 +24,6 @@ public class Comment implements Serializable { private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함) private String authorProfileImage; // 조회 시 member JOIN (저장 안 함) private String content; + private Boolean blocked; // 신고 누적 블라인드 private LocalDateTime createdAt; } diff --git a/src/main/java/com/sb/web/board/dto/CommentResponse.java b/src/main/java/com/sb/web/board/dto/CommentResponse.java index 7fe3fdc..4073288 100644 --- a/src/main/java/com/sb/web/board/dto/CommentResponse.java +++ b/src/main/java/com/sb/web/board/dto/CommentResponse.java @@ -24,6 +24,7 @@ public class CommentResponse { private Integer downCount; // 비추천 수 private String myVote; // 현재 사용자의 투표: UP / DOWN / null private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출 + private Boolean blocked; // 신고 누적 블라인드(관리자만 본문 열람) public static CommentResponse from(Comment c) { return CommentResponse.builder() @@ -33,6 +34,7 @@ public class CommentResponse { .authorGooglePicture(c.getAuthorGooglePicture()) .authorProfileImage(c.getAuthorProfileImage()) .content(c.getContent()) + .blocked(Boolean.TRUE.equals(c.getBlocked())) .createdAt(c.getCreatedAt()) .upCount(0) .downCount(0) diff --git a/src/main/java/com/sb/web/board/mapper/CommentMapper.java b/src/main/java/com/sb/web/board/mapper/CommentMapper.java index 4019144..d29e74d 100644 --- a/src/main/java/com/sb/web/board/mapper/CommentMapper.java +++ b/src/main/java/com/sb/web/board/mapper/CommentMapper.java @@ -18,4 +18,7 @@ public interface CommentMapper { int delete(@Param("id") Long id); int deleteByPostId(@Param("postId") Long postId); + + /** 신고 누적 블라인드 설정 */ + int updateBlocked(@Param("id") Long id, @Param("blocked") boolean blocked); } diff --git a/src/main/java/com/sb/web/board/mapper/ReportMapper.java b/src/main/java/com/sb/web/board/mapper/ReportMapper.java new file mode 100644 index 0000000..df62698 --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/ReportMapper.java @@ -0,0 +1,23 @@ +package com.sb.web.board.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 신고(report) 매퍼 — 글/댓글 신고 누적 집계. + */ +@Mapper +public interface ReportMapper { + + /** 신고 추가 (회원당 대상별 1회 — 중복은 무시) */ + int insertIgnore(@Param("targetType") String targetType, + @Param("targetId") Long targetId, + @Param("memberId") Long memberId, + @Param("reason") String reason); + + int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId); + + int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId); + + int deleteByMember(@Param("memberId") Long memberId); +} diff --git a/src/main/java/com/sb/web/board/service/BoardService.java b/src/main/java/com/sb/web/board/service/BoardService.java index 6cf2ef0..cfbf9c8 100644 --- a/src/main/java/com/sb/web/board/service/BoardService.java +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -14,6 +14,7 @@ import com.sb.web.board.dto.Recommender; import com.sb.web.board.dto.VoteResponse; import com.sb.web.board.mapper.CommentMapper; import com.sb.web.board.mapper.PostMapper; +import com.sb.web.board.mapper.ReportMapper; import com.sb.web.board.mapper.TagMapper; import com.sb.web.board.mapper.VoteMapper; import com.sb.web.common.exception.ApiException; @@ -41,6 +42,7 @@ public class BoardService { private final TagMapper tagMapper; private final CommentMapper commentMapper; private final VoteMapper voteMapper; + private final ReportMapper reportMapper; private final PointService pointService; private final RedisTemplate redisTemplate; @@ -48,6 +50,10 @@ public class BoardService { private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24); private static final String UP = "UP"; private static final String DOWN = "DOWN"; + private static final String T_POST = "POST"; + private static final String T_COMMENT = "COMMENT"; + /** 신고 누적 블라인드 임계 */ + private static final int REPORT_BLIND_THRESHOLD = 5; // 인기 글/댓글: 등록 1일 이내 + 추천 50건 이상이면 상단에 최대 3건 노출 private static final int HOT_WITHIN_HOURS = 24; @@ -185,6 +191,7 @@ public class BoardService { // 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조) voteMapper.deleteCommentVotesByPost(id); voteMapper.deletePostVotesByPost(id); + reportMapper.deleteByTarget(T_POST, id); tagMapper.deletePostTagsByPostId(id); commentMapper.deleteByPostId(id); postMapper.delete(id); @@ -217,9 +224,43 @@ public class BoardService { } assertCanModify(comment.getAuthorId(), user); voteMapper.deleteCommentVotesByComment(commentId); + reportMapper.deleteByTarget(T_COMMENT, commentId); commentMapper.delete(commentId); } + /* ===================== 신고 ===================== */ + + /** 게시글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */ + @Transactional + public void reportPost(Long postId, SessionUser user) { + Post post = mustFindPost(postId); + if (post.getAuthorId().equals(user.getId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글은 신고할 수 없습니다."); + } + reportMapper.insertIgnore(T_POST, postId, user.getId(), null); + if (!Boolean.TRUE.equals(post.getBlocked()) + && reportMapper.countByTarget(T_POST, postId) >= REPORT_BLIND_THRESHOLD) { + postMapper.updateBlocked(postId, true, "신고 누적으로 블라인드되었습니다."); + } + } + + /** 댓글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */ + @Transactional + public void reportComment(Long commentId, SessionUser user) { + Comment comment = commentMapper.findById(commentId); + if (comment == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."); + } + if (comment.getAuthorId().equals(user.getId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "본인 댓글은 신고할 수 없습니다."); + } + reportMapper.insertIgnore(T_COMMENT, commentId, user.getId(), null); + if (!Boolean.TRUE.equals(comment.getBlocked()) + && reportMapper.countByTarget(T_COMMENT, commentId) >= REPORT_BLIND_THRESHOLD) { + commentMapper.updateBlocked(commentId, true); + } + } + /* ===================== 추천/비추천 ===================== */ /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ @@ -301,6 +342,13 @@ public class BoardService { } return cr; }).toList()); + // 신고 누적 블라인드 댓글: 관리자가 아니면 본문 숨김 + boolean viewerAdmin = isAdmin(viewer); + if (!viewerAdmin) { + comments.forEach(cr -> { + if (Boolean.TRUE.equals(cr.getBlocked())) cr.setContent(null); + }); + } comments = orderWithHot(comments); PostDetail detail = PostDetail.of(post, tags, comments); diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql index 431dd0f..246aa42 100644 --- a/src/main/resources/db/board.sql +++ b/src/main/resources/db/board.sql @@ -95,6 +95,22 @@ CREATE TABLE IF NOT EXISTS comment ( KEY idx_comment_post (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- 댓글 신고 누적 블라인드 플래그 (멱등) +ALTER TABLE comment ADD COLUMN IF NOT EXISTS blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '신고 누적 블라인드'; + +-- 신고 (글/댓글). 회원당 대상별 1회. 5건 누적 시 블라인드(관리자만 열람). +CREATE TABLE IF NOT EXISTS report ( + id BIGINT NOT NULL AUTO_INCREMENT, + target_type VARCHAR(10) NOT NULL COMMENT 'POST / COMMENT', + target_id BIGINT NOT NULL, + member_id BIGINT NOT NULL COMMENT '신고자', + reason VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_report (target_type, target_id, member_id), + KEY idx_report_target (target_type, target_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환. CREATE TABLE IF NOT EXISTS post_vote ( post_id BIGINT NOT NULL, diff --git a/src/main/resources/mapper/CommentMapper.xml b/src/main/resources/mapper/CommentMapper.xml index 521cab0..c30114a 100644 --- a/src/main/resources/mapper/CommentMapper.xml +++ b/src/main/resources/mapper/CommentMapper.xml @@ -11,11 +11,12 @@ + + + UPDATE comment SET blocked = #{blocked} WHERE id = #{id} + + INSERT INTO comment (post_id, author_id, author_name, content, created_at) diff --git a/src/main/resources/mapper/ReportMapper.xml b/src/main/resources/mapper/ReportMapper.xml new file mode 100644 index 0000000..cbf94b0 --- /dev/null +++ b/src/main/resources/mapper/ReportMapper.xml @@ -0,0 +1,23 @@ + + + + + + INSERT IGNORE INTO report (target_type, target_id, member_id, reason, created_at) + VALUES (#{targetType}, #{targetId}, #{memberId}, #{reason}, NOW()) + + + + + + DELETE FROM report WHERE target_type = #{targetType} AND target_id = #{targetId} + + + + DELETE FROM report WHERE member_id = #{memberId} + + + diff --git a/src/main/resources/mapper/WithdrawMapper.xml b/src/main/resources/mapper/WithdrawMapper.xml index 53ea42c..3029a9e 100644 --- a/src/main/resources/mapper/WithdrawMapper.xml +++ b/src/main/resources/mapper/WithdrawMapper.xml @@ -52,7 +52,10 @@ DELETE FROM board_image WHERE member_id = #{memberId} - + + + DELETE FROM report WHERE member_id = #{memberId} + DELETE FROM point_history WHERE member_id = #{memberId}