-
Notifications
You must be signed in to change notification settings - Fork 0
feat/48 :: 크리에이터 권한 요청 승인/거절 API 구현 #13
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
Changes from all commits
76a7abe
a801906
73bfe64
2e38c73
80239bf
582ce97
2c7e47e
24a31ed
3a7986c
5db8132
e739e92
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,32 @@ | ||
| package kr.magicbox.creator.adapter.in.web; | ||
|
|
||
| import jakarta.validation.Valid; | ||
| import kr.magicbox.creator.adapter.in.web.dto.request.ReviewCertificationRequest; | ||
| import kr.magicbox.creator.application.port.in.ReviewCreatorCertificationUseCase; | ||
| import kr.magicbox.creator.domain.vo.UserId; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.security.core.annotation.AuthenticationPrincipal; | ||
| import org.springframework.web.bind.annotation.PatchMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/creator/certification") | ||
| @RequiredArgsConstructor | ||
| public class AdminCreatorCertificationCommandController { | ||
|
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. [Critical] 관리자 인증/인가 검증 부재 이 컨트롤러는 관리자만 접근해야 하는 심사 API인데, 또한 @PreAuthorize("hasRole('ADMIN')")
@PatchMapping("/{creatorCertificationId}/review")
public ResponseEntity<Void> reviewCertification(
@AuthenticationPrincipal AdminId adminId,
@PathVariable Long creatorCertificationId,
@Valid @RequestBody ReviewCertificationRequest request
) {
reviewCreatorCertificationUseCase.reviewCreatorCertification(
request.toCommand(creatorCertificationId, adminId));
...
}
Collaborator
Author
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. 관리자 인증은 Istio 딴에서 처리하려고 하고 있습니다 |
||
|
|
||
| private final ReviewCreatorCertificationUseCase reviewCreatorCertificationUseCase; | ||
|
|
||
| @PatchMapping("/{creatorCertificationId}/review") | ||
| public ResponseEntity<Void> reviewCertification( | ||
| @AuthenticationPrincipal UserId reviewerId, | ||
| @PathVariable Long creatorCertificationId, | ||
| @Valid @RequestBody ReviewCertificationRequest request | ||
| ) { | ||
| reviewCreatorCertificationUseCase.reviewCreatorCertification(request.toCommand(reviewerId, creatorCertificationId)); | ||
| return ResponseEntity.noContent().build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package kr.magicbox.creator.adapter.in.web; | ||
|
|
||
| import kr.magicbox.creator.adapter.in.web.constants.CursorConstants; | ||
| import kr.magicbox.creator.adapter.in.web.dto.response.CursorResponse; | ||
| import kr.magicbox.creator.adapter.in.web.dto.response.PendingCertificationResponse; | ||
| import kr.magicbox.creator.adapter.in.web.validation.CursorSize; | ||
| import kr.magicbox.creator.application.dto.query.GetAllPendingCertificationsQuery; | ||
| import kr.magicbox.creator.application.port.in.GetAllPendingCreatorCertificationsUseCase; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.validation.annotation.Validated; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/creator/certification") | ||
| @RequiredArgsConstructor | ||
| @Validated | ||
|
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. [Major] 관리자 인가 검증 부재 Command 컨트롤러와 동일하게, pending 목록 조회도 관리자 전용 기능이지만 인가 처리가 없습니다. 현재
Collaborator
Author
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. 관리자 인증은 Istio 딴에서 처리하려고 하고 있습니다 |
||
| public class AdminCreatorCertificationQueryController { | ||
|
|
||
| private final GetAllPendingCreatorCertificationsUseCase getAllPendingCreatorCertificationsUseCase; | ||
|
|
||
| @GetMapping("/pending") | ||
| public ResponseEntity<CursorResponse<PendingCertificationResponse>> getPendingCertifications( | ||
| @RequestParam(required = false) Long cursor, | ||
| @RequestParam(defaultValue = CursorConstants.DEFAULT_SIZE) @CursorSize Integer size | ||
| ) { | ||
| List<PendingCertificationResponse> content = | ||
| getAllPendingCreatorCertificationsUseCase | ||
| .getAllPendingCreatorCertifications(GetAllPendingCertificationsQuery.of(cursor, size)) | ||
| .stream() | ||
| .map(PendingCertificationResponse::from) | ||
| .toList(); | ||
|
|
||
| return ResponseEntity.ok(CursorResponse.of(content, size, PendingCertificationResponse::certificationId)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package kr.magicbox.creator.adapter.in.web.dto.request; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import kr.magicbox.creator.application.dto.command.ReviewCertificationCommand; | ||
| import kr.magicbox.creator.domain.vo.CreatorCertificationId; | ||
| import kr.magicbox.creator.domain.vo.UserId; | ||
| import lombok.Builder; | ||
|
|
||
| @Builder | ||
| public record ReviewCertificationRequest( | ||
| @NotNull(message = "심사자는 필수입니다.") UserId reviewerId, | ||
| @NotNull(message = "심사 결정은 필수입니다.") ReviewDecisionRequest decision, | ||
| @NotBlank(message = "심사 메시지는 필수입니다.") String reviewMessage | ||
| ) { | ||
|
|
||
| public ReviewCertificationCommand toCommand(UserId reviewerId, Long creatorCertificationId) { | ||
| return new ReviewCertificationCommand( | ||
| reviewerId, | ||
| CreatorCertificationId.of(creatorCertificationId), | ||
| decision.toCommand(), | ||
| reviewMessage | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package kr.magicbox.creator.adapter.in.web.dto.request; | ||
|
|
||
| import kr.magicbox.creator.application.dto.command.ReviewDecisionCommand; | ||
|
|
||
| public enum ReviewDecisionRequest { | ||
| APPROVED, REJECTED; | ||
|
|
||
| public ReviewDecisionCommand toCommand() { | ||
| return ReviewDecisionCommand.valueOf(this.name()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package kr.magicbox.creator.adapter.in.web.dto.response; | ||
|
|
||
| import kr.magicbox.creator.application.dto.result.PendingCertificationResult; | ||
| import kr.magicbox.creator.domain.enums.MagicGenre; | ||
| import lombok.Builder; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.Set; | ||
|
|
||
| @Builder | ||
| public record PendingCertificationResponse( | ||
| Long certificationId, | ||
| Long userId, | ||
| Set<MagicGenre> genres, | ||
| String portfolioUrl, | ||
| Instant requestedAt | ||
| ) { | ||
|
|
||
| public static PendingCertificationResponse from(PendingCertificationResult result) { | ||
| return PendingCertificationResponse.builder() | ||
| .certificationId(result.certificationId().value()) | ||
| .userId(result.userId().value()) | ||
| .genres(result.genres()) | ||
| .portfolioUrl(result.portfolioUrl()) | ||
| .requestedAt(result.requestedAt()) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package kr.magicbox.creator.adapter.out.communication.grpc; | ||
|
|
||
| import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; | ||
| import io.grpc.ManagedChannel; | ||
| import kr.magicbox.creator.adapter.out.communication.ServiceHost; | ||
| import kr.magicbox.creator.adapter.out.communication.grpc.exception.UserServiceUnavailableException; | ||
| import kr.magicbox.creator.application.port.out.UserNicknameQueryPort; | ||
| import kr.magicbox.creator.domain.vo.UserId; | ||
| import kr.magicbox.creator.grpc.user.GetUserNicknameRequest; | ||
| import kr.magicbox.creator.grpc.user.GetUserNicknameResponse; | ||
| import kr.magicbox.creator.grpc.user.UserServiceGrpc; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.grpc.client.GrpcChannelFactory; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class UserNicknameQueryGrpcAdapter implements UserNicknameQueryPort { | ||
| private final GrpcChannelFactory grpcChannelFactory; | ||
|
|
||
| @Override | ||
| @CircuitBreaker(name = "userService", fallbackMethod = "getNicknameFallback") | ||
| public String getNickname(UserId userId) { | ||
| GetUserNicknameRequest request = GetUserNicknameRequest.newBuilder() | ||
| .setUserId(userId.value()) | ||
| .build(); | ||
|
|
||
| ManagedChannel channel = grpcChannelFactory.createChannel(ServiceHost.USER.getHostName()); | ||
| UserServiceGrpc.UserServiceBlockingStub stub = UserServiceGrpc.newBlockingStub(channel); | ||
| GetUserNicknameResponse response = stub.getUserNickname(request); | ||
|
|
||
| return response.getNickname(); | ||
| } | ||
|
|
||
| @SuppressWarnings("unused") | ||
| private String getNicknameFallback(UserId userId, Throwable throwable) { | ||
| log.warn("유저 서비스 연결 실패"); | ||
| throw new UserServiceUnavailableException(throwable); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package kr.magicbox.creator.adapter.out.communication.grpc.exception; | ||
|
|
||
| import kr.magicbox.creator.global.exception.SystemError; | ||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| @SuppressWarnings("java:S110") | ||
| public class UserServiceUnavailableException extends SystemError { | ||
| public UserServiceUnavailableException(Throwable cause) { | ||
| super("유저 서비스 호출을 할 수 없습니다.", HttpStatus.SERVICE_UNAVAILABLE, cause); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package kr.magicbox.creator.adapter.out.persistence; | ||
|
|
||
| import kr.magicbox.creator.adapter.out.persistence.entity.CreatorDomainEventEntity; | ||
| import kr.magicbox.creator.adapter.out.persistence.repository.CreatorDomainEventRepository; | ||
| import kr.magicbox.creator.application.port.out.CreatorDomainEventRepositoryPort; | ||
| import kr.magicbox.creator.domain.event.CreatorDomainEvent; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Repository; | ||
| import tools.jackson.databind.ObjectMapper; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class CreatorDomainEventAdapter implements CreatorDomainEventRepositoryPort { | ||
|
|
||
| private final CreatorDomainEventRepository creatorDomainEventRepository; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @Override | ||
| public void save(CreatorDomainEvent event) { | ||
| String payload = objectMapper.writeValueAsString(event); | ||
| creatorDomainEventRepository.save(CreatorDomainEventEntity.builder() | ||
| .eventType(event.eventType().getValue()) | ||
| .key(event.key()) | ||
| .payload(payload) | ||
| .build()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| package kr.magicbox.creator.adapter.out.persistence; | ||
|
|
||
| import kr.magicbox.creator.adapter.out.persistence.entity.CreatorEntity; | ||
| import kr.magicbox.creator.adapter.out.persistence.mapper.CreatorMapper; | ||
| import kr.magicbox.creator.adapter.out.persistence.repository.CreatorJpaRepository; | ||
| import kr.magicbox.creator.application.port.out.CreatorRepositoryPort; | ||
| import kr.magicbox.creator.domain.aggregate.Creator; | ||
| import kr.magicbox.creator.domain.vo.Nickname; | ||
| import kr.magicbox.creator.domain.vo.UserId; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class CreatorJpaAdapter implements CreatorRepositoryPort { | ||
|
|
||
| private final CreatorJpaRepository creatorJpaRepository; | ||
| private final CreatorMapper creatorMapper; | ||
|
|
||
| @Override | ||
| public void save(Creator creator) { | ||
| CreatorEntity entity = creatorMapper.toEntity(creator); | ||
| creatorJpaRepository.save(entity); | ||
| } | ||
|
|
||
| @Override | ||
| public void update(Creator creator) { | ||
| creatorJpaRepository.findById(creator.getId().value()) | ||
| .ifPresent(entity -> { | ||
| creatorMapper.updateEntity(creator, entity); | ||
| creatorJpaRepository.save(entity); | ||
| }); | ||
| } | ||
|
|
||
| @Override | ||
| public boolean existsByUserId(UserId userId) { | ||
| return creatorJpaRepository.existsByUserId(userId.value()); | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<Creator> findByUserId(UserId userId) { | ||
| return creatorJpaRepository.findByUserId(userId.value()) | ||
| .map(creatorMapper::toDomain); | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<Creator> findByUserIdWithLock(UserId userId) { | ||
| return creatorJpaRepository.findByUserIdWithLock(userId.value()) | ||
| .map(creatorMapper::toDomain); | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<Creator> findByNickname(Nickname nickname) { | ||
| return creatorJpaRepository.findByNickname(nickname.value()) | ||
| .map(creatorMapper::toDomain); | ||
| } | ||
|
|
||
| @Override | ||
| public Optional<Creator> findByNicknameWithLock(Nickname nickname) { | ||
| return creatorJpaRepository.findByNicknameWithLock(nickname.value()) | ||
| .map(creatorMapper::toDomain); | ||
| } | ||
|
|
||
| @Override | ||
| public List<Creator> findAllByCursor(Long cursorId, int size) { | ||
| return creatorJpaRepository.findAllByCursor(cursorId, PageRequest.of(0, size)) | ||
| .stream() | ||
| .map(creatorMapper::toDomain) | ||
| .toList(); | ||
| } | ||
|
|
||
| @Override | ||
| public List<Creator> searchByNickname(String keyword, Long cursorId, int size) { | ||
| return creatorJpaRepository.searchByNickname(keyword, cursorId, PageRequest.of(0, size)) | ||
| .stream() | ||
| .map(creatorMapper::toDomain) | ||
| .toList(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package kr.magicbox.creator.adapter.out.persistence.entity; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Entity | ||
| @Table(name = "creator_domain_event") | ||
| public class CreatorDomainEventEntity extends BaseEntity { | ||
|
|
||
| @Column(nullable = false) | ||
| private String eventType; | ||
|
|
||
| @Column(name = "`key`", nullable = false) | ||
| private String key; | ||
|
|
||
| @Column(nullable = false, columnDefinition = "JSON") | ||
| private String payload; | ||
|
|
||
| @Builder | ||
| public CreatorDomainEventEntity(String eventType, String key, String payload) { | ||
| this.eventType = eventType; | ||
| this.key = key; | ||
| this.payload = payload; | ||
| } | ||
| } |
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.
[Suggestion] Admin API 경로 분리 제안
현재 Admin 컨트롤러와 사용자 컨트롤러가 동일한 base path(
/api/creator/certification)를 사용합니다. Admin API를/api/admin/creator/certification으로 분리하면:/api/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.
이것은 지금 현재 pr들이 머지되면 한 번에 반영하겠습니다