feat: 가계부·게시판 백엔드 API 구현
- com.sb.web 패키지로 재구성: account / auth / board / user / admin / common - 가계부(account): 내역(필터), 계좌·순자산, 예산, 분류, 정기 거래, 투자 포트폴리오 (종목·매매 이력, 이동평균 평단·실현/평가손익) - MyBatis + MariaDB + Redis 세션, BCrypt - 스키마 자동 초기화(db/*.sql, 멱등), 사용자별 데이터 격리(member_id) - 문서: docs/BACKEND.md, .env.example Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9243a41385
commit
2a2f81dc32
@@ -0,0 +1,215 @@
|
||||
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 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);
|
||||
|
||||
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
|
||||
List<PostSummary> content = postMapper.findPage(offset, s, blankToNull(tag), blankToNull(keyword), st);
|
||||
long total = postMapper.countPage(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";
|
||||
}
|
||||
|
||||
/** 상세 조회 (열람 제한 글은 관리자만 전체 열람, 그 외엔 제한 응답) */
|
||||
@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);
|
||||
}
|
||||
|
||||
/** 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())
|
||||
.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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user