From b4344f527060ff18e315276cd51c23a2a3ddabf3 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 13:21:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=EA=B8=80/=EB=B8=94=EB=9D=BC=EC=9D=B8=EB=93=9C/?= =?UTF-8?q?=EC=9E=90=EA=B8=B0=EA=B8=80=20=EC=B6=94=EC=B2=9C=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20+=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/auth/profile: 아바타·포인트 포함 전체 프로필(재로그인 없이 최신화) - 자기 글/댓글 추천·비추천 차단(400) - 인기 글: 등록 1일 이내 + 추천 50건 이상이면 목록 상단 최대 3건 노출(hot) - 인기 댓글: 동일 조건으로 댓글 목록 상단 정렬(hot) - PostSummary 에 downCount 추가(비추천 20+ 블라인드 판정용) Co-Authored-By: Claude Opus 4.8 --- .../web/auth/controller/AuthController.java | 6 +++ .../com/sb/web/auth/service/AuthService.java | 9 ++++ .../com/sb/web/board/dto/CommentResponse.java | 1 + .../com/sb/web/board/dto/PostSummary.java | 4 +- .../com/sb/web/board/mapper/PostMapper.java | 6 +++ .../sb/web/board/service/BoardService.java | 53 +++++++++++++++++-- src/main/resources/mapper/PostMapper.xml | 21 +++++++- 7 files changed, 95 insertions(+), 5 deletions(-) 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 3a30d40..bbecf68 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -104,6 +104,12 @@ public class AuthController { return ResponseEntity.noContent().build(); } + /** 현재 회원 전체 프로필 (아바타·포인트 포함) — 재로그인 없이 최신값 동기화 */ + @GetMapping("/profile") + public MemberResponse profile(@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return authService.getProfile(current.getId()); + } + /** 가입정보(이름/이메일) 변경 */ @PutMapping("/profile") public MemberResponse updateProfile( diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 49ac6f0..9a614a4 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -319,6 +319,15 @@ public class AuthService { } } + /** 현재 회원 전체 프로필(아바타·포인트 포함) — 재로그인 없이 최신값 동기화용 */ + public MemberResponse getProfile(Long memberId) { + Member member = memberMapper.findById(memberId); + if (member == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); + } + return MemberResponse.from(member); + } + /** * 가입정보(이름/이메일) 변경. 비밀번호 인증을 통과한 화면에서 호출된다. * 변경 후 세션(Redis + DB 백업)의 표시 이름도 동기화한다. 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 4ea2d8c..7fe3fdc 100644 --- a/src/main/java/com/sb/web/board/dto/CommentResponse.java +++ b/src/main/java/com/sb/web/board/dto/CommentResponse.java @@ -23,6 +23,7 @@ public class CommentResponse { private Integer upCount; // 추천 수 private Integer downCount; // 비추천 수 private String myVote; // 현재 사용자의 투표: UP / DOWN / null + private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출 public static CommentResponse from(Comment c) { return CommentResponse.builder() 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 3e5cd5f..74b8c6c 100644 --- a/src/main/java/com/sb/web/board/dto/PostSummary.java +++ b/src/main/java/com/sb/web/board/dto/PostSummary.java @@ -19,7 +19,9 @@ public class PostSummary { private String authorProfileImage; private Integer viewCount; private Integer commentCount; - private Integer upCount; // 추천 수 + private Integer upCount; // 추천 수 + private Integer downCount; // 비추천 수 + private Boolean hot; // 인기 글(추천 50+ & 1일 이내) 상단 노출 private Boolean blocked; private Boolean notice; private LocalDateTime createdAt; diff --git a/src/main/java/com/sb/web/board/mapper/PostMapper.java b/src/main/java/com/sb/web/board/mapper/PostMapper.java index f622ee2..5c41f47 100644 --- a/src/main/java/com/sb/web/board/mapper/PostMapper.java +++ b/src/main/java/com/sb/web/board/mapper/PostMapper.java @@ -22,6 +22,12 @@ public interface PostMapper { @Param("keyword") String keyword, @Param("searchType") String searchType); + /** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */ + List findHotPosts(@Param("category") String category, + @Param("hours") int hours, + @Param("minUp") int minUp, + @Param("limit") int limit); + Post findById(@Param("id") Long id); int insert(Post post); 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 bb0c393..6cf2ef0 100644 --- a/src/main/java/com/sb/web/board/service/BoardService.java +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -49,6 +49,11 @@ public class BoardService { private static final String UP = "UP"; private static final String DOWN = "DOWN"; + // 인기 글/댓글: 등록 1일 이내 + 추천 50건 이상이면 상단에 최대 3건 노출 + private static final int HOT_WITHIN_HOURS = 24; + private static final int HOT_MIN_UP = 50; + private static final int HOT_MAX = 3; + /* ===================== 게시글 ===================== */ public PageResponse list(int page, int size, String category, String tag, String keyword, String searchType) { @@ -61,6 +66,20 @@ public class BoardService { // 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다. List content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st); long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st); + + // 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거) + if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) { + List hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX); + if (!hot.isEmpty()) { + java.util.Set hotIds = new java.util.HashSet<>(); + hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); }); + List merged = new java.util.ArrayList<>(hot); + for (PostSummary ps : content) { + if (!hotIds.contains(ps.getId())) merged.add(ps); + } + content = merged; + } + } return PageResponse.of(content, p, s, total); } @@ -206,7 +225,10 @@ public class BoardService { /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ @Transactional public VoteResponse votePost(Long postId, String type, SessionUser user) { - mustFindPost(postId); + Post post = mustFindPost(postId); + if (post.getAuthorId().equals(user.getId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글에는 추천/비추천할 수 없습니다."); + } Long memberId = user.getId(); String existing = voteMapper.findPostVote(postId, memberId); boolean cancel = type.equals(existing); @@ -228,6 +250,9 @@ public class BoardService { if (comment == null) { throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."); } + if (comment.getAuthorId().equals(user.getId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "본인 댓글에는 추천/비추천할 수 없습니다."); + } Long memberId = user.getId(); String existing = voteMapper.findCommentVote(commentId, memberId); boolean cancel = type.equals(existing); @@ -266,7 +291,7 @@ public class BoardService { for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) { voteByComment.put(s.getCommentId(), s); } - List comments = commentMapper.findByPostId(postId).stream().map(c -> { + List comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId).stream().map(c -> { CommentResponse cr = CommentResponse.from(c); CommentVoteSummary s = voteByComment.get(c.getId()); if (s != null) { @@ -275,7 +300,8 @@ public class BoardService { cr.setMyVote(s.getMyVote()); } return cr; - }).toList(); + }).toList()); + comments = orderWithHot(comments); PostDetail detail = PostDetail.of(post, tags, comments); detail.setUpCount(voteMapper.countPostVotes(postId, UP)); @@ -285,6 +311,27 @@ public class BoardService { return detail; } + /** 인기 댓글(추천 50+ & 1일 이내) 최대 3건을 추천 많은 순으로 상단에 올리고 hot 표시 */ + private List orderWithHot(List comments) { + java.time.LocalDateTime cutoff = java.time.LocalDateTime.now().minusHours(HOT_WITHIN_HOURS); + List hot = comments.stream() + .filter(c -> c.getUpCount() != null && c.getUpCount() >= HOT_MIN_UP + && c.getCreatedAt() != null && c.getCreatedAt().isAfter(cutoff)) + .sorted((a, b) -> Integer.compare(b.getUpCount(), a.getUpCount())) + .limit(HOT_MAX) + .toList(); + if (hot.isEmpty()) { + return comments; + } + java.util.Set hotIds = new java.util.HashSet<>(); + hot.forEach(c -> { c.setHot(true); hotIds.add(c.getId()); }); + List ordered = new java.util.ArrayList<>(hot); + for (CommentResponse c : comments) { + if (!hotIds.contains(c.getId())) ordered.add(c); + } + return ordered; + } + /** 선택된 태그 ID 중 실제 DB에 존재하는 것만 게시글에 연결 (신규 태그 생성 없음) */ private void applyTags(Long postId, List tagIds) { if (tagIds == null || tagIds.isEmpty()) { diff --git a/src/main/resources/mapper/PostMapper.xml b/src/main/resources/mapper/PostMapper.xml index f9b1602..0939fc3 100644 --- a/src/main/resources/mapper/PostMapper.xml +++ b/src/main/resources/mapper/PostMapper.xml @@ -38,7 +38,8 @@ p.id, p.title, p.author_id, p.author_name, p.view_count, p.blocked, p.notice, p.created_at, m.google_picture AS author_google_picture, m.profile_image AS author_profile_image, (SELECT COUNT(*) FROM comment c WHERE c.post_id = p.id) AS comment_count, - (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') AS up_count + (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') AS up_count, + (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'DOWN') AS down_count FROM post p LEFT JOIN member m ON m.id = p.author_id @@ -46,6 +47,24 @@ LIMIT #{size} OFFSET #{offset} + + +