From ef9dace1df8a2f5bfa64fd0adbf28889aa4e5939 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 12:27:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C/=EB=B9=84=EC=B6=94=EC=B2=9C=20+=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=95=84=EB=B0=94=ED=83=80=20+=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=EC=9E=90=20+=20=ED=99=9C=EB=8F=99=20=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작성자 아바타: 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 --- src/main/java/com/sb/web/SbBtApplication.java | 2 +- .../web/auth/controller/AuthController.java | 8 ++ .../java/com/sb/web/auth/domain/Member.java | 1 + .../com/sb/web/auth/dto/MemberResponse.java | 2 + .../web/board/controller/BoardController.java | 27 +++++ .../java/com/sb/web/board/domain/Comment.java | 2 + .../java/com/sb/web/board/domain/Post.java | 2 + .../com/sb/web/board/dto/CommentResponse.java | 9 ++ .../sb/web/board/dto/CommentVoteSummary.java | 15 +++ .../java/com/sb/web/board/dto/PostDetail.java | 8 ++ .../com/sb/web/board/dto/PostSummary.java | 4 + .../com/sb/web/board/dto/Recommender.java | 15 +++ .../com/sb/web/board/dto/VoteRequest.java | 14 +++ .../com/sb/web/board/dto/VoteResponse.java | 16 +++ .../com/sb/web/board/mapper/VoteMapper.java | 48 +++++++++ .../sb/web/board/service/BoardService.java | 102 ++++++++++++++++-- .../com/sb/web/common/config/WebConfig.java | 2 +- .../java/com/sb/web/point/PointService.java | 41 +++++++ .../com/sb/web/point/mapper/PointMapper.java | 20 ++++ src/main/resources/db/board.sql | 20 ++++ src/main/resources/db/member.sql | 14 +++ src/main/resources/mapper/CommentMapper.xml | 20 ++-- src/main/resources/mapper/MemberMapper.xml | 5 +- src/main/resources/mapper/PointMapper.xml | 24 +++++ src/main/resources/mapper/PostMapper.xml | 17 +-- src/main/resources/mapper/VoteMapper.xml | 75 +++++++++++++ .../controller/AuthControllerWebMvcTest.java | 1 + 27 files changed, 489 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/sb/web/board/dto/CommentVoteSummary.java create mode 100644 src/main/java/com/sb/web/board/dto/Recommender.java create mode 100644 src/main/java/com/sb/web/board/dto/VoteRequest.java create mode 100644 src/main/java/com/sb/web/board/dto/VoteResponse.java create mode 100644 src/main/java/com/sb/web/board/mapper/VoteMapper.java create mode 100644 src/main/java/com/sb/web/point/PointService.java create mode 100644 src/main/java/com/sb/web/point/mapper/PointMapper.java create mode 100644 src/main/resources/mapper/PointMapper.xml create mode 100644 src/main/resources/mapper/VoteMapper.xml diff --git a/src/main/java/com/sb/web/SbBtApplication.java b/src/main/java/com/sb/web/SbBtApplication.java index b93652d..fece9d5 100644 --- a/src/main/java/com/sb/web/SbBtApplication.java +++ b/src/main/java/com/sb/web/SbBtApplication.java @@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling -@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper"}) +@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper"}) public class SbBtApplication { public static void main(String[] args) { diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index 41f37b6..3a30d40 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -29,6 +29,7 @@ public class AuthController { private final AuthService authService; private final com.sb.web.admin.service.AppSettingService appSettingService; + private final com.sb.web.point.PointService pointService; /** 회원가입 허용 여부 (비보호) — 프론트가 가입 진입 전에 차단 표시용 */ @GetMapping("/signup-enabled") @@ -79,6 +80,13 @@ public class AuthController { return current; } + /** 현재 사용자 활동 포인트 (최신값 — 게시판 작성으로 수시 변동) */ + @GetMapping("/points") + public java.util.Map points( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return java.util.Map.of("points", pointService.getPoints(current.getId())); + } + @PutMapping("/password") public ResponseEntity changePassword( @Valid @RequestBody PasswordChangeRequest req, diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java index 4f5d261..98b79df 100644 --- a/src/main/java/com/sb/web/auth/domain/Member.java +++ b/src/main/java/com/sb/web/auth/domain/Member.java @@ -30,6 +30,7 @@ public class Member implements Serializable { private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜 private String role; // USER / ADMIN private String plan; // FREE / PREMIUM (멤버십) + private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상) private String status; // ACTIVE / SUSPENDED / WITHDRAWN private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/src/main/java/com/sb/web/auth/dto/MemberResponse.java b/src/main/java/com/sb/web/auth/dto/MemberResponse.java index ae5c480..d8f48da 100644 --- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java +++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java @@ -18,6 +18,7 @@ public class MemberResponse { private String provider; private String role; private String plan; // FREE / PREMIUM + private Long points; // 활동 포인트 private String googlePicture; // 구글 아바타 URL private String profileImage; // 사용자 지정 프로필 이미지(data URL) @@ -30,6 +31,7 @@ public class MemberResponse { .provider(m.getProvider()) .role(m.getRole()) .plan(m.getPlan()) + .points(m.getPoints()) .googlePicture(m.getGooglePicture()) .profileImage(m.getProfileImage()) .build(); diff --git a/src/main/java/com/sb/web/board/controller/BoardController.java b/src/main/java/com/sb/web/board/controller/BoardController.java index 00fa962..22d2419 100644 --- a/src/main/java/com/sb/web/board/controller/BoardController.java +++ b/src/main/java/com/sb/web/board/controller/BoardController.java @@ -8,7 +8,10 @@ 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.TagCategoryResponse; +import com.sb.web.board.dto.VoteRequest; +import com.sb.web.board.dto.VoteResponse; import com.sb.web.board.service.BoardService; import com.sb.web.board.service.TagService; import jakarta.validation.Valid; @@ -143,4 +146,28 @@ public class BoardController { boardService.deleteComment(commentId, current); return ResponseEntity.noContent().build(); } + + /** 게시글 추천/비추천 (토글) */ + @PostMapping("/posts/{id}/vote") + public VoteResponse votePost( + @PathVariable Long id, + @Valid @RequestBody VoteRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.votePost(id, req.getType(), current); + } + + /** 게시글 추천(👍)한 사람 목록 */ + @GetMapping("/posts/{id}/recommenders") + public List recommenders(@PathVariable Long id) { + return boardService.recommenders(id); + } + + /** 댓글 추천/비추천 (토글) */ + @PostMapping("/comments/{commentId}/vote") + public VoteResponse voteComment( + @PathVariable Long commentId, + @Valid @RequestBody VoteRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.voteComment(commentId, req.getType(), current); + } } diff --git a/src/main/java/com/sb/web/board/domain/Comment.java b/src/main/java/com/sb/web/board/domain/Comment.java index e9180ab..b2a3398 100644 --- a/src/main/java/com/sb/web/board/domain/Comment.java +++ b/src/main/java/com/sb/web/board/domain/Comment.java @@ -21,6 +21,8 @@ public class Comment implements Serializable { private Long postId; private Long authorId; private String authorName; + private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함) + private String authorProfileImage; // 조회 시 member JOIN (저장 안 함) private String content; private LocalDateTime createdAt; } diff --git a/src/main/java/com/sb/web/board/domain/Post.java b/src/main/java/com/sb/web/board/domain/Post.java index f05bfa5..eeede99 100644 --- a/src/main/java/com/sb/web/board/domain/Post.java +++ b/src/main/java/com/sb/web/board/domain/Post.java @@ -22,6 +22,8 @@ public class Post implements Serializable { private String content; private Long authorId; private String authorName; + private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함) + private String authorProfileImage; // 조회 시 member JOIN (저장 안 함) private Integer viewCount; private Boolean blocked; private String blockReason; diff --git a/src/main/java/com/sb/web/board/dto/CommentResponse.java b/src/main/java/com/sb/web/board/dto/CommentResponse.java index 2e1d644..4ea2d8c 100644 --- a/src/main/java/com/sb/web/board/dto/CommentResponse.java +++ b/src/main/java/com/sb/web/board/dto/CommentResponse.java @@ -16,16 +16,25 @@ public class CommentResponse { private Long id; private Long authorId; private String authorName; + private String authorGooglePicture; + private String authorProfileImage; private String content; private LocalDateTime createdAt; + private Integer upCount; // 추천 수 + private Integer downCount; // 비추천 수 + private String myVote; // 현재 사용자의 투표: UP / DOWN / null public static CommentResponse from(Comment c) { return CommentResponse.builder() .id(c.getId()) .authorId(c.getAuthorId()) .authorName(c.getAuthorName()) + .authorGooglePicture(c.getAuthorGooglePicture()) + .authorProfileImage(c.getAuthorProfileImage()) .content(c.getContent()) .createdAt(c.getCreatedAt()) + .upCount(0) + .downCount(0) .build(); } } diff --git a/src/main/java/com/sb/web/board/dto/CommentVoteSummary.java b/src/main/java/com/sb/web/board/dto/CommentVoteSummary.java new file mode 100644 index 0000000..a5f80b3 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/CommentVoteSummary.java @@ -0,0 +1,15 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +/** + * 한 게시글의 댓글별 추천/비추천 집계 + 현재 사용자 투표 (N+1 방지용 일괄 조회 결과). + */ +@Data +public class CommentVoteSummary { + + private Long commentId; + private int upCount; + private int downCount; + private String myVote; // UP / DOWN / null +} diff --git a/src/main/java/com/sb/web/board/dto/PostDetail.java b/src/main/java/com/sb/web/board/dto/PostDetail.java index 21377de..b64a306 100644 --- a/src/main/java/com/sb/web/board/dto/PostDetail.java +++ b/src/main/java/com/sb/web/board/dto/PostDetail.java @@ -19,6 +19,8 @@ public class PostDetail { private String content; private Long authorId; private String authorName; + private String authorGooglePicture; + private String authorProfileImage; private Integer viewCount; private Boolean blocked; private String blockReason; @@ -28,6 +30,10 @@ public class PostDetail { private LocalDateTime updatedAt; private List tags; private List comments; + private Integer upCount; // 추천 수 + private Integer downCount; // 비추천 수 + private String myVote; // 현재 사용자의 투표: UP / DOWN / null + private List recommenders; // 추천(👍)한 사람 목록 (아바타 표기용) public static PostDetail of(Post p, List tags, List comments) { return PostDetail.builder() @@ -36,6 +42,8 @@ public class PostDetail { .content(p.getContent()) .authorId(p.getAuthorId()) .authorName(p.getAuthorName()) + .authorGooglePicture(p.getAuthorGooglePicture()) + .authorProfileImage(p.getAuthorProfileImage()) .viewCount(p.getViewCount()) .blocked(Boolean.TRUE.equals(p.getBlocked())) .blockReason(p.getBlockReason()) diff --git a/src/main/java/com/sb/web/board/dto/PostSummary.java b/src/main/java/com/sb/web/board/dto/PostSummary.java index 28a9069..3e5cd5f 100644 --- a/src/main/java/com/sb/web/board/dto/PostSummary.java +++ b/src/main/java/com/sb/web/board/dto/PostSummary.java @@ -13,9 +13,13 @@ public class PostSummary { private Long id; private String title; + private Long authorId; private String authorName; + private String authorGooglePicture; + private String authorProfileImage; private Integer viewCount; private Integer commentCount; + private Integer upCount; // 추천 수 private Boolean blocked; private Boolean notice; private LocalDateTime createdAt; diff --git a/src/main/java/com/sb/web/board/dto/Recommender.java b/src/main/java/com/sb/web/board/dto/Recommender.java new file mode 100644 index 0000000..b0635db --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/Recommender.java @@ -0,0 +1,15 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +/** + * 추천(👍)한 사람 (본문 하단 아바타·이름 표기용). + */ +@Data +public class Recommender { + + private Long memberId; + private String name; + private String googlePicture; + private String profileImage; +} diff --git a/src/main/java/com/sb/web/board/dto/VoteRequest.java b/src/main/java/com/sb/web/board/dto/VoteRequest.java new file mode 100644 index 0000000..dccf8bb --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/VoteRequest.java @@ -0,0 +1,14 @@ +package com.sb.web.board.dto; + +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 추천/비추천 요청. 같은 표를 다시 보내면 취소(토글), 반대면 전환. + */ +@Data +public class VoteRequest { + + @Pattern(regexp = "UP|DOWN", message = "투표는 UP 또는 DOWN 이어야 합니다.") + private String type; +} diff --git a/src/main/java/com/sb/web/board/dto/VoteResponse.java b/src/main/java/com/sb/web/board/dto/VoteResponse.java new file mode 100644 index 0000000..830518e --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/VoteResponse.java @@ -0,0 +1,16 @@ +package com.sb.web.board.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 추천/비추천 후 최신 집계 + 현재 사용자 상태. + */ +@Data +@AllArgsConstructor +public class VoteResponse { + + private int upCount; + private int downCount; + private String myVote; // UP / DOWN / null +} diff --git a/src/main/java/com/sb/web/board/mapper/VoteMapper.java b/src/main/java/com/sb/web/board/mapper/VoteMapper.java new file mode 100644 index 0000000..00720fe --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/VoteMapper.java @@ -0,0 +1,48 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.dto.CommentVoteSummary; +import com.sb.web.board.dto.Recommender; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 게시글/댓글 추천·비추천(post_vote / comment_vote) 매퍼. + */ +@Mapper +public interface VoteMapper { + + // ===== 게시글 ===== + /** 현재 사용자의 게시글 투표(UP/DOWN) 또는 null */ + String findPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId); + + /** 투표 등록/변경 (INSERT … ON DUPLICATE KEY UPDATE) */ + int upsertPostVote(@Param("postId") Long postId, @Param("memberId") Long memberId, @Param("type") String type); + + int deletePostVote(@Param("postId") Long postId, @Param("memberId") Long memberId); + + int countPostVotes(@Param("postId") Long postId, @Param("type") String type); + + /** 추천(👍)한 사람 목록 (최신순) */ + List findPostRecommenders(@Param("postId") Long postId); + + int deletePostVotesByPost(@Param("postId") Long postId); + + // ===== 댓글 ===== + String findCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId); + + int upsertCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId, @Param("type") String type); + + int deleteCommentVote(@Param("commentId") Long commentId, @Param("memberId") Long memberId); + + int countCommentVotes(@Param("commentId") Long commentId, @Param("type") String type); + + /** 한 게시글의 댓글별 집계 + 현재 사용자 투표 (일괄) */ + List findCommentVoteSummary(@Param("postId") Long postId, @Param("memberId") Long memberId); + + int deleteCommentVotesByComment(@Param("commentId") Long commentId); + + /** 게시글 삭제 시 그 글의 모든 댓글 투표 정리 */ + int deleteCommentVotesByPost(@Param("postId") Long postId); +} diff --git a/src/main/java/com/sb/web/board/service/BoardService.java b/src/main/java/com/sb/web/board/service/BoardService.java index 7855499..bb0c393 100644 --- a/src/main/java/com/sb/web/board/service/BoardService.java +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -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 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 recommenders(Long postId) { + mustFindPost(postId); + return voteMapper.findPostRecommenders(postId); + } + /* ===================== 태그 ===================== */ public List allTags() { @@ -192,11 +256,33 @@ public class BoardService { /* ===================== 내부 ===================== */ - 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); + private PostDetail assemble(Post post, SessionUser viewer) { + Long postId = post.getId(); + Long viewerId = viewer == null ? null : viewer.getId(); + List tags = tagMapper.findNamesByPostId(postId); + + // 댓글별 추천/비추천 집계를 한 번에 조회(N+1 방지) + Map voteByComment = new HashMap<>(); + for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) { + voteByComment.put(s.getCommentId(), s); + } + List 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에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */ diff --git a/src/main/java/com/sb/web/common/config/WebConfig.java b/src/main/java/com/sb/web/common/config/WebConfig.java index 2d13145..b58a1b6 100644 --- a/src/main/java/com/sb/web/common/config/WebConfig.java +++ b/src/main/java/com/sb/web/common/config/WebConfig.java @@ -25,7 +25,7 @@ public class WebConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) registry.addInterceptor(authInterceptor) - .addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password", + .addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/logout", "/api/auth/password", "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", "/api/board/**", "/api/admin/**", "/api/account/**"); // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사) diff --git a/src/main/java/com/sb/web/point/PointService.java b/src/main/java/com/sb/web/point/PointService.java new file mode 100644 index 0000000..02f5829 --- /dev/null +++ b/src/main/java/com/sb/web/point/PointService.java @@ -0,0 +1,41 @@ +package com.sb.web.point; + +import com.sb.web.point.mapper.PointMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 활동 포인트 적립/조회. + * 게시판 글/댓글 작성 시 10점, 하루 3회까지만 지급(글+댓글 합산). + */ +@Service +@RequiredArgsConstructor +public class PointService { + + private final PointMapper pointMapper; + + public static final int BOARD_WRITE_POINT = 10; + public static final int BOARD_WRITE_DAILY_LIMIT = 3; + public static final String REASON_BOARD_WRITE = "BOARD_WRITE"; + + /** + * 게시판 글/댓글 작성 보상. 오늘 지급 횟수가 한도 미만일 때만 지급한다. + * @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0 + */ + @Transactional + public int awardBoardWrite(Long memberId) { + int today = pointMapper.countTodayGrants(memberId, REASON_BOARD_WRITE); + if (today >= BOARD_WRITE_DAILY_LIMIT) { + return 0; + } + pointMapper.insertHistory(memberId, BOARD_WRITE_POINT, REASON_BOARD_WRITE); + pointMapper.addPoints(memberId, BOARD_WRITE_POINT); + return BOARD_WRITE_POINT; + } + + public long getPoints(Long memberId) { + Long p = pointMapper.getPoints(memberId); + return p == null ? 0L : p; + } +} diff --git a/src/main/java/com/sb/web/point/mapper/PointMapper.java b/src/main/java/com/sb/web/point/mapper/PointMapper.java new file mode 100644 index 0000000..2a83005 --- /dev/null +++ b/src/main/java/com/sb/web/point/mapper/PointMapper.java @@ -0,0 +1,20 @@ +package com.sb.web.point.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 포인트(member.points) 및 적립 이력(point_history) 매퍼. + */ +@Mapper +public interface PointMapper { + + /** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */ + int countTodayGrants(@Param("memberId") Long memberId, @Param("reason") String reason); + + int insertHistory(@Param("memberId") Long memberId, @Param("amount") int amount, @Param("reason") String reason); + + int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount); + + Long getPoints(@Param("memberId") Long memberId); +} diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql index 00fe8b9..a2053b5 100644 --- a/src/main/resources/db/board.sql +++ b/src/main/resources/db/board.sql @@ -95,3 +95,23 @@ CREATE TABLE IF NOT EXISTS comment ( PRIMARY KEY (id), KEY idx_comment_post (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환. +CREATE TABLE IF NOT EXISTS post_vote ( + post_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (post_id, member_id), + KEY idx_post_vote_post (post_id, vote_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 댓글 추천/비추천 (회원당 1표) +CREATE TABLE IF NOT EXISTS comment_vote ( + comment_id BIGINT NOT NULL, + member_id BIGINT NOT NULL, + vote_type VARCHAR(4) NOT NULL COMMENT 'UP / DOWN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (comment_id, member_id), + KEY idx_comment_vote_comment (comment_id, vote_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index 167b518..a423668 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -35,6 +35,20 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'F ALTER TABLE member ADD COLUMN IF NOT EXISTS google_picture VARCHAR(500) NULL COMMENT '구글 아바타 URL (로그인 시 동기화)' AFTER google_id; ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMENT '사용자 지정 프로필 이미지(data URL)' AFTER google_picture; +-- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등). +ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan; + +-- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용). +CREATE TABLE IF NOT EXISTS point_history ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + amount INT NOT NULL COMMENT '증감 포인트(+/-)', + reason VARCHAR(30) NOT NULL COMMENT 'BOARD_WRITE 등', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_point_history_member_date (member_id, reason, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ============================================================ -- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경. -- ============================================================ diff --git a/src/main/resources/mapper/CommentMapper.xml b/src/main/resources/mapper/CommentMapper.xml index db8c319..521cab0 100644 --- a/src/main/resources/mapper/CommentMapper.xml +++ b/src/main/resources/mapper/CommentMapper.xml @@ -8,21 +8,27 @@ + + + - id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, status, created_at, updated_at + id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, points, status, created_at, updated_at - SELECT id, login_id, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at + SELECT id, login_id, name, email, provider, provider_id, google_id, role, plan, points, status, created_at, updated_at FROM member ORDER BY id diff --git a/src/main/resources/mapper/PointMapper.xml b/src/main/resources/mapper/PointMapper.xml new file mode 100644 index 0000000..c81497d --- /dev/null +++ b/src/main/resources/mapper/PointMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + INSERT INTO point_history (member_id, amount, reason, created_at) + VALUES (#{memberId}, #{amount}, #{reason}, NOW()) + + + + UPDATE member SET points = points + #{amount}, updated_at = NOW() WHERE id = #{memberId} + + + + + diff --git a/src/main/resources/mapper/PostMapper.xml b/src/main/resources/mapper/PostMapper.xml index edea528..f9b1602 100644 --- a/src/main/resources/mapper/PostMapper.xml +++ b/src/main/resources/mapper/PostMapper.xml @@ -35,9 +35,12 @@ + + + + + + + + INSERT INTO post_vote (post_id, member_id, vote_type, created_at) + VALUES (#{postId}, #{memberId}, #{type}, NOW()) + ON DUPLICATE KEY UPDATE vote_type = #{type}, created_at = NOW() + + + + DELETE FROM post_vote WHERE post_id = #{postId} AND member_id = #{memberId} + + + + + + + + DELETE FROM post_vote WHERE post_id = #{postId} + + + + + + + INSERT INTO comment_vote (comment_id, member_id, vote_type, created_at) + VALUES (#{commentId}, #{memberId}, #{type}, NOW()) + ON DUPLICATE KEY UPDATE vote_type = #{type}, created_at = NOW() + + + + DELETE FROM comment_vote WHERE comment_id = #{commentId} AND member_id = #{memberId} + + + + + + + + DELETE FROM comment_vote WHERE comment_id = #{commentId} + + + + DELETE FROM comment_vote WHERE comment_id IN (SELECT id FROM comment WHERE post_id = #{postId}) + + + diff --git a/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java index 44993e9..b4b681a 100644 --- a/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java +++ b/src/test/java/com/sb/web/auth/controller/AuthControllerWebMvcTest.java @@ -38,6 +38,7 @@ class AuthControllerWebMvcTest { @Autowired MockMvc mvc; @MockitoBean AuthService authService; @MockitoBean AppSettingService appSettingService; + @MockitoBean com.sb.web.point.PointService pointService; /** * @MapperScan 매퍼 빈들이 SqlSessionTemplate 을 요구한다(웹 슬라이스엔 DataSource 없음).