Files
sb-backend/src/main/java/com/sb/web/board/service/BoardService.java
T
ByungCheolandClaude Opus 4.8 bf6fb97ed8 feat: 게시글 수정 시에도 공지 체크박스 반영 (관리자)
- BoardService.update: 관리자면 req.notice 로 공지 갱신
  (작성/수정 체크박스로 공지 on/off, 상세 토글 버튼 제거 대응)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:59:04 +09:00

245 lines
9.3 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.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<String, Object> redisTemplate;
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
/* ===================== 게시글 ===================== */
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);
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<String> allTags() {
return tagMapper.findAllNames();
}
/* ===================== 내부 ===================== */
private PostDetail assemble(Post post) {
List<String> tags = tagMapper.findNamesByPostId(post.getId());
List<CommentResponse> comments = commentMapper.findByPostId(post.getId())
.stream().map(CommentResponse::from).toList();
return PostDetail.of(post, tags, comments);
}
/** 선택된 태그 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();
}
}