Skip to content

Commit e7669ed

Browse files
authored
feat(moderation): 사용자 차단 관련 기능 구현 (#128)
* feat: user_blocks 테이블과 UserBlock 엔티티 구현 * feat(moderation): UserBlockCommandService, UserBlockQueryService 구현 * feat(moderation): 사용자 차단과 관련한 요청 및 응답 dto 구현 & ModerationController에 작성 사용자 차단, 차단 사용자 목록 조회, 사용자 차단 해제 API 스웨거 문서화 * feat(moderation): 유저 검색과 친구 요청에 차단 관계 반영 양방향(내가 차단한 경우 + 나를 차단한 경우) 모두 포함 * refactor(moderation): 친구 요청 수락/거절 시 차단 관계 존재 시의 예외 처리 * refactor(moderation): 친구 요청 수락/거절 시 차단 관계 존재 시 ErrorCode의 스웨거 문서화
1 parent 4ae1c24 commit e7669ed

14 files changed

Lines changed: 365 additions & 5 deletions

File tree

src/main/java/com/devkor/ifive/nadab/domain/friend/api/FriendController.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ public ResponseEntity<ApiResponseDto<Void>> cancelFriendRequest(
183183
description = """
184184
- ErrorCode: FRIENDSHIP_ALREADY_PROCESSED - 이미 처리된 요청
185185
- ErrorCode: FRIEND_LIMIT_EXCEEDED - 친구 수 20명 초과
186+
- ErrorCode: MODERATION_BLOCK_RELATIONSHIP_EXISTS - 차단 관계 존재
186187
""",
187188
content = @Content
188189
),
@@ -217,7 +218,10 @@ public ResponseEntity<ApiResponseDto<Void>> acceptFriendRequest(
217218
@ApiResponse(responseCode = "204", description = "거절 성공"),
218219
@ApiResponse(
219220
responseCode = "400",
220-
description = "ErrorCode: FRIENDSHIP_ALREADY_PROCESSED - 이미 처리된 요청",
221+
description = """
222+
- ErrorCode: FRIENDSHIP_ALREADY_PROCESSED - 이미 처리된 요청
223+
- ErrorCode: MODERATION_BLOCK_RELATIONSHIP_EXISTS - 차단 관계 존재
224+
""",
221225
content = @Content
222226
),
223227
@ApiResponse(

src/main/java/com/devkor/ifive/nadab/domain/friend/application/FriendshipCommandService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.devkor.ifive.nadab.domain.friend.core.entity.Friendship;
66
import com.devkor.ifive.nadab.domain.friend.core.entity.FriendshipStatus;
77
import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository;
8+
import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository;
89
import com.devkor.ifive.nadab.domain.user.core.entity.User;
910
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
1011
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
@@ -25,6 +26,7 @@ public class FriendshipCommandService {
2526

2627
private final FriendshipRepository friendshipRepository;
2728
private final UserRepository userRepository;
29+
private final UserBlockRepository userBlockRepository;
2830
private final ApplicationEventPublisher eventPublisher;
2931

3032
public Long sendFriendRequest(Long requesterId, String receiverNickname) {
@@ -128,6 +130,11 @@ public void acceptFriendRequest(Long userId, Long friendshipId) {
128130
throw new BadRequestException(ErrorCode.FRIEND_LIMIT_EXCEEDED);
129131
}
130132

133+
// 차단 관계 확인
134+
if (userBlockRepository.existsAnyBlockBetweenUsers(userId, requesterId)) {
135+
throw new BadRequestException(ErrorCode.MODERATION_BLOCK_RELATIONSHIP_EXISTS);
136+
}
137+
131138
// 5. 상태 변경
132139
friendship.accept();
133140

@@ -152,6 +159,12 @@ public void rejectFriendRequest(Long userId, Long friendshipId) {
152159
throw new BadRequestException(ErrorCode.FRIENDSHIP_ALREADY_PROCESSED);
153160
}
154161

162+
// 차단관계 확인
163+
Long requesterId = friendship.getOtherUserId(userId);
164+
if (userBlockRepository.existsAnyBlockBetweenUsers(userId, requesterId)) {
165+
throw new BadRequestException(ErrorCode.MODERATION_BLOCK_RELATIONSHIP_EXISTS);
166+
}
167+
155168
// 4. 삭제
156169
friendshipRepository.delete(friendship);
157170
}

src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import org.springframework.data.repository.query.Param;
99

1010
import java.util.List;
11-
import java.util.Optional;
1211

1312
public interface FriendshipRepository extends JpaRepository<Friendship, Long> {
1413

@@ -56,11 +55,24 @@ select count(f) from Friendship f
5655
and f.status = 'PENDING'
5756
and lower(r.nickname) like lower(:keyword) escape '\\'
5857
and f.user1.deletedAt is null and f.user2.deletedAt is null
58+
and not exists (
59+
select 1 from UserBlock ub
60+
where (ub.blocker.id = :userId and ub.blocked.id = r.id)
61+
or (ub.blocker.id = r.id and ub.blocked.id = :userId)
62+
)
5963
order by f.createdAt desc
6064
""")
6165
List<Friendship> findReceivedPendingRequestsByKeyword(
6266
@Param("userId") Long userId,
6367
@Param("keyword") String keyword
6468
);
6569

66-
}
70+
@Modifying
71+
@Query("""
72+
delete from Friendship f
73+
where f.user1.id = :userId1
74+
and f.user2.id = :userId2
75+
""")
76+
int deleteByUserIds(@Param("userId1") Long userId1, @Param("userId2") Long userId2);
77+
78+
}

src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package com.devkor.ifive.nadab.domain.moderation.api;
22

3+
import com.devkor.ifive.nadab.domain.moderation.api.dto.request.BlockUserRequest;
34
import com.devkor.ifive.nadab.domain.moderation.api.dto.request.ReportContentRequest;
5+
import com.devkor.ifive.nadab.domain.moderation.api.dto.response.BlockedUserListResponse;
46
import com.devkor.ifive.nadab.domain.moderation.application.ContentReportCommandService;
7+
import com.devkor.ifive.nadab.domain.moderation.application.UserBlockCommandService;
8+
import com.devkor.ifive.nadab.domain.moderation.application.UserBlockQueryService;
59
import com.devkor.ifive.nadab.global.core.response.ApiResponseDto;
610
import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity;
711
import com.devkor.ifive.nadab.global.security.principal.UserPrincipal;
812
import io.swagger.v3.oas.annotations.Operation;
913
import io.swagger.v3.oas.annotations.media.Content;
14+
import io.swagger.v3.oas.annotations.media.Schema;
1015
import io.swagger.v3.oas.annotations.responses.ApiResponse;
1116
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
1217
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -24,6 +29,8 @@
2429
public class ModerationController {
2530

2631
private final ContentReportCommandService contentReportCommandService;
32+
private final UserBlockCommandService userBlockCommandService;
33+
private final UserBlockQueryService userBlockQueryService;
2734

2835
@PostMapping("/reports")
2936
@PreAuthorize("isAuthenticated()")
@@ -87,4 +94,91 @@ public ResponseEntity<ApiResponseDto<Void>> reportContent(
8794
);
8895
return ApiResponseEntity.noContent();
8996
}
97+
98+
@PostMapping("/blocks")
99+
@PreAuthorize("isAuthenticated()")
100+
@Operation(
101+
summary = "사용자 차단",
102+
description = """
103+
특정 사용자를 차단합니다.
104+
105+
- 차단 즉시 기존 친구 관계(수락/대기 포함)를 제거합니다.
106+
- 상호 검색 결과 및 상호 피드 노출이 즉시 중단됩니다.
107+
""",
108+
security = @SecurityRequirement(name = "bearerAuth"),
109+
responses = {
110+
@ApiResponse(
111+
responseCode = "204",
112+
description = "차단 성공",
113+
content = @Content
114+
),
115+
@ApiResponse(
116+
responseCode = "400",
117+
description = """
118+
- ErrorCode: MODERATION_CANNOT_BLOCK_SELF - 본인 차단 불가
119+
- ErrorCode: MODERATION_ALREADY_BLOCKED - 이미 차단한 사용자
120+
""",
121+
content = @Content
122+
),
123+
@ApiResponse(
124+
responseCode = "404",
125+
description = "ErrorCode: USER_NOT_FOUND",
126+
content = @Content
127+
),
128+
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
129+
}
130+
)
131+
public ResponseEntity<ApiResponseDto<Void>> blockUser(
132+
@AuthenticationPrincipal UserPrincipal principal,
133+
@Valid @RequestBody BlockUserRequest request
134+
) {
135+
userBlockCommandService.blockUser(principal.getId(), request.blockedNickname());
136+
return ApiResponseEntity.noContent();
137+
}
138+
139+
@GetMapping("/blocks")
140+
@PreAuthorize("isAuthenticated()")
141+
@Operation(
142+
summary = "차단 사용자 목록 조회",
143+
description = "내가 차단한 사용자 목록을 조회합니다.",
144+
security = @SecurityRequirement(name = "bearerAuth"),
145+
responses = {
146+
@ApiResponse(
147+
responseCode = "200",
148+
description = "조회 성공",
149+
content = @Content(schema = @Schema(implementation = BlockedUserListResponse.class))
150+
),
151+
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
152+
}
153+
)
154+
public ResponseEntity<ApiResponseDto<BlockedUserListResponse>> getBlockedUsers(
155+
@AuthenticationPrincipal UserPrincipal principal
156+
) {
157+
BlockedUserListResponse response = userBlockQueryService.getBlockedUsers(principal.getId());
158+
return ApiResponseEntity.ok(response);
159+
}
160+
161+
@DeleteMapping("/blocks/{userBlockId}")
162+
@PreAuthorize("isAuthenticated()")
163+
@Operation(
164+
summary = "사용자 차단 해제",
165+
description = "차단 관계를 해제합니다.",
166+
security = @SecurityRequirement(name = "bearerAuth"),
167+
responses = {
168+
@ApiResponse(responseCode = "204", description = "차단 해제 성공"),
169+
@ApiResponse(
170+
responseCode = "404",
171+
description = "ErrorCode: MODERATION_BLOCK_NOT_FOUND - 차단 관계를 찾을 수 없음",
172+
content = @Content
173+
),
174+
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
175+
}
176+
)
177+
public ResponseEntity<ApiResponseDto<Void>> unblockUser(
178+
@AuthenticationPrincipal UserPrincipal principal,
179+
@PathVariable Long userBlockId
180+
) {
181+
userBlockCommandService.unblockUser(principal.getId(), userBlockId);
182+
return ApiResponseEntity.noContent();
183+
}
90184
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.devkor.ifive.nadab.domain.moderation.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
7+
@Schema(description = "사용자 차단 요청")
8+
public record BlockUserRequest(
9+
@Schema(description = "차단할 사용자 닉네임", example = "모래")
10+
@NotBlank(message = "닉네임은 필수입니다.")
11+
@Size(min = 1, max = 255, message = "닉네임은 1자 이상 255자 이하여야 합니다.")
12+
String blockedNickname
13+
) {
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.devkor.ifive.nadab.domain.moderation.api.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
import java.util.List;
6+
7+
@Schema(description = "차단 사용자 목록 응답")
8+
public record BlockedUserListResponse(
9+
@Schema(description = "총 차단 사용자 수", example = "2")
10+
int totalCount,
11+
12+
@Schema(description = "차단 사용자 목록")
13+
List<BlockedUserResponse> blockedUsers
14+
) {
15+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.devkor.ifive.nadab.domain.moderation.api.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
@Schema(description = "차단 사용자 정보")
6+
public record BlockedUserResponse(
7+
@Schema(description = "차단 관계 ID", example = "12")
8+
Long userBlockId,
9+
10+
@Schema(description = "닉네임", example = "모래")
11+
String nickname,
12+
13+
@Schema(description = "프로필 이미지 URL", example = "https://cdn.example.com/profiles/abc123.png")
14+
String profileImageUrl
15+
) {
16+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.devkor.ifive.nadab.domain.moderation.application;
2+
3+
import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository;
4+
import com.devkor.ifive.nadab.domain.moderation.core.entity.UserBlock;
5+
import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository;
6+
import com.devkor.ifive.nadab.domain.user.core.entity.User;
7+
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
8+
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
9+
import com.devkor.ifive.nadab.global.exception.BadRequestException;
10+
import com.devkor.ifive.nadab.global.exception.NotFoundException;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.stereotype.Service;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
@Service
16+
@RequiredArgsConstructor
17+
@Transactional
18+
public class UserBlockCommandService {
19+
20+
private final UserRepository userRepository;
21+
private final UserBlockRepository userBlockRepository;
22+
private final FriendshipRepository friendshipRepository;
23+
24+
public Long blockUser(Long blockerId, String blockedNickname) {
25+
User blocked = userRepository.findByNicknameAndDeletedAtIsNull(blockedNickname)
26+
.orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND));
27+
28+
if (blockerId.equals(blocked.getId())) {
29+
throw new BadRequestException(ErrorCode.MODERATION_CANNOT_BLOCK_SELF);
30+
}
31+
32+
if (userBlockRepository.existsByBlocker_IdAndBlocked_Id(blockerId, blocked.getId())) {
33+
throw new BadRequestException(ErrorCode.MODERATION_ALREADY_BLOCKED);
34+
}
35+
36+
User blocker = userRepository.getReferenceById(blockerId);
37+
UserBlock saved = userBlockRepository.save(UserBlock.create(blocker, blocked));
38+
39+
Long userId1 = Math.min(blockerId, blocked.getId());
40+
Long userId2 = Math.max(blockerId, blocked.getId());
41+
friendshipRepository.deleteByUserIds(userId1, userId2);
42+
43+
return saved.getId();
44+
}
45+
46+
public void unblockUser(Long blockerId, Long userBlockId) {
47+
UserBlock userBlock = userBlockRepository.findByIdAndBlocker_Id(userBlockId, blockerId)
48+
.orElseThrow(() -> new NotFoundException(ErrorCode.MODERATION_BLOCK_NOT_FOUND));
49+
userBlockRepository.delete(userBlock);
50+
}
51+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.devkor.ifive.nadab.domain.moderation.application;
2+
3+
import com.devkor.ifive.nadab.domain.moderation.api.dto.response.BlockedUserListResponse;
4+
import com.devkor.ifive.nadab.domain.moderation.api.dto.response.BlockedUserResponse;
5+
import com.devkor.ifive.nadab.domain.moderation.core.entity.UserBlock;
6+
import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository;
7+
import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
import java.util.List;
13+
14+
@Service
15+
@RequiredArgsConstructor
16+
@Transactional(readOnly = true)
17+
public class UserBlockQueryService {
18+
19+
private final UserBlockRepository userBlockRepository;
20+
private final ProfileImageUrlBuilder profileImageUrlBuilder;
21+
22+
public BlockedUserListResponse getBlockedUsers(Long blockerId) {
23+
List<UserBlock> userBlocks = userBlockRepository.findByBlockerIdWithBlockedUser(blockerId);
24+
25+
List<BlockedUserResponse> blockedUsers = userBlocks.stream()
26+
.map(userBlock -> new BlockedUserResponse(
27+
userBlock.getId(),
28+
userBlock.getBlocked().getNickname(),
29+
profileImageUrlBuilder.buildUserProfileUrl(userBlock.getBlocked())
30+
))
31+
.toList();
32+
33+
return new BlockedUserListResponse(blockedUsers.size(), blockedUsers);
34+
}
35+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.devkor.ifive.nadab.domain.moderation.core.entity;
2+
3+
import com.devkor.ifive.nadab.domain.user.core.entity.User;
4+
import com.devkor.ifive.nadab.global.shared.entity.AuditableEntity;
5+
import jakarta.persistence.*;
6+
import lombok.AccessLevel;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Entity
11+
@Table(name = "user_blocks")
12+
@Getter
13+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
14+
public class UserBlock extends AuditableEntity {
15+
16+
@Id
17+
@GeneratedValue(strategy = GenerationType.IDENTITY)
18+
private Long id;
19+
20+
@ManyToOne(fetch = FetchType.LAZY, optional = false)
21+
@JoinColumn(name = "blocker_id", nullable = false)
22+
private User blocker;
23+
24+
@ManyToOne(fetch = FetchType.LAZY, optional = false)
25+
@JoinColumn(name = "blocked_id", nullable = false)
26+
private User blocked;
27+
28+
public static UserBlock create(User blocker, User blocked) {
29+
UserBlock userBlock = new UserBlock();
30+
userBlock.blocker = blocker;
31+
userBlock.blocked = blocked;
32+
return userBlock;
33+
}
34+
}

0 commit comments

Comments
 (0)