feat: 게시판 인기글/블라인드/자기글 추천차단 + 프로필 전역 동기화
CI / build (push) Failing after 15m11s

- GET /api/auth/profile: 아바타·포인트 포함 전체 프로필(재로그인 없이 최신화)
- 자기 글/댓글 추천·비추천 차단(400)
- 인기 글: 등록 1일 이내 + 추천 50건 이상이면 목록 상단 최대 3건 노출(hot)
- 인기 댓글: 동일 조건으로 댓글 목록 상단 정렬(hot)
- PostSummary 에 downCount 추가(비추천 20+ 블라인드 판정용)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 13:21:19 +09:00
parent ef9dace1df
commit b4344f5270
7 changed files with 95 additions and 5 deletions
@@ -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(
@@ -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 백업)의 표시 이름도 동기화한다.
@@ -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()
@@ -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;
@@ -22,6 +22,12 @@ public interface PostMapper {
@Param("keyword") String keyword,
@Param("searchType") String searchType);
/** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */
List<PostSummary> 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);
@@ -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<PostSummary> list(int page, int size, String category, String tag, String keyword, String searchType) {
@@ -61,6 +66,20 @@ public class BoardService {
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
List<PostSummary> 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<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX);
if (!hot.isEmpty()) {
java.util.Set<Long> hotIds = new java.util.HashSet<>();
hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); });
List<PostSummary> 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<CommentResponse> comments = commentMapper.findByPostId(postId).stream().map(c -> {
List<CommentResponse> 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<CommentResponse> orderWithHot(List<CommentResponse> comments) {
java.time.LocalDateTime cutoff = java.time.LocalDateTime.now().minusHours(HOT_WITHIN_HOURS);
List<CommentResponse> 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<Long> hotIds = new java.util.HashSet<>();
hot.forEach(c -> { c.setHot(true); hotIds.add(c.getId()); });
List<CommentResponse> 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<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) {
+20 -1
View File
@@ -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
<include refid="filter"/>
@@ -46,6 +47,24 @@
LIMIT #{size} OFFSET #{offset}
</select>
<!-- 인기 글: 등록 #{hours}시간 이내 + 추천 #{minUp}건 이상, 추천 많은 순 #{limit}건 (상단 노출용) -->
<select id="findHotPosts" resultType="com.sb.web.board.dto.PostSummary">
SELECT
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 = 'DOWN') AS down_count
FROM post p
LEFT JOIN member m ON m.id = p.author_id
WHERE p.notice = 0
AND (#{category} IS NULL OR p.category = #{category})
AND p.created_at >= NOW() - INTERVAL #{hours} HOUR
AND (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') >= #{minUp}
ORDER BY (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') DESC, p.id DESC
LIMIT #{limit}
</select>
<select id="countPage" resultType="long">
SELECT COUNT(*)
FROM post p