Skip to content
Merged
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
Copy link
Copy Markdown

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으로 분리하면:

  1. Spring Security에서 /api/admin/** 패턴으로 일괄 인가 처리 가능
  2. API Gateway 레벨에서 admin 요청 라우팅/로깅 분리 가능
  3. API 문서에서 관리자/사용자 API 구분이 명확해짐

마이크로서비스 환경에서 관리자 트래픽과 사용자 트래픽을 분리하는 것은 운영 측면에서도 유리합니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

이것은 지금 현재 pr들이 머지되면 한 번에 반영하겠습니다

public class AdminCreatorCertificationCommandController {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Critical] 관리자 인증/인가 검증 부재

이 컨트롤러는 관리자만 접근해야 하는 심사 API인데, @PreAuthorize("hasRole('ADMIN')") 또는 @Secured("ROLE_ADMIN") 같은 인가 처리가 전혀 없습니다. 일반 사용자도 이 엔드포인트를 호출하여 인증 요청을 승인/거절할 수 있는 보안 취약점입니다.

또한 @AuthenticationPrincipal도 없어서 누가 심사했는지 기록할 수 없습니다. 감사(audit) 추적을 위해 심사자 ID를 command에 포함시키는 것을 권장합니다.

@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));
    ...
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major] 관리자 인가 검증 부재

Command 컨트롤러와 동일하게, pending 목록 조회도 관리자 전용 기능이지만 인가 처리가 없습니다. @PreAuthorize("hasRole('ADMIN')") 등을 추가하거나, URL 경로를 /api/admin/creator/certification으로 분리하여 Spring Security filter chain에서 일괄 처리하는 것을 권장합니다.

현재 /api/creator/certification 경로를 사용자 컨트롤러(CreatorCertificationCommandController)와 공유하고 있어, 경로만으로는 관리자 API를 구분할 수 없습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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
Expand Up @@ -9,7 +9,6 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Optional;

@Entity
Expand Down
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;
}
}
Loading
Loading