-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[BE] Feat/#378 Admin API 구현 #405
Changes from all commits
1000bed
0a7f1cb
540a644
1dd3c73
1c50017
309b827
1535ada
d688eee
06ef99b
c7c8ee8
7f2c47b
523ce2c
0e887ea
190f5d3
1932e58
bb6fb8d
0f54571
a1b2b54
1e23aee
605a2e3
e5a898d
53f9c42
f7afa50
9084afd
b30c821
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
== 관리자 기능 | ||
|
||
=== 전체 회원 조회 | ||
|
||
operation::admin-controller-test/find-all-member-details[snippets='http-request,http-response'] | ||
|
||
=== 회원 상세 조회 | ||
|
||
operation::admin-controller-test/find-member[snippets='http-request,http-response'] | ||
|
||
=== 회원 차단(삭제) | ||
|
||
operation::admin-controller-test/delete-member[snippets='http-request,http-response'] | ||
|
||
=== 토픽 삭제 | ||
|
||
operation::admin-controller-test/delete-topic[snippets='http-request,http-response'] | ||
|
||
=== 토픽 이미지 삭제 | ||
|
||
operation::admin-controller-test/delete-topic-image[snippets='http-request,http-response'] | ||
|
||
=== 핀 삭제 | ||
|
||
operation::admin-controller-test/delete-pin[snippets='http-request,http-response'] | ||
|
||
=== 핀 이미지 삭제 | ||
|
||
operation::admin-controller-test/delete-pin-image[snippets='http-request,http-response'] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
package com.mapbefine.mapbefine.admin.application; | ||
|
||
import static com.mapbefine.mapbefine.permission.exception.PermissionErrorCode.PERMISSION_FORBIDDEN_BY_NOT_ADMIN; | ||
import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.TOPIC_NOT_FOUND; | ||
|
||
import com.mapbefine.mapbefine.atlas.domain.AtlasRepository; | ||
import com.mapbefine.mapbefine.auth.domain.AuthMember; | ||
import com.mapbefine.mapbefine.bookmark.domain.BookmarkRepository; | ||
import com.mapbefine.mapbefine.member.domain.Member; | ||
import com.mapbefine.mapbefine.member.domain.MemberRepository; | ||
import com.mapbefine.mapbefine.member.domain.Role; | ||
import com.mapbefine.mapbefine.member.domain.Status; | ||
import com.mapbefine.mapbefine.permission.domain.PermissionRepository; | ||
import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionForbiddenException; | ||
import com.mapbefine.mapbefine.pin.domain.Pin; | ||
import com.mapbefine.mapbefine.pin.domain.PinImageRepository; | ||
import com.mapbefine.mapbefine.pin.domain.PinRepository; | ||
import com.mapbefine.mapbefine.topic.domain.Topic; | ||
import com.mapbefine.mapbefine.topic.domain.TopicRepository; | ||
import com.mapbefine.mapbefine.topic.exception.TopicException; | ||
import java.util.List; | ||
import java.util.NoSuchElementException; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@Transactional | ||
public class AdminCommandService { | ||
|
||
private final MemberRepository memberRepository; | ||
private final TopicRepository topicRepository; | ||
private final PinRepository pinRepository; | ||
private final PinImageRepository pinImageRepository; | ||
private final PermissionRepository permissionRepository; | ||
private final AtlasRepository atlasRepository; | ||
private final BookmarkRepository bookmarkRepository; | ||
|
||
public AdminCommandService( | ||
MemberRepository memberRepository, | ||
TopicRepository topicRepository, | ||
PinRepository pinRepository, | ||
PinImageRepository pinImageRepository, | ||
PermissionRepository permissionRepository, | ||
AtlasRepository atlasRepository, | ||
BookmarkRepository bookmarkRepository | ||
) { | ||
this.memberRepository = memberRepository; | ||
this.topicRepository = topicRepository; | ||
this.pinRepository = pinRepository; | ||
this.pinImageRepository = pinImageRepository; | ||
this.permissionRepository = permissionRepository; | ||
this.atlasRepository = atlasRepository; | ||
this.bookmarkRepository = bookmarkRepository; | ||
} | ||
|
||
public void blockMember(AuthMember authMember, Long memberId) { | ||
validateAdminPermission(authMember); | ||
|
||
Member member = findMemberById(memberId); | ||
member.updateStatus(Status.BLOCKED); | ||
|
||
deleteAllRelatedMember(member); | ||
} | ||
|
||
private void deleteAllRelatedMember(Member member) { | ||
List<Long> pinIds = extractPinIdsByMember(member); | ||
Long memberId = member.getId(); | ||
|
||
pinImageRepository.deleteAllByPinIds(pinIds); | ||
topicRepository.deleteAllByMemberId(memberId); | ||
pinRepository.deleteAllByMemberId(memberId); | ||
permissionRepository.deleteAllByMemberId(memberId); | ||
atlasRepository.deleteAllByMemberId(memberId); | ||
bookmarkRepository.deleteAllByMemberId(memberId); | ||
} | ||
|
||
private Member findMemberById(Long id) { | ||
return memberRepository.findById(id) | ||
.orElseThrow(() -> new NoSuchElementException("findMemberByAuthMember; member not found; id=" + id)); | ||
} | ||
|
||
private void validateAdminPermission(AuthMember authMember) { | ||
if (authMember.isRole(Role.ADMIN)) { | ||
return; | ||
} | ||
|
||
throw new PermissionForbiddenException(PERMISSION_FORBIDDEN_BY_NOT_ADMIN); | ||
} | ||
|
||
private List<Long> extractPinIdsByMember(Member member) { | ||
return member.getCreatedPins() | ||
.stream() | ||
.map(Pin::getId) | ||
.toList(); | ||
} | ||
|
||
public void deleteTopic(AuthMember authMember, Long topicId) { | ||
validateAdminPermission(authMember); | ||
|
||
topicRepository.deleteById(topicId); | ||
} | ||
|
||
public void deleteTopicImage(AuthMember authMember, Long topicId) { | ||
validateAdminPermission(authMember); | ||
|
||
Topic topic = findTopicById(topicId); | ||
topic.removeImage(); | ||
} | ||
|
||
private Topic findTopicById(Long topicId) { | ||
return topicRepository.findById(topicId) | ||
.orElseThrow(() -> new TopicException.TopicNotFoundException(TOPIC_NOT_FOUND, List.of(topicId))); | ||
} | ||
|
||
public void deletePin(AuthMember authMember, Long pinId) { | ||
validateAdminPermission(authMember); | ||
|
||
pinRepository.deleteById(pinId); | ||
} | ||
|
||
public void deletePinImage(AuthMember authMember, Long pinImageId) { | ||
validateAdminPermission(authMember); | ||
|
||
pinImageRepository.deleteById(pinImageId); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
package com.mapbefine.mapbefine.admin.application; | ||
|
||
import com.mapbefine.mapbefine.admin.dto.AdminMemberDetailResponse; | ||
import com.mapbefine.mapbefine.admin.dto.AdminMemberResponse; | ||
import com.mapbefine.mapbefine.auth.domain.AuthMember; | ||
import com.mapbefine.mapbefine.member.domain.Member; | ||
import com.mapbefine.mapbefine.member.domain.MemberRepository; | ||
import com.mapbefine.mapbefine.member.domain.Role; | ||
import com.mapbefine.mapbefine.permission.exception.PermissionErrorCode; | ||
import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionForbiddenException; | ||
import com.mapbefine.mapbefine.pin.domain.Pin; | ||
import com.mapbefine.mapbefine.topic.domain.Topic; | ||
import java.util.List; | ||
import java.util.NoSuchElementException; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
||
@Service | ||
@Transactional(readOnly = true) | ||
public class AdminQueryService { | ||
|
||
private final MemberRepository memberRepository; | ||
|
||
public AdminQueryService(MemberRepository memberRepository) { | ||
this.memberRepository = memberRepository; | ||
} | ||
|
||
public List<AdminMemberResponse> findAllMemberDetails(AuthMember authMember) { | ||
validateAdminPermission(authMember); | ||
|
||
List<Member> members = memberRepository.findAllByMemberInfoRole(Role.USER); | ||
|
||
return members.stream() | ||
.map(AdminMemberResponse::from) | ||
.toList(); | ||
} | ||
|
||
private Member findMemberById(Long id) { | ||
return memberRepository.findById(id) | ||
.orElseThrow(() -> new NoSuchElementException("findMemberByAuthMember; member not found; id=" + id)); | ||
} | ||
|
||
private void validateAdminPermission(AuthMember authMember) { | ||
if (authMember.isRole(Role.ADMIN)) { | ||
return; | ||
} | ||
|
||
throw new PermissionForbiddenException(PermissionErrorCode.PERMISSION_FORBIDDEN_BY_NOT_ADMIN); | ||
} | ||
|
||
public AdminMemberDetailResponse findMemberDetail(AuthMember authMember, Long memberId) { | ||
validateAdminPermission(authMember); | ||
|
||
Member findMember = findMemberById(memberId); | ||
List<Topic> topics = findMember.getCreatedTopics(); | ||
List<Pin> pins = findMember.getCreatedPins(); | ||
|
||
return AdminMemberDetailResponse.of(findMember, topics, pins); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
package com.mapbefine.mapbefine.admin.dto; | ||
|
||
import com.mapbefine.mapbefine.member.domain.Member; | ||
import com.mapbefine.mapbefine.member.domain.MemberInfo; | ||
import com.mapbefine.mapbefine.pin.domain.Pin; | ||
import com.mapbefine.mapbefine.pin.dto.response.PinResponse; | ||
import com.mapbefine.mapbefine.topic.domain.Topic; | ||
import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; | ||
import java.time.LocalDateTime; | ||
import java.util.List; | ||
|
||
public record AdminMemberDetailResponse( | ||
Long id, | ||
String nickName, | ||
String email, | ||
String imageUrl, | ||
List<TopicResponse> topics, | ||
List<PinResponse> pins, | ||
LocalDateTime updatedAt | ||
) { | ||
|
||
// TODO: 2023/09/12 topics, pins 모두 member를 통해 얻어올 수 있다. Service에서 꺼내서 넘겨줄 것인가 ? 아니면 DTO에서 꺼내올 것인가 ? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제가 최근들어 N + 1 문제에 대해서 알게되서 그런지, Member 에서 꺼내오는 것이 추후 Join 을 통해서 성능 개선하기에 쉬울 것 같아요. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 근데 옆에서 석진씨가 OneToMany 컬럼이 2개 이상일 때 Fetch Join 하면 에러날 수도 있다고 하네요! 다음에 같이 한번 실험해봐요 홍홍ㅎ옿옿옿옿오 |
||
public static AdminMemberDetailResponse of( | ||
Member member, | ||
List<Topic> topics, | ||
List<Pin> pins | ||
) { | ||
MemberInfo memberInfo = member.getMemberInfo(); | ||
List<TopicResponse> topicResponses = topics.stream() | ||
.map(TopicResponse::fromGuestQuery) | ||
.toList(); | ||
List<PinResponse> pinResponses = pins.stream() | ||
.map(PinResponse::from) | ||
.toList(); | ||
|
||
return new AdminMemberDetailResponse( | ||
member.getId(), | ||
memberInfo.getNickName(), | ||
memberInfo.getEmail(), | ||
memberInfo.getImageUrl(), | ||
topicResponses, | ||
pinResponses, | ||
member.getUpdatedAt() | ||
); | ||
} | ||
|
||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package com.mapbefine.mapbefine.admin.dto; | ||
|
||
import com.mapbefine.mapbefine.member.domain.Member; | ||
import com.mapbefine.mapbefine.member.domain.MemberInfo; | ||
import java.time.LocalDateTime; | ||
|
||
public record AdminMemberResponse( | ||
Long id, | ||
String nickName, | ||
String email, | ||
String imageUrl, | ||
LocalDateTime updatedAt | ||
) { | ||
|
||
public static AdminMemberResponse from(Member member) { | ||
MemberInfo memberInfo = member.getMemberInfo(); | ||
|
||
return new AdminMemberResponse( | ||
member.getId(), | ||
memberInfo.getNickName(), | ||
memberInfo.getEmail(), | ||
memberInfo.getImageUrl(), | ||
member.getUpdatedAt() | ||
); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
package com.mapbefine.mapbefine.admin.presentation; | ||
|
||
import com.mapbefine.mapbefine.admin.application.AdminCommandService; | ||
import com.mapbefine.mapbefine.admin.application.AdminQueryService; | ||
import com.mapbefine.mapbefine.admin.dto.AdminMemberDetailResponse; | ||
import com.mapbefine.mapbefine.admin.dto.AdminMemberResponse; | ||
import com.mapbefine.mapbefine.auth.domain.AuthMember; | ||
import java.util.List; | ||
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.RequestMapping; | ||
import org.springframework.web.bind.annotation.RestController; | ||
|
||
@RestController | ||
@RequestMapping("/admin") | ||
public class AdminController { | ||
|
||
private final AdminQueryService adminQueryService; | ||
private final AdminCommandService adminCommandService; | ||
|
||
|
||
public AdminController(AdminQueryService adminQueryService, AdminCommandService adminCommandService) { | ||
this.adminQueryService = adminQueryService; | ||
this.adminCommandService = adminCommandService; | ||
} | ||
|
||
@GetMapping("/members") | ||
public ResponseEntity<List<AdminMemberResponse>> findAllMembers(AuthMember authMember) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3. 이미 관리자가 호출한 게 확인이 되었다면 AuthMember를 아예 안받아도 되지는 않나요?! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Service에서 이중으로 검증하기 위함이었어요 ! 어떤 방법이든 Service에서 검증해줄 수는 있으니까요. 어떤 방법이 좋을까요 ? 도이뿐만 아니라, 준팍과 매튜도 동일한 내용에 대해서 코멘트 남겨주셔서 의견 덧붙입니다. 서비스가 핵심 비즈니스 로직을 담고 있는 만큼, 서비스에서의 검증은 필수적이라고 생각했어요. 혹여나, 다른 Controller에서 해당 Service를 호출하게 된다면, 권한과 상관없이 관리자 기능을 수행할수도 있지 않을까요 ? 관리 포인트가 늘어나게 된다는 점에서 동일한 리뷰를 주신 것 같은데요. 저는 Interceptor가 중복 코드를 없애기 위해서, AOP를 통해 Admin관련 서비스 메서드들이 호출되기 이전에 매번 검증 메서드를 호출하도록 하는 방법이 있을 것 같아요. 다만, Admin만을 위해 AOP를 도입하는 것이 맞을까 라는 의문이 들기도 하네요. 내일 오전 강의 시작 전에(혹은 오후에) 잠깐 모여서 이야기 나누어봤으면 좋겠습니다. |
||
List<AdminMemberResponse> responses = adminQueryService.findAllMemberDetails(authMember); | ||
|
||
return ResponseEntity.ok(responses); | ||
} | ||
|
||
@DeleteMapping("/members/{memberId}") | ||
public ResponseEntity<Void> deleteMember(AuthMember authMember, @PathVariable Long memberId) { | ||
adminCommandService.blockMember(authMember, memberId); | ||
|
||
return ResponseEntity.noContent().build(); | ||
} | ||
|
||
@GetMapping("/members/{memberId}") | ||
public ResponseEntity<AdminMemberDetailResponse> findMember(AuthMember authMember, @PathVariable Long memberId) { | ||
AdminMemberDetailResponse response = adminQueryService.findMemberDetail(authMember, memberId); | ||
|
||
return ResponseEntity.ok(response); | ||
} | ||
|
||
@DeleteMapping("/topics/{topicId}") | ||
public ResponseEntity<Void> deleteTopic(AuthMember authMember, @PathVariable Long topicId) { | ||
adminCommandService.deleteTopic(authMember, topicId); | ||
|
||
return ResponseEntity.noContent().build(); | ||
} | ||
|
||
@DeleteMapping("/topics/{topicId}/images") | ||
public ResponseEntity<Void> deleteTopicImage(AuthMember authMember, @PathVariable Long topicId) { | ||
adminCommandService.deleteTopicImage(authMember, topicId); | ||
|
||
return ResponseEntity.noContent().build(); | ||
} | ||
|
||
@DeleteMapping("/pins/{pinId}") | ||
public ResponseEntity<Void> deletePin(AuthMember authMember, @PathVariable Long pinId) { | ||
adminCommandService.deletePin(authMember, pinId); | ||
|
||
return ResponseEntity.noContent().build(); | ||
} | ||
|
||
@DeleteMapping("/pins/images/{imageId}") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2. deleteTopicImage와 같이 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
차라리 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전 이 부분은 쥬니가 한 게 더 자연스럽다구 생각합니다. Pins 다음에 PinId가 들어가면 의미가 더 명확해지긴 하겠지만 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 오 쥬니 새로운 의견대로 가는 게 제일 깔끔하겠는데요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아 이부분은 제가 생각을 잘못한 것 같아요!!! 쥬니가 하신대로 진행하는 것 동의합니다 ~ |
||
public ResponseEntity<Void> deletePinImage(AuthMember authMember, @PathVariable Long imageId) { | ||
adminCommandService.deletePinImage(authMember, imageId); | ||
|
||
return ResponseEntity.noContent().build(); | ||
} | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P5. 전체 사용자가 아닌 USER만 찾는 것 같은데 ADMIN은 어떤 이유에서 제외하셨는지 궁금합니당ㅋㅋ
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ADMIN이 ADMIN을 삭제할까봐...?
관리자들 사이에서의 개인정보는 지켜주고 싶어서 ..?
별 다른 이유는 없습니다 ㅎㅎ
그냥 관리자는 일반 유저들을 관리한다고 생각해서 위와 같이 구현했던 것 같아요.
모든 회원을 조회하도록 수정하는 것이 자연스럽다면, blockMember 메서드에서 삭제하려는 Member가 Admin인지 검증하는 로직이 필요하겠네요.
어떻게 생각하시나용