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.ReportedItem; 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 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 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 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 hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX); if (!hot.isEmpty()) { java.util.Set hotIds = new java.util.HashSet<>(); hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); }); List 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); } /** 내 글 모아보기 */ public PageResponse myPosts(Long memberId, int page, int size) { int p = Math.max(page, 1); int s = Math.min(Math.max(size, 1), 100); List content = postMapper.findMyPage(memberId, (p - 1) * s, s); return PageResponse.of(content, p, s, postMapper.countMyPosts(memberId)); } /** 내 댓글 모아보기 */ public PageResponse myComments(Long memberId, int page, int size) { int p = Math.max(page, 1); int s = Math.min(Math.max(size, 1), 100); var content = commentMapper.findMyPage(memberId, (p - 1) * s, s); return PageResponse.of(content, p, s, commentMapper.countMyComments(memberId)); } 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); // 해제 시 신고 기록 초기화 → 다음 신고 1건에 곧바로 재블라인드되지 않게 if (!blocked) { reportMapper.deleteByTarget(T_POST, id); } } /** 댓글 블라인드 해제 (관리자 전용) — 신고 기록 초기화 */ @Transactional public void unblockComment(Long commentId, SessionUser user) { assertAdmin(user); Comment comment = commentMapper.findById(commentId); if (comment == null) { throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."); } commentMapper.updateBlocked(commentId, false); reportMapper.deleteByTarget(T_COMMENT, commentId); } /** 게시글 공지 설정/해제 (관리자 전용) — 목록 최상단 고정 */ @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(), req.getContent()); 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(), req.getContent()); 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(readOnly = true) public List listReportedItems(SessionUser user) { assertAdmin(user); return reportMapper.listReported(); } /** 신고 무시 (관리자 전용) — 블라인드 없이 신고 기록만 초기화 */ @Transactional public void dismissReports(String targetType, Long targetId, SessionUser user) { assertAdmin(user); if (!T_POST.equals(targetType) && !T_COMMENT.equals(targetType)) { throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 신고 대상 유형입니다."); } reportMapper.deleteByTarget(targetType, targetId); } /* ===================== 추천/비추천 ===================== */ /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ @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); } pointService.applyVotePoints(post.getAuthorId(), memberId, existing, 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); } pointService.applyVotePoints(comment.getAuthorId(), memberId, existing, type); return new VoteResponse( voteMapper.countCommentVotes(commentId, UP), voteMapper.countCommentVotes(commentId, DOWN), cancel ? null : type); } /** 추천(👍)한 사람 목록 (본문 하단 표기/모달용) */ public List recommenders(Long postId) { mustFindPost(postId); return voteMapper.findPostRecommenders(postId); } /* ===================== 태그 ===================== */ public List allTags() { return tagMapper.findAllNames(); } /* ===================== 내부 ===================== */ private PostDetail assemble(Post post, SessionUser viewer) { Long postId = post.getId(); Long viewerId = viewer == null ? null : viewer.getId(); List tags = tagMapper.findNamesByPostId(postId); // 댓글별 추천/비추천 집계를 한 번에 조회(N+1 방지) Map voteByComment = new HashMap<>(); for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) { voteByComment.put(s.getCommentId(), s); } List 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 orderWithHot(List comments) { java.time.LocalDateTime cutoff = java.time.LocalDateTime.now().minusHours(HOT_WITHIN_HOURS); List 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 hotIds = new java.util.HashSet<>(); hot.forEach(c -> { c.setHot(true); hotIds.add(c.getId()); }); List 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 tagIds) { if (tagIds == null || tagIds.isEmpty()) { return; } List 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(); } }