- BoardService.update: 관리자면 req.notice 로 공지 갱신 (작성/수정 체크박스로 공지 on/off, 상세 토글 버튼 제거 대응) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
9.3 KiB
Java
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();
|
|
}
|
|
}
|