Files
sb-backend/src/main/java/com/sb/web/board/service/BoardService.java
T
ByungCheolandClaude Opus 4.8 ba75613e1c 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>
2026-06-28 18:05:10 +09:00

426 lines
18 KiB
Java

package com.sb.web.board.service;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.board.domain.Comment;
import com.sb.web.board.domain.Post;
import com.sb.web.board.dto.CommentRequest;
import com.sb.web.board.dto.CommentResponse;
import com.sb.web.board.dto.CommentVoteSummary;
import com.sb.web.board.dto.PageResponse;
import com.sb.web.board.dto.PostDetail;
import com.sb.web.board.dto.PostRequest;
import com.sb.web.board.dto.PostSummary;
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;
import com.sb.web.point.PointService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 자유게시판 서비스. 글 CRUD, 태그(find-or-create), 조회수, 댓글, 권한 처리.
*/
@Service
@RequiredArgsConstructor
public class BoardService {
private final PostMapper postMapper;
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;
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
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;
private static final int HOT_MIN_UP = 50;
private static final int HOT_MAX = 3;
/* ===================== 게시글 ===================== */
public PageResponse<PostSummary> list(int page, int size, String category, String tag, String keyword, String searchType) {
int p = Math.max(page, 1);
int s = Math.min(Math.max(size, 1), 100);
int offset = (p - 1) * s;
String st = normalizeSearchType(searchType);
String cat = normalizeCategory(category);
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st);
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st);
// 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거)
if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) {
List<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX);
if (!hot.isEmpty()) {
java.util.Set<Long> hotIds = new java.util.HashSet<>();
hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); });
List<PostSummary> merged = new java.util.ArrayList<>(hot);
for (PostSummary ps : content) {
if (!hotIds.contains(ps.getId())) merged.add(ps);
}
content = merged;
}
}
return PageResponse.of(content, p, s, total);
}
private String normalizeSearchType(String t) {
return ("content".equals(t) || "comment".equals(t)) ? t : "title";
}
/** 목록 필터용: 빈값이면 null(전체), 알 수 없는 값은 community 로 보정 */
private String normalizeCategory(String c) {
if (c == null || c.isBlank()) return null;
return ("saving".equals(c) || "tips".equals(c) || "community".equals(c)) ? c : "community";
}
/** 저장용: 빈값/알 수 없는 값은 community 로 기본 설정 */
private String toCategory(String c) {
if (c == null || c.isBlank()) return "community";
return ("saving".equals(c) || "tips".equals(c)) ? c : "community";
}
/** 상세 조회 (열람 제한 글은 관리자만 전체 열람, 그 외엔 제한 응답) */
@Transactional
public PostDetail get(Long id, SessionUser viewer) {
Post post = mustFindPost(id);
if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(viewer)) {
return PostDetail.restricted(post); // 본문·댓글 숨김, 조회수 증가 없음
}
if (shouldCountView(id, viewer)) {
postMapper.increaseViewCount(id);
post.setViewCount(post.getViewCount() + 1);
}
return assemble(post, viewer);
}
/** 게시글 열람 제한/해제 (관리자 전용) */
@Transactional
public void setBlocked(Long id, boolean blocked, String reason, SessionUser user) {
assertAdmin(user);
mustFindPost(id);
postMapper.updateBlocked(id, blocked, blocked ? reason : null);
}
/** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */
@Transactional
public void setNotice(Long id, boolean notice, SessionUser user) {
assertAdmin(user);
mustFindPost(id);
postMapper.updateNotice(id, notice);
}
/** Redis 로 (사용자, 게시글) 단위 첫 열람 여부 판단 → 중복 클릭 시 조회수 증가 방지 */
private boolean shouldCountView(Long postId, SessionUser viewer) {
if (viewer == null) {
return false;
}
String key = "board:view:" + viewer.getId() + ":" + postId;
Boolean firstView = redisTemplate.opsForValue().setIfAbsent(key, "1", VIEW_DEDUP_TTL);
return Boolean.TRUE.equals(firstView);
}
@Transactional
public PostDetail create(PostRequest req, SessionUser author) {
Post post = Post.builder()
.title(req.getTitle())
.content(req.getContent())
.authorId(author.getId())
.authorName(author.getName())
// 공지는 관리자만 설정 가능
.notice(isAdmin(author) && Boolean.TRUE.equals(req.getNotice()))
.category(toCategory(req.getCategory()))
.build();
postMapper.insert(post);
applyTags(post.getId(), req.getTagIds());
pointService.awardBoardWrite(author.getId()); // 글 작성 보상(일 3회 한도)
return assemble(mustFindPost(post.getId()), author);
}
@Transactional
public PostDetail update(Long id, PostRequest req, SessionUser user) {
Post post = mustFindPost(id);
if (Boolean.TRUE.equals(post.getBlocked()) && !isAdmin(user)) {
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글은 수정할 수 없습니다.");
}
assertCanModify(post.getAuthorId(), user);
post.setTitle(req.getTitle());
post.setContent(req.getContent());
postMapper.update(post);
// 공지는 관리자만 변경 (수정 화면 체크박스 반영)
if (isAdmin(user)) {
postMapper.updateNotice(id, Boolean.TRUE.equals(req.getNotice()));
}
tagMapper.deletePostTagsByPostId(id);
applyTags(id, req.getTagIds());
return assemble(mustFindPost(id), user);
}
@Transactional
public void delete(Long id, SessionUser user) {
Post post = mustFindPost(id);
assertCanModify(post.getAuthorId(), user);
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
voteMapper.deleteCommentVotesByPost(id);
voteMapper.deletePostVotesByPost(id);
reportMapper.deleteByTarget(T_POST, id);
tagMapper.deletePostTagsByPostId(id);
commentMapper.deleteByPostId(id);
postMapper.delete(id);
}
/* ===================== 댓글 ===================== */
@Transactional
public CommentResponse addComment(Long postId, CommentRequest req, SessionUser author) {
Post post = mustFindPost(postId);
if (Boolean.TRUE.equals(post.getBlocked())) {
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다.");
}
Comment comment = Comment.builder()
.postId(postId)
.authorId(author.getId())
.authorName(author.getName())
.content(req.getContent())
.build();
commentMapper.insert(comment);
pointService.awardBoardWrite(author.getId()); // 댓글 작성 보상(일 3회 한도, 글과 합산)
return CommentResponse.from(commentMapper.findById(comment.getId()));
}
@Transactional
public void deleteComment(Long commentId, SessionUser user) {
Comment comment = commentMapper.findById(commentId);
if (comment == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
}
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);
}
}
/* ===================== 추천/비추천 ===================== */
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
@Transactional
public VoteResponse votePost(Long postId, String type, SessionUser user) {
Post post = mustFindPost(postId);
if (post.getAuthorId().equals(user.getId())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글에는 추천/비추천할 수 없습니다.");
}
Long memberId = user.getId();
String existing = voteMapper.findPostVote(postId, memberId);
boolean cancel = type.equals(existing);
if (cancel) {
voteMapper.deletePostVote(postId, memberId);
} else {
voteMapper.upsertPostVote(postId, memberId, type);
}
return new VoteResponse(
voteMapper.countPostVotes(postId, UP),
voteMapper.countPostVotes(postId, DOWN),
cancel ? null : type);
}
/** 댓글 추천/비추천 토글 */
@Transactional
public VoteResponse voteComment(Long commentId, String type, 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, "본인 댓글에는 추천/비추천할 수 없습니다.");
}
Long memberId = user.getId();
String existing = voteMapper.findCommentVote(commentId, memberId);
boolean cancel = type.equals(existing);
if (cancel) {
voteMapper.deleteCommentVote(commentId, memberId);
} else {
voteMapper.upsertCommentVote(commentId, memberId, type);
}
return new VoteResponse(
voteMapper.countCommentVotes(commentId, UP),
voteMapper.countCommentVotes(commentId, DOWN),
cancel ? null : type);
}
/** 추천(👍)한 사람 목록 (본문 하단 표기/모달용) */
public List<Recommender> recommenders(Long postId) {
mustFindPost(postId);
return voteMapper.findPostRecommenders(postId);
}
/* ===================== 태그 ===================== */
public List<String> allTags() {
return tagMapper.findAllNames();
}
/* ===================== 내부 ===================== */
private PostDetail assemble(Post post, SessionUser viewer) {
Long postId = post.getId();
Long viewerId = viewer == null ? null : viewer.getId();
List<String> tags = tagMapper.findNamesByPostId(postId);
// 댓글별 추천/비추천 집계를 한 번에 조회(N+1 방지)
Map<Long, CommentVoteSummary> voteByComment = new HashMap<>();
for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) {
voteByComment.put(s.getCommentId(), s);
}
List<CommentResponse> comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId).stream().map(c -> {
CommentResponse cr = CommentResponse.from(c);
CommentVoteSummary s = voteByComment.get(c.getId());
if (s != null) {
cr.setUpCount(s.getUpCount());
cr.setDownCount(s.getDownCount());
cr.setMyVote(s.getMyVote());
}
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);
detail.setUpCount(voteMapper.countPostVotes(postId, UP));
detail.setDownCount(voteMapper.countPostVotes(postId, DOWN));
detail.setMyVote(viewerId == null ? null : voteMapper.findPostVote(postId, viewerId));
detail.setRecommenders(voteMapper.findPostRecommenders(postId));
return detail;
}
/** 인기 댓글(추천 50+ & 1일 이내) 최대 3건을 추천 많은 순으로 상단에 올리고 hot 표시 */
private List<CommentResponse> orderWithHot(List<CommentResponse> comments) {
java.time.LocalDateTime cutoff = java.time.LocalDateTime.now().minusHours(HOT_WITHIN_HOURS);
List<CommentResponse> hot = comments.stream()
.filter(c -> c.getUpCount() != null && c.getUpCount() >= HOT_MIN_UP
&& c.getCreatedAt() != null && c.getCreatedAt().isAfter(cutoff))
.sorted((a, b) -> Integer.compare(b.getUpCount(), a.getUpCount()))
.limit(HOT_MAX)
.toList();
if (hot.isEmpty()) {
return comments;
}
java.util.Set<Long> hotIds = new java.util.HashSet<>();
hot.forEach(c -> { c.setHot(true); hotIds.add(c.getId()); });
List<CommentResponse> ordered = new java.util.ArrayList<>(hot);
for (CommentResponse c : comments) {
if (!hotIds.contains(c.getId())) ordered.add(c);
}
return ordered;
}
/** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */
private void applyTags(Long postId, List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) {
return;
}
List<Long> distinct = tagIds.stream().filter(Objects::nonNull).distinct().toList();
if (distinct.isEmpty()) {
return;
}
for (Long tagId : tagMapper.findExistingIds(distinct)) {
tagMapper.insertPostTag(postId, tagId);
}
}
private Post mustFindPost(Long id) {
Post post = postMapper.findById(id);
if (post == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.");
}
return post;
}
/** 작성자 본인 또는 관리자만 수정/삭제 가능 */
private void assertCanModify(Long authorId, SessionUser user) {
if (!authorId.equals(user.getId()) && !isAdmin(user)) {
throw new ApiException(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
}
private boolean isAdmin(SessionUser user) {
return user != null && "ADMIN".equals(user.getRole());
}
private void assertAdmin(SessionUser user) {
if (!isAdmin(user)) {
throw new ApiException(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다.");
}
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}