feat: 게시판 추천/비추천 + 작성자 아바타 + 추천자 + 활동 포인트

- 작성자 아바타: post/comment 조회 시 member JOIN(google_picture/profile_image),
  목록·상세·댓글 응답에 노출
- 추천/비추천: post_vote/comment_vote 테이블 + 토글 API
  (POST /board/posts|comments/{id}/vote), 게시글/댓글 응답에 up/down/myVote
- 추천자 목록: GET /board/posts/{id}/recommenders + PostDetail.recommenders
- 포인트: member.points + point_history. 글/댓글 작성 시 10P, 하루 3회 한도(합산).
  PointService, GET /auth/points, MemberResponse.points 노출
- @MapperScan 에 point.mapper 추가, AuthController WebMvc 테스트 MockBean 보강

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 12:27:35 +09:00
co-authored by Claude Opus 4.8
parent 98f5f51112
commit ef9dace1df
27 changed files with 489 additions and 25 deletions
@@ -5,14 +5,19 @@ 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.VoteResponse;
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.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;
@@ -20,7 +25,9 @@ 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;
/**
@@ -33,10 +40,14 @@ public class BoardService {
private final PostMapper postMapper;
private final TagMapper tagMapper;
private final CommentMapper commentMapper;
private final VoteMapper voteMapper;
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";
/* ===================== 게시글 ===================== */
@@ -80,7 +91,7 @@ public class BoardService {
postMapper.increaseViewCount(id);
post.setViewCount(post.getViewCount() + 1);
}
return assemble(post);
return assemble(post, viewer);
}
/** 게시글 열람 제한/해제 (관리자 전용) */
@@ -122,7 +133,8 @@ public class BoardService {
.build();
postMapper.insert(post);
applyTags(post.getId(), req.getTagIds());
return assemble(mustFindPost(post.getId()));
pointService.awardBoardWrite(author.getId()); // 글 작성 보상(일 3회 한도)
return assemble(mustFindPost(post.getId()), author);
}
@Transactional
@@ -144,13 +156,16 @@ public class BoardService {
tagMapper.deletePostTagsByPostId(id);
applyTags(id, req.getTagIds());
return assemble(mustFindPost(id));
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);
tagMapper.deletePostTagsByPostId(id);
commentMapper.deleteByPostId(id);
postMapper.delete(id);
@@ -171,6 +186,7 @@ public class BoardService {
.content(req.getContent())
.build();
commentMapper.insert(comment);
pointService.awardBoardWrite(author.getId()); // 댓글 작성 보상(일 3회 한도, 글과 합산)
return CommentResponse.from(commentMapper.findById(comment.getId()));
}
@@ -181,9 +197,57 @@ public class BoardService {
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
}
assertCanModify(comment.getAuthorId(), user);
voteMapper.deleteCommentVotesByComment(commentId);
commentMapper.delete(commentId);
}
/* ===================== 추천/비추천 ===================== */
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
@Transactional
public VoteResponse votePost(Long postId, String type, SessionUser user) {
mustFindPost(postId);
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, "댓글을 찾을 수 없습니다.");
}
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() {
@@ -192,11 +256,33 @@ public class BoardService {
/* ===================== 내부 ===================== */
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);
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 = 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();
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;
}
/** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */