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.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.mapper.CommentMapper; import com.sb.web.board.mapper.PostMapper; import com.sb.web.board.mapper.TagMapper; import com.sb.web.common.exception.ApiException; 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.List; 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 RedisTemplate redisTemplate; /** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */ private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24); /* ===================== 게시글 ===================== */ 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); 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); } /** 게시글 열람 제한/해제 (관리자 전용) */ @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()); return assemble(mustFindPost(post.getId())); } @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)); } @Transactional public void delete(Long id, SessionUser user) { Post post = mustFindPost(id); assertCanModify(post.getAuthorId(), user); 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); 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); commentMapper.delete(commentId); } /* ===================== 태그 ===================== */ public List allTags() { return tagMapper.findAllNames(); } /* ===================== 내부 ===================== */ private PostDetail assemble(Post post) { List tags = tagMapper.findNamesByPostId(post.getId()); List comments = commentMapper.findByPostId(post.getId()) .stream().map(CommentResponse::from).toList(); return PostDetail.of(post, tags, comments); } /** 선택된 태그 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(); } }