- GET /api/board/reports — 글/댓글 신고 대상별 집계(UNION, 신고 많은 순) - POST /api/board/reports/dismiss — 블라인드 없이 신고 기록만 초기화 - 관리자 신고 관리 화면 백엔드 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
476 lines
20 KiB
Java
476 lines
20 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.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<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);
|
|
}
|
|
|
|
/** 내 글 모아보기 */
|
|
public PageResponse<PostSummary> myPosts(Long memberId, int page, int size) {
|
|
int p = Math.max(page, 1);
|
|
int s = Math.min(Math.max(size, 1), 100);
|
|
List<PostSummary> content = postMapper.findMyPage(memberId, (p - 1) * s, s);
|
|
return PageResponse.of(content, p, s, postMapper.countMyPosts(memberId));
|
|
}
|
|
|
|
/** 내 댓글 모아보기 */
|
|
public PageResponse<com.sb.web.board.dto.MyCommentResponse> 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()); // 글 작성 보상(일 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(readOnly = true)
|
|
public List<ReportedItem> 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);
|
|
}
|
|
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();
|
|
}
|
|
}
|