feat: 게시판 신고 — 누적 5건 시 블라인드(관리자만 열람)

- report 테이블(회원당 대상별 1회), comment.blocked 컬럼
- POST /board/posts|comments/{id}/report, 본인 글/댓글 신고 불가
- 글: 5건이면 blocked(기존 열람제한 재사용) / 댓글: blocked → 비관리자 본문 숨김
- 삭제·탈퇴 시 신고 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 18:05:10 +09:00
co-authored by Claude Opus 4.8
parent a47b8405f4
commit ba75613e1c
12 changed files with 149 additions and 5 deletions
@@ -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<String, Object> 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);