Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public enum ErrorCode {
REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 신고 대상입니다."),
CHAT_PARTNER_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "채팅 상대를 찾을 수 없습니다."),
CHAT_PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "채팅 참여자를 찾을 수 없습니다."),
BLOCK_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "차단 대상 사용자를 찾을 수 없습니다."),

// auth
USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."),
Expand Down Expand Up @@ -126,6 +127,10 @@ public enum ErrorCode {
// report
ALREADY_REPORTED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 신고한 상태입니다."),

// block
ALREADY_BLOCKED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 차단한 상태입니다."),
CANNOT_BLOCK_YOURSELF(HttpStatus.BAD_REQUEST.value(), "자기 자신을 차단할 수 없습니다."),

// chat
INVALID_CHAT_ROOM_STATE(HttpStatus.BAD_REQUEST.value(), "잘못된 채팅방 상태입니다."),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ public ResponseEntity<?> findAccessibleCodes() {

@GetMapping("/{code}")
public ResponseEntity<?> findPostsByCodeAndCategory(
@AuthorizedUser long siteUserId, // todo: '사용하지 않는 인자'로 인증된 유저만 접근하게 하기보다는, 다른 방식으로 접근하는것이 좋을 것 같다
@AuthorizedUser(required = false) Long siteUserId,
@PathVariable(value = "code") String code,
@RequestParam(value = "category", defaultValue = "전체") String category) {
List<PostListResponse> postsByCodeAndPostCategory = postQueryService
.findPostsByCodeAndPostCategory(code, category);
.findPostsByCodeAndPostCategory(code, category, siteUserId);
return ResponseEntity.ok().body(postsByCodeAndPostCategory);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,31 @@
public interface CommentRepository extends JpaRepository<Comment, Long> {

@Query(value = """
WITH RECURSIVE CommentTree AS (
SELECT
id, parent_id, post_id, site_user_id, content,
created_at, updated_at, is_deleted,
0 AS level, CAST(id AS CHAR(255)) AS path
FROM comment
WHERE post_id = :postId AND parent_id IS NULL
UNION ALL
SELECT
c.id, c.parent_id, c.post_id, c.site_user_id, c.content,
c.created_at, c.updated_at, c.is_deleted,
ct.level + 1, CONCAT(ct.path, '->', c.id)
FROM comment c
INNER JOIN CommentTree ct ON c.parent_id = ct.id
)
SELECT * FROM CommentTree
ORDER BY path
""", nativeQuery = true)
List<Comment> findCommentTreeByPostId(@Param("postId") Long postId);
WITH RECURSIVE CommentTree AS (
SELECT
id, parent_id, post_id, site_user_id, content,
created_at, updated_at, is_deleted,
0 AS level, CAST(id AS CHAR(255)) AS path
FROM comment
WHERE post_id = :postId AND parent_id IS NULL
AND site_user_id NOT IN (
SELECT blocked_id FROM user_block WHERE blocker_id = :siteUserId
)
UNION ALL
SELECT
c.id, c.parent_id, c.post_id, c.site_user_id, c.content,
c.created_at, c.updated_at, c.is_deleted,
ct.level + 1, CONCAT(ct.path, '->', c.id)
FROM comment c
INNER JOIN CommentTree ct ON c.parent_id = ct.id
WHERE c.site_user_id NOT IN (
SELECT blocked_id FROM user_block WHERE blocker_id = :siteUserId
)
)
SELECT * FROM CommentTree
ORDER BY path
""", nativeQuery = true)
List<Comment> findCommentTreeByPostIdExcludingBlockedUsers(@Param("postId") Long postId, @Param("siteUserId") Long siteUserId);

default Comment getById(Long id) {
return findById(id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public class CommentService {
public List<PostFindCommentResponse> findCommentsByPostId(long siteUserId, Long postId) {
SiteUser siteUser = siteUserRepository.findById(siteUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
List<Comment> allComments = commentRepository.findCommentTreeByPostId(postId);
List<Comment> allComments = commentRepository.findCommentTreeByPostIdExcludingBlockedUsers(postId, siteUserId);
List<Comment> filteredComments = filterCommentsByDeletionRules(allComments);

Set<Long> userIds = filteredComments.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public interface PostRepository extends JpaRepository<Post, Long> {

List<Post> findByBoardCode(String boardCode);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo 관련해서 OrderByCreatedAtDesc 적용하면 될 거 같습니다 !
오래된 순으로 응답이 만들어지는 게 당연한 로직이었네요 ...

Copy link
Contributor Author

@Gyuhyeok99 Gyuhyeok99 Sep 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 이건 일부로 적용 안했습니다! 차단기능 바로 상용 나갈건데 이러면 프론트 강제배포가 필요해서요!
서버만 먼저 나가면 오래된순으로 보이는 문제가 발생할 거 같습니다..
왜 아무도 몰랐을까요 🥲

인성님이 작업해주실테니 그때 수정해서 프론트랑 같이 배포나가면 좋을 거 같습니다! @Hexeong


@Query("""
SELECT p FROM Post p
WHERE p.boardCode = :boardCode
AND p.siteUserId NOT IN (
SELECT ub.blockedId FROM UserBlock ub WHERE ub.blockerId = :siteUserId
)
""")
List<Post> findByBoardCodeExcludingBlockedUsers(@Param("boardCode") String boardCode, @Param("siteUserId") Long siteUserId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 todo 관련해서 order by p.createAt desc 뒤에 붙이면 정렬될 거 같습니다

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에 말씀드린 것과 같은 이야기입니다!


@EntityGraph(attributePaths = {"postImageList"})
Optional<Post> findPostById(Long id);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.solidconnection.community.post.service;

import static com.example.solidconnection.common.exception.ErrorCode.ACCESS_DENIED;
import static com.example.solidconnection.common.exception.ErrorCode.INVALID_BOARD_CODE;
import static com.example.solidconnection.common.exception.ErrorCode.INVALID_POST_CATEGORY;
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;
Expand All @@ -21,6 +22,7 @@
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.siteuser.repository.UserBlockRepository;
import com.example.solidconnection.util.RedisUtils;
import java.util.List;
import java.util.Objects;
Expand All @@ -38,18 +40,24 @@ public class PostQueryService {
private final PostRepository postRepository;
private final PostLikeRepository postLikeRepository;
private final SiteUserRepository siteUserRepository;
private final UserBlockRepository userBlockRepository;
private final CommentService commentService;
private final RedisService redisService;
private final RedisUtils redisUtils;

@Transactional(readOnly = true)
public List<PostListResponse> findPostsByCodeAndPostCategory(String code, String category) {
public List<PostListResponse> findPostsByCodeAndPostCategory(String code, String category, Long siteUserId) {

String boardCode = validateCode(code);
PostCategory postCategory = validatePostCategory(category);
boardRepository.getByCode(boardCode);
List<Post> postList = postRepository.findByBoardCode(boardCode);

List<Post> postList; // todo : 추후 개선 필요(현재 최신순으로 응답나가지 않고 있음)
if (siteUserId != null) {
postList = postRepository.findByBoardCodeExcludingBlockedUsers(boardCode, siteUserId);
} else {
postList = postRepository.findByBoardCode(boardCode);
}
return PostListResponse.from(getPostListByPostCategory(postList, postCategory));
}

Expand All @@ -58,6 +66,9 @@ public PostFindResponse findPostById(long siteUserId, Long postId) {
SiteUser siteUser = siteUserRepository.findById(siteUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));
Post post = postRepository.getByIdUsingEntityGraph(postId);

validatedIsBlockedByMe(post, siteUser);

Boolean isOwner = getIsOwner(post, siteUser);
Boolean isLiked = getIsLiked(post, siteUser);

Expand Down Expand Up @@ -111,4 +122,10 @@ private List<Post> getPostListByPostCategory(List<Post> postList, PostCategory p
.filter(post -> post.getCategory().equals(postCategory))
.collect(Collectors.toList());
}

private void validatedIsBlockedByMe(Post post, SiteUser siteUser) {
if (userBlockRepository.existsByBlockerIdAndBlockedId(siteUser.getId(), post.getSiteUserId())) {
throw new CustomException(ACCESS_DENIED);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package com.example.solidconnection.siteuser.controller;

import com.example.solidconnection.common.dto.SliceResponse;
import com.example.solidconnection.common.resolver.AuthorizedUser;
import com.example.solidconnection.siteuser.dto.NicknameExistsResponse;
import com.example.solidconnection.siteuser.dto.UserBlockResponse;
import com.example.solidconnection.siteuser.service.SiteUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -23,4 +32,31 @@ public ResponseEntity<NicknameExistsResponse> checkNicknameExists(
NicknameExistsResponse nicknameExistsResponse = siteUserService.checkNicknameExists(nickname);
return ResponseEntity.ok(nicknameExistsResponse);
}

@GetMapping("/blocks")
public ResponseEntity<SliceResponse<UserBlockResponse>> getBlockedUsers(
@AuthorizedUser long siteUserId,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
) {
SliceResponse<UserBlockResponse> response = siteUserService.getBlockedUsers(siteUserId, pageable);
return ResponseEntity.ok(response);
}

@PostMapping("/block/{blocked-id}")
public ResponseEntity<Void> blockUser(
@AuthorizedUser long siteUserId,
@PathVariable("blocked-id") Long blockedId
) {
siteUserService.blockUser(siteUserId, blockedId);
return ResponseEntity.ok().build();
}

@DeleteMapping("/block/{blocked-id}")
public ResponseEntity<Void> cancelUserBlock(
@AuthorizedUser long siteUserId,
@PathVariable("blocked-id") Long blockedId
) {
siteUserService.cancelUserBlock(siteUserId, blockedId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ public class UserBlock extends BaseEntity {

@Column(name = "blocked_id", nullable = false)
private long blockedId;

public UserBlock(long blockerId, long blockedId) {
this.blockerId = blockerId;
this.blockedId = blockedId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.siteuser.dto;

import java.time.ZonedDateTime;

public record UserBlockResponse(
long id,
long blockedId,
String nickname,
ZonedDateTime createdAt
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.solidconnection.siteuser.repository;

import com.example.solidconnection.siteuser.domain.UserBlock;
import com.example.solidconnection.siteuser.dto.UserBlockResponse;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface UserBlockRepository extends JpaRepository<UserBlock, Long> {

boolean existsByBlockerIdAndBlockedId(long blockerId, long blockedId);

Optional<UserBlock> findByBlockerIdAndBlockedId(long blockerId, long blockedId);

@Query("""
SELECT new com.example.solidconnection.siteuser.dto.UserBlockResponse(
ub.id, ub.blockedId, su.nickname, ub.createdAt
)
FROM UserBlock ub
JOIN SiteUser su ON ub.blockedId = su.id
WHERE ub.blockerId = :blockerId
""")
Slice<UserBlockResponse> findBlockedUsersWithNickname(@Param("blockerId") long blockerId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,71 @@
package com.example.solidconnection.siteuser.service;

import static com.example.solidconnection.common.exception.ErrorCode.ALREADY_BLOCKED_BY_CURRENT_USER;
import static com.example.solidconnection.common.exception.ErrorCode.BLOCK_USER_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.CANNOT_BLOCK_YOURSELF;
import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND;

import com.example.solidconnection.common.dto.SliceResponse;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.siteuser.domain.UserBlock;
import com.example.solidconnection.siteuser.dto.NicknameExistsResponse;
import com.example.solidconnection.siteuser.dto.UserBlockResponse;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.siteuser.repository.UserBlockRepository;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Service
public class SiteUserService {

private final SiteUserRepository siteUserRepository;
private final UserBlockRepository userBlockRepository;

public NicknameExistsResponse checkNicknameExists(String nickname) {
boolean exists = siteUserRepository.existsByNickname(nickname);
return NicknameExistsResponse.from(exists);
}

@Transactional(readOnly = true)
public SliceResponse<UserBlockResponse> getBlockedUsers(long siteUserId, Pageable pageable) {
Slice<UserBlockResponse> slice = userBlockRepository.findBlockedUsersWithNickname(siteUserId, pageable);

List<UserBlockResponse> content = slice.getContent();
return SliceResponse.of(content, slice);
}

@Transactional
public void blockUser(long blockerId, long blockedId) {
validateBlockUser(blockerId, blockedId);
UserBlock userBlock = new UserBlock(blockerId, blockedId);
userBlockRepository.save(userBlock);
}

private void validateBlockUser(long blockerId, long blockedId) {
if (Objects.equals(blockerId, blockedId)) {
throw new CustomException(CANNOT_BLOCK_YOURSELF);
}
if (!siteUserRepository.existsById(blockedId)) {
throw new CustomException(USER_NOT_FOUND);
}
if (userBlockRepository.existsByBlockerIdAndBlockedId(blockerId, blockedId)) {
throw new CustomException(ALREADY_BLOCKED_BY_CURRENT_USER);
}
}

@Transactional
public void cancelUserBlock(long blockerId, long blockedId) {
if (!siteUserRepository.existsById(blockedId)) {
throw new CustomException(USER_NOT_FOUND);
}
UserBlock userBlock = userBlockRepository.findByBlockerIdAndBlockedId(blockerId, blockedId)
.orElseThrow(() -> new CustomException(BLOCK_USER_NOT_FOUND));
userBlockRepository.delete(userBlock);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse;
import com.example.solidconnection.siteuser.fixture.SiteUserFixture;
import com.example.solidconnection.siteuser.fixture.UserBlockFixture;
import com.example.solidconnection.support.TestContainerSpringBootTest;
import jakarta.transaction.Transactional;
import java.util.List;
Expand Down Expand Up @@ -56,6 +57,9 @@ class CommentServiceTest {
@Autowired
private CommentFixture commentFixture;

@Autowired
private UserBlockFixture userBlockFixture;

private SiteUser user1;
private SiteUser user2;
private Post post;
Expand Down Expand Up @@ -187,6 +191,33 @@ class 댓글_조회_테스트 {
.containsExactlyInAnyOrder(user2.getId(), user2.getId())
);
}

@Test
void 차단한_사용자의_댓글은_제외된다() {
// given
userBlockFixture.유저_차단(user1.getId(), user2.getId());
Comment parentComment1 = commentFixture.부모_댓글("부모 댓글1", post, user1);
Comment childComment1 = commentFixture.자식_댓글("자식 댓글1", post, user1, parentComment1);
Comment childComment2 = commentFixture.자식_댓글("자식 댓글2", post, user2, parentComment1);
Comment parentCommen2 = commentFixture.부모_댓글("부모 댓글2", post, user2);
Comment childComment3 = commentFixture.자식_댓글("자식 댓글1", post, user1, parentCommen2);
Comment childComment4 = commentFixture.자식_댓글("자식 댓글1", post, user1, parentCommen2);


// when
List<PostFindCommentResponse> responses = commentService.findCommentsByPostId(user1.getId(), post.getId());

// then
assertAll(
() -> assertThat(responses).hasSize(2),
() -> assertThat(responses)
.extracting(PostFindCommentResponse::id)
.containsExactly(parentComment1.getId(), childComment1.getId()),
() -> assertThat(responses)
.extracting(PostFindCommentResponse::id)
.doesNotContain(childComment2.getId(), parentCommen2.getId(), childComment3.getId(), childComment4.getId())
);
}
}

@Nested
Expand Down
Loading
Loading