From 1bca1e4bed13847fe0ca3309e4f6fe453703c2d6 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 23 Jan 2026 14:26:06 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[SRLT-133]=20Feat:=20=EC=A0=84=EB=AC=B8?= =?UTF-8?q?=EA=B0=80=20=ED=99=9C=EC=84=B1=EC=83=81=ED=83=9C=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/domain/expert/entity/Expert.java | 5 +++++ .../expert/enumerate/ExpertActiveStatus.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index f1ce0af0..8ef04d34 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -5,6 +5,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.enumerate.TagCategory; import starlight.shared.AbstractEntity; @@ -36,6 +37,10 @@ public class Expert extends AbstractEntity { @Column private String detailedIntroduction; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ExpertActiveStatus activeStatus = ExpertActiveStatus.ACTIVE; + @Min(0) @Column private Integer mentoringPriceWon; diff --git a/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java b/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java new file mode 100644 index 00000000..198209ff --- /dev/null +++ b/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java @@ -0,0 +1,14 @@ +package starlight.domain.expert.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ExpertActiveStatus { + + ACTIVE("활동중"), + INACTIVE("비활동중"); + + private final String description; +} From 87228f719d6c762d92913edecedbc2bc99ac83f2 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 23 Jan 2026 17:00:52 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[SRLT-133]=20Feat:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=EC=9A=A9=20=EC=A0=84=EB=AC=B8=EA=B0=80?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java --- .../webapi/BackofficeExpertController.java | 26 ++++++++ .../BackofficeExpertListResponse.java | 59 +++++++++++++++++++ .../mail/webapi/BackofficeMailController.java | 2 + .../adapter/expert/persistence/ExpertJpa.java | 1 + .../expert/webapi/ExpertController.java | 2 +- .../persistence/ExpertApplicationJpa.java | 3 +- .../expert/BackofficeExpertQueryService.java | 37 ++++++++++++ .../BackofficeExpertQueryUseCase.java | 10 ++++ .../result/BackofficeExpertDetailResult.java | 55 +++++++++++++++++ ...fficeExpertApplicationCountLookupPort.java | 9 +++ .../required/BackofficeExpertQueryPort.java | 10 ++++ .../expert/ExpertDetailQueryService.java | 16 ++++- .../provided/ExpertDetailQueryUseCase.java | 2 +- .../starlight/bootstrap/SecurityConfig.java | 2 +- .../starlight/bootstrap/SwaggerConfig.java | 15 ++--- .../expert/webapi/ExpertControllerTest.java | 2 +- 16 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java create mode 100644 src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java create mode 100644 src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java create mode 100644 src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java new file mode 100644 index 00000000..9ea121df --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java @@ -0,0 +1,26 @@ +package starlight.adapter.backoffice.expert.webapi; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +@RequestMapping("/v1/backoffice/experts") +public class BackofficeExpertController { + + private final BackofficeExpertQueryUseCase backofficeExpertQuery; + + @GetMapping + public ApiResponse> searchAll() { + return ApiResponse.success(BackofficeExpertListResponse.fromAll(backofficeExpertQuery.searchAll())); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java new file mode 100644 index 00000000..b9e3a18e --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java @@ -0,0 +1,59 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.util.List; + +public record BackofficeExpertListResponse( + Long id, + String name, + String oneLineIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + String activeStatus, + List careers, + List tags, + List categories +) { + private static final int MAX_CAREERS = 3; + + public static BackofficeExpertListResponse from(BackofficeExpertDetailResult result) { + List careers = result.careers().stream() + .limit(MAX_CAREERS) + .map(BackofficeExpertCareerSummaryResponse::from) + .toList(); + + return new BackofficeExpertListResponse( + result.id(), + result.name(), + result.oneLineIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.activeStatus().name(), + careers, + result.tags(), + result.categories() + ); + } + + public static List fromAll(List results) { + return results.stream() + .map(BackofficeExpertListResponse::from) + .toList(); + } + + public record BackofficeExpertCareerSummaryResponse( + Integer orderIndex, + String careerTitle + ) { + public static BackofficeExpertCareerSummaryResponse from(ExpertCareerResult result) { + return new BackofficeExpertCareerSummaryResponse( + result.orderIndex(), + result.careerTitle() + ); + } + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java index f6492534..fc4deeea 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springframework.web.bind.annotation.*; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; @@ -14,6 +15,7 @@ @RestController @RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") public class BackofficeMailController { private final BackofficeMailSendUseCase backofficeMailSendUseCase; diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index 92501813..deae9a0d 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -18,6 +18,7 @@ @Component @RequiredArgsConstructor public class ExpertJpa implements ExpertQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertQueryPort, starlight.application.expertReport.required.ExpertLookupPort, starlight.application.expertApplication.required.ExpertLookupPort { diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java index ce98ccd8..c92942a8 100644 --- a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java +++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java @@ -27,7 +27,7 @@ public class ExpertController implements ExpertApiDoc { @GetMapping public ApiResponse> search() { - return ApiResponse.success(ExpertListResponse.fromAll(expertDetailQuery.searchAll())); + return ApiResponse.success(ExpertListResponse.fromAll(expertDetailQuery.searchAllActive())); } @GetMapping("/{expertId}") diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java index b8a4ef3c..0f974f1a 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -16,7 +16,8 @@ @Slf4j @Component @RequiredArgsConstructor -public class ExpertApplicationJpa implements ExpertApplicationQueryPort, +public class ExpertApplicationJpaPort implements ExpertApplicationQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort, starlight.application.expert.required.ExpertApplicationCountLookupPort, starlight.application.expertReport.required.ExpertApplicationCountLookupPort { diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java new file mode 100644 index 00000000..9d7f6403 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java @@ -0,0 +1,37 @@ +package starlight.application.backoffice.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; +import starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort; +import starlight.application.backoffice.expert.required.BackofficeExpertQueryPort; +import starlight.domain.expert.entity.Expert; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BackofficeExpertQueryService implements BackofficeExpertQueryUseCase { + + private final BackofficeExpertQueryPort expertQueryPort; + private final BackofficeExpertApplicationCountLookupPort expertApplicationLookupPort; + + @Override + public List searchAll() { + List experts = expertQueryPort.findAllWithCareersTagsCategories(); + + List expertIds = experts.stream() + .map(Expert::getId) + .toList(); + + Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); + + return experts.stream() + .map(expert -> BackofficeExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) + .toList(); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java new file mode 100644 index 00000000..97b4a7cf --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.provided; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; + +import java.util.List; + +public interface BackofficeExpertQueryUseCase { + + List searchAll(); +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java new file mode 100644 index 00000000..ae1cb972 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java @@ -0,0 +1,55 @@ +package starlight.application.backoffice.expert.provided.dto.result; + +import starlight.application.expert.provided.dto.ExpertCareerResult; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.ExpertActiveStatus; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertDetailResult( + Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + String detailedIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + Integer mentoringPriceWon, + ExpertActiveStatus activeStatus, + List careers, + List tags, + List categories +) { + public static BackofficeExpertDetailResult from(Expert expert, long applicationCount) { + List careers = expert.getCareers().stream() + .map(ExpertCareerResult::from) + .toList(); + + List categories = expert.getCategories().stream() + .map(TagCategory::name) + .distinct() + .toList(); + + List tags = expert.getTags().stream() + .distinct() + .toList(); + + return new BackofficeExpertDetailResult( + expert.getId(), + applicationCount, + expert.getName(), + expert.getOneLineIntroduction(), + expert.getDetailedIntroduction(), + expert.getProfileImageUrl(), + expert.getWorkedPeriod(), + expert.getEmail(), + expert.getMentoringPriceWon(), + expert.getActiveStatus(), + careers, + tags, + categories + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java new file mode 100644 index 00000000..3bfd7304 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.expert.required; + +import java.util.List; +import java.util.Map; + +public interface BackofficeExpertApplicationCountLookupPort { + + Map countByExpertIds(List expertIds); +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java new file mode 100644 index 00000000..381519b6 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.required; + +import starlight.domain.expert.entity.Expert; + +import java.util.List; + +public interface BackofficeExpertQueryPort { + + List findAllWithCareersTagsCategories(); +} diff --git a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java index 88edceaa..1625e4d5 100644 --- a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java +++ b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java @@ -7,7 +7,10 @@ import starlight.application.expert.provided.dto.ExpertDetailResult; import starlight.application.expert.required.ExpertApplicationCountLookupPort; import starlight.application.expert.required.ExpertQueryPort; +import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; import java.util.List; import java.util.Map; @@ -21,16 +24,20 @@ public class ExpertDetailQueryService implements ExpertDetailQueryUseCase { private final ExpertApplicationCountLookupPort expertApplicationLookupPort; @Override - public List searchAll() { + public List searchAllActive() { List experts = expertQueryPort.findAllWithCareersTagsCategories(); - List expertIds = experts.stream() + List activeExperts = experts.stream() + .filter(expert -> expert.getActiveStatus() == ExpertActiveStatus.ACTIVE) + .toList(); + + List expertIds = activeExperts.stream() .map(Expert::getId) .toList(); Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); - return experts.stream() + return activeExperts.stream() .map(expert -> ExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) .toList(); } @@ -38,6 +45,9 @@ public List searchAll() { @Override public ExpertDetailResult findById(Long expertId) { Expert expert = expertQueryPort.findByIdWithCareersAndTags(expertId); + if (expert.getActiveStatus() != ExpertActiveStatus.ACTIVE) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } Map countMap = expertApplicationLookupPort.countByExpertIds(List.of(expertId)); long count = countMap.getOrDefault(expertId, 0L); return ExpertDetailResult.from(expert, count); diff --git a/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java index 705b16ee..53c13d4c 100644 --- a/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java +++ b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java @@ -6,7 +6,7 @@ public interface ExpertDetailQueryUseCase { - List searchAll(); + List searchAllActive(); ExpertDetailResult findById(Long expertId); } diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 07f42329..055e7ada 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -76,7 +76,7 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep ); } - http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") + http.securityMatcher("/v1/backoffice/**", "/login", "/logout") .cors(Customizer.withDefaults()) .csrf((csrf) -> csrf .csrfTokenRepository(csrfTokenRepository) diff --git a/src/main/java/starlight/bootstrap/SwaggerConfig.java b/src/main/java/starlight/bootstrap/SwaggerConfig.java index df6ebb17..879c6f2e 100644 --- a/src/main/java/starlight/bootstrap/SwaggerConfig.java +++ b/src/main/java/starlight/bootstrap/SwaggerConfig.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; @@ -10,8 +9,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.util.Collections; - @OpenAPIDefinition( info = @Info(title = "StarLight 명세서", description = "StarLight API 명세서", version = "v1" ), @@ -31,11 +28,15 @@ public OpenAPI openAPI() { .bearerFormat("JWT") .in(SecurityScheme.In.HEADER) .name("Authorization"); - io.swagger.v3.oas.models.security.SecurityRequirement securityRequirement = - new io.swagger.v3.oas.models.security.SecurityRequirement().addList("bearerAuth"); + SecurityScheme backofficeSessionScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.COOKIE) + .name("JSESSIONID"); return new OpenAPI() - .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) - .security(Collections.singletonList(securityRequirement)); + .components(new Components() + .addSecuritySchemes("bearerAuth", securityScheme) + .addSecuritySchemes("backofficeSession", backofficeSessionScheme) + ); } } diff --git a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java index a4c7a490..42d69a89 100644 --- a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java +++ b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java @@ -64,7 +64,7 @@ class ExpertControllerTest { void listAll() throws Exception { ExpertDetailResult e1 = expertResult(1L, "홍길동", Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY)); - when(expertDetailQuery.searchAll()).thenReturn(List.of(e1)); + when(expertDetailQuery.searchAllActive()).thenReturn(List.of(e1)); mockMvc.perform(get("/v1/experts")) .andExpect(status().isOk()) From bb5dc46d80db5ef91bfa6ced17ddf3440a59f970 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 14:43:54 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[SRLT-133]=20Feat:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20=EC=A0=84=EB=AC=B8=EA=B0=80/=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80/=EB=A9=94=EC=9D=BC=20=EA=B4=80=EB=A0=A8=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전문가 관리 API: 생성, 조회, 수정, 삭제, 활성 상태 변경 및 Swagger 문서 추가 - 이미지 업로드 API: Presigned URL 발급, 이미지 공개 전환 요청 및 Swagger 문서 추가 - 메일 관리 API: 템플릿 생성, 조회, 발송 및 Swagger 문서 추가 - DTO, validation 어노테이션, 요청/응답 매핑 구현 - API 문서화 및 요청-응답 형식 정의 --- .../storage/NcpPresignedUrlProvider.java | 2 + .../webapi/BackofficeExpertController.java | 82 +++++- ...officeExpertActiveStatusUpdateRequest.java | 8 + .../BackofficeExpertCareerUpdateRequest.java | 25 ++ .../BackofficeExpertCreateRequest.java | 26 ++ ...officeExpertProfileImageUpdateRequest.java | 7 + .../BackofficeExpertUpdateRequest.java | 50 ++++ .../BackofficeExpertCareerResponse.java | 25 ++ .../BackofficeExpertCreateResponse.java | 11 + .../BackofficeExpertDetailResponse.java | 43 +++ .../swagger/BackofficeExpertApiDoc.java | 269 ++++++++++++++++++ .../webapi/BackofficeImageController.java | 45 +++ .../request/BackofficeImagePublicRequest.java | 7 + .../webapi/swagger/BackofficeImageApiDoc.java | 86 ++++++ .../webapi/validation/ValidImageFileName.java | 23 ++ .../ValidImageFileNameValidator.java | 27 ++ .../mail/webapi/BackofficeMailController.java | 5 +- .../webapi/swagger/BackofficeMailApiDoc.java | 135 +++++++++ .../adapter/expert/persistence/ExpertJpa.java | 27 ++ .../BackofficeExpertCommandService.java | 99 +++++++ .../expert/BackofficeExpertQueryService.java | 17 +- .../BackofficeExpertCommandUseCase.java | 20 ++ .../BackofficeExpertQueryUseCase.java | 2 + ...ckofficeExpertActiveStatusUpdateInput.java | 12 + .../BackofficeExpertCareerUpdateInput.java | 13 + .../input/BackofficeExpertCreateInput.java | 23 ++ ...ckofficeExpertProfileImageUpdateInput.java | 10 + .../input/BackofficeExpertUpdateInput.java | 44 +++ .../result/BackofficeExpertCreateResult.java | 9 + .../required/BackofficeExpertCommandPort.java | 10 + .../required/BackofficeExpertQueryPort.java | 4 + .../domain/expert/dto/ExpertCareerUpdate.java | 12 + .../domain/expert/entity/Expert.java | 156 ++++++++++ .../domain/expert/entity/ExpertCareer.java | 3 +- .../expert/exception/ExpertErrorType.java | 5 +- ...34\352\260\200\354\235\264\353\223\234.md" | 16 ++ 36 files changed, 1350 insertions(+), 8 deletions(-) create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java create mode 100644 src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java create mode 100644 src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java create mode 100644 src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java index 59ffb381..188211a7 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java @@ -79,12 +79,14 @@ public String makePublic(String objectUrl) { .key(key) .acl(ObjectCannedACL.PUBLIC_READ) .build(); + ncpS3Client.putObjectAcl(aclRequest); log.info("객체 공개 처리 완료(PUBLIC_READ): key={}", objectUrl); } catch (S3Exception e) { log.error("객체 공개 처리 실패 - Message: {}", e.getMessage()); throw new RuntimeException("객체 공개 처리 실패: " + e.getMessage(), e); } + return objectUrl; } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java index 9ea121df..da233e15 100644 --- a/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java @@ -1,12 +1,28 @@ package starlight.adapter.backoffice.expert.webapi; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertActiveStatusUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertCreateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertProfileImageUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertCreateResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertDetailResponse; import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.adapter.backoffice.expert.webapi.swagger.BackofficeExpertApiDoc; +import starlight.application.backoffice.expert.provided.BackofficeExpertCommandUseCase; import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; import starlight.shared.apiPayload.response.ApiResponse; import java.util.List; @@ -15,12 +31,74 @@ @RequiredArgsConstructor @SecurityRequirement(name = "backofficeSession") @RequestMapping("/v1/backoffice/experts") -public class BackofficeExpertController { +public class BackofficeExpertController implements BackofficeExpertApiDoc { private final BackofficeExpertQueryUseCase backofficeExpertQuery; + private final BackofficeExpertCommandUseCase backofficeExpertCommand; @GetMapping public ApiResponse> searchAll() { - return ApiResponse.success(BackofficeExpertListResponse.fromAll(backofficeExpertQuery.searchAll())); + return ApiResponse.success(BackofficeExpertListResponse.fromAll( + backofficeExpertQuery.searchAll() + )); } + + @PostMapping + public ApiResponse create( + @Valid @RequestBody BackofficeExpertCreateRequest request + ) { + return ApiResponse.success(BackofficeExpertCreateResponse.from( + backofficeExpertCommand.createExpert(request.toInput()) + )); + } + + @GetMapping("/{expertId}") + public ApiResponse detail( + @PathVariable Long expertId + ) { + return ApiResponse.success(BackofficeExpertDetailResponse.from( + backofficeExpertQuery.findById(expertId) + )); + } + + @PatchMapping("/{expertId}/active-status") + public ApiResponse updateActiveStatus( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertActiveStatusUpdateRequest request + ) { + backofficeExpertCommand.updateActiveStatus( + BackofficeExpertActiveStatusUpdateInput.of(expertId, request.activeStatus()) + ); + + return ApiResponse.success(); + } + + @PatchMapping("/{expertId}") + public ApiResponse update( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertUpdateRequest request + ) { + backofficeExpertCommand.updateExpert(request.toInput(expertId)); + return ApiResponse.success(); + } + + @DeleteMapping("/{expertId}") + public ApiResponse delete( + @PathVariable Long expertId + ) { + backofficeExpertCommand.deleteExpert(expertId); + return ApiResponse.success(); + } + + @PatchMapping("/{expertId}/profile-image") + public ApiResponse updateProfileImage( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertProfileImageUpdateRequest request + ) { + backofficeExpertCommand.updateProfileImage( + BackofficeExpertProfileImageUpdateInput.of(expertId, request.profileImageUrl()) + ); + return ApiResponse.success(); + } + } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java new file mode 100644 index 00000000..87518508 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java @@ -0,0 +1,8 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.NotNull; +import starlight.domain.expert.enumerate.ExpertActiveStatus; + +public record BackofficeExpertActiveStatusUpdateRequest( + @NotNull ExpertActiveStatus activeStatus +) { } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java new file mode 100644 index 00000000..15d1c113 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java @@ -0,0 +1,25 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerUpdateRequest( + Long id, + @NotNull @Min(0) Integer orderIndex, + @NotBlank String careerTitle, + String careerExplanation, + @NotNull LocalDateTime careerStartedAt, + @NotNull LocalDateTime careerEndedAt +) { + @AssertTrue(message = "경력 시작일은 종료일보다 늦을 수 없습니다.") + public boolean isValidPeriod() { + if (careerStartedAt == null || careerEndedAt == null) { + return true; + } + return !careerStartedAt.isAfter(careerEndedAt); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java new file mode 100644 index 00000000..90619cfe --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java @@ -0,0 +1,26 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertCreateRequest( + @NotBlank String name, + @Email @NotBlank String email, + String oneLineIntroduction, + List tags, + List categories +) { + public BackofficeExpertCreateInput toInput() { + return BackofficeExpertCreateInput.of( + name, + email, + oneLineIntroduction, + tags, + categories + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java new file mode 100644 index 00000000..5cfdce32 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record BackofficeExpertProfileImageUpdateRequest( + @NotBlank String profileImageUrl +) { } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java new file mode 100644 index 00000000..740952f0 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java @@ -0,0 +1,50 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCareerUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertUpdateRequest( + @NotBlank String name, + @Email @NotBlank String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + @Valid List careers +) { + public BackofficeExpertUpdateInput toInput(Long expertId) { + List careerInputs = careers == null + ? List.of() + : careers.stream() + .map(career -> new BackofficeExpertCareerUpdateInput( + career.id(), + career.orderIndex(), + career.careerTitle(), + career.careerExplanation(), + career.careerStartedAt(), + career.careerEndedAt() + )) + .toList(); + + return BackofficeExpertUpdateInput.of( + expertId, + name, + email, + oneLineIntroduction, + detailedIntroduction, + workedPeriod, + mentoringPriceWon, + tags, + categories, + careerInputs + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java new file mode 100644 index 00000000..3086dafe --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java @@ -0,0 +1,25 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerResponse( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { + public static BackofficeExpertCareerResponse from(ExpertCareerResult result) { + return new BackofficeExpertCareerResponse( + result.id(), + result.orderIndex(), + result.careerTitle(), + result.careerExplanation(), + result.careerStartedAt(), + result.careerEndedAt() + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java new file mode 100644 index 00000000..b507bb51 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java @@ -0,0 +1,11 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; + +public record BackofficeExpertCreateResponse( + Long id +) { + public static BackofficeExpertCreateResponse from(BackofficeExpertCreateResult result) { + return new BackofficeExpertCreateResponse(result.id()); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java new file mode 100644 index 00000000..620ca93a --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java @@ -0,0 +1,43 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; + +import java.util.List; + +public record BackofficeExpertDetailResponse( + Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + String detailedIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + Integer mentoringPriceWon, + String activeStatus, + List careers, + List tags, + List categories +) { + public static BackofficeExpertDetailResponse from(BackofficeExpertDetailResult result) { + List careers = result.careers().stream() + .map(BackofficeExpertCareerResponse::from) + .toList(); + + return new BackofficeExpertDetailResponse( + result.id(), + result.applicationCount(), + result.name(), + result.oneLineIntroduction(), + result.detailedIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.mentoringPriceWon(), + result.activeStatus().name(), + careers, + result.tags(), + result.categories() + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java new file mode 100644 index 00000000..136e93dd --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java @@ -0,0 +1,269 @@ +package starlight.adapter.backoffice.expert.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertActiveStatusUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertCreateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertProfileImageUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertCreateResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertDetailResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "[Office] 전문가", description = "백오피스 전문가 관리 API") +public interface BackofficeExpertApiDoc { + + @Operation( + summary = "전문가 목록 조회(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = BackofficeExpertListResponse.class)) + ) + ) + }) + @GetMapping + ApiResponse> searchAll(); + + @Operation( + summary = "전문가 생성(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeExpertCreateResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping + ApiResponse create( + @RequestBody BackofficeExpertCreateRequest request + ); + + @Operation( + summary = "전문가 상세 조회(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeExpertDetailResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = { + @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + ), + @ExampleObject( + name = "조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) + ) + }) + @GetMapping("/{expertId}") + ApiResponse detail( + @PathVariable Long expertId + ); + + @Operation( + summary = "전문가 상세 수정(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = { + @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + ), + @ExampleObject( + name = "조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) + ) + }) + @PatchMapping("/{expertId}") + ApiResponse update( + @PathVariable Long expertId, + @RequestBody BackofficeExpertUpdateRequest request + ); + + @Operation( + summary = "전문가 활성 상태 변경(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ) + }) + @PatchMapping("/{expertId}/active-status") + ApiResponse updateActiveStatus( + @PathVariable Long expertId, + @RequestBody BackofficeExpertActiveStatusUpdateRequest request + ); + + @Operation( + summary = "전문가 프로필 이미지 변경(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PatchMapping("/{expertId}/profile-image") + ApiResponse updateProfileImage( + @PathVariable Long expertId, + @RequestBody BackofficeExpertProfileImageUpdateRequest request + ); + + @Operation( + summary = "전문가 삭제(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ) + }) + @DeleteMapping("/{expertId}") + ApiResponse delete( + @PathVariable Long expertId + ); +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java new file mode 100644 index 00000000..06216431 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -0,0 +1,45 @@ +package starlight.adapter.backoffice.image.webapi; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; +import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; +import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; +import starlight.application.aireport.required.PresignedUrlProvider; +import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +@Validated +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +@RequestMapping("/v1/backoffice/images") +public class BackofficeImageController implements BackofficeImageApiDoc { + + private static final long BACKOFFICE_USER_ID = 0L; + + private final PresignedUrlProvider presignedUrlProvider; + + @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse getPresignedUrl( + @RequestParam @ValidImageFileName String fileName + ) { + return ApiResponse.success(presignedUrlProvider.getPreSignedUrl(BACKOFFICE_USER_ID, fileName)); + } + + @PostMapping(value = "/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse finalizePublic( + @Valid @RequestBody BackofficeImagePublicRequest request + ) { + return ApiResponse.success(presignedUrlProvider.makePublic(request.objectUrl())); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java b/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java new file mode 100644 index 00000000..fb96bf5e --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.image.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record BackofficeImagePublicRequest( + @NotBlank String objectUrl +) { } diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java new file mode 100644 index 00000000..68f6d473 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -0,0 +1,86 @@ +package starlight.adapter.backoffice.image.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; +import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +@Tag(name = "[Office] 이미지", description = "백오피스 이미지 업로드 API") +public interface BackofficeImageApiDoc { + + @Operation( + summary = "Presigned URL 발급(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + schema = @Schema(implementation = PreSignedUrlResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "fileName 검증 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "fileName이 올바르지 않습니다." + } + } + """ + )) + ) + }) + @GetMapping(value = "/v1/backoffice/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + ApiResponse getPresignedUrl( + @RequestParam String fileName + ); + + @Operation( + summary = "이미지 공개 전환(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping(value = "/v1/backoffice/images/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) + ApiResponse finalizePublic( + @RequestBody BackofficeImagePublicRequest request + ); +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java new file mode 100644 index 00000000..9727ada9 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java @@ -0,0 +1,23 @@ +package starlight.adapter.backoffice.image.webapi.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = ValidImageFileNameValidator.class) +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidImageFileName { + + String message() default "fileName이 올바르지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java new file mode 100644 index 00000000..a43ef885 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java @@ -0,0 +1,27 @@ +package starlight.adapter.backoffice.image.webapi.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.util.StringUtils; + +import java.util.regex.Pattern; + +public class ValidImageFileNameValidator implements ConstraintValidator { + + private static final Pattern FILE_NAME_PATTERN = + Pattern.compile("^[A-Za-z0-9._-]+\\.(png|jpg|jpeg|webp)$", Pattern.CASE_INSENSITIVE); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + + if (!StringUtils.hasText(value)) { + return false; + } + + if (value.contains("/") || value.contains("\\")) { + return false; + } + + return FILE_NAME_PATTERN.matcher(value).matches(); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java index fc4deeea..e017521b 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -1,12 +1,13 @@ package starlight.adapter.backoffice.mail.webapi; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springframework.web.bind.annotation.*; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; +import starlight.adapter.backoffice.mail.webapi.swagger.BackofficeMailApiDoc; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; import starlight.application.backoffice.mail.provided.BackofficeMailTemplateUseCase; import starlight.shared.apiPayload.response.ApiResponse; @@ -16,7 +17,7 @@ @RestController @RequiredArgsConstructor @SecurityRequirement(name = "backofficeSession") -public class BackofficeMailController { +public class BackofficeMailController implements BackofficeMailApiDoc { private final BackofficeMailSendUseCase backofficeMailSendUseCase; private final BackofficeMailTemplateUseCase templateUseCase; diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java new file mode 100644 index 00000000..be24f355 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java @@ -0,0 +1,135 @@ +package starlight.adapter.backoffice.mail.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +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.RequestBody; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; +import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "[Office] 메일", description = "백오피스 메일 관리 API") +public interface BackofficeMailApiDoc { + + @Operation( + summary = "백오피스 메일 발송", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "이메일 전송에 성공하였습니다.", + "error": null + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping("/v1/backoffice/mail/send") + ApiResponse send( + @RequestBody BackofficeMailSendRequest request + ); + + @Operation( + summary = "메일 템플릿 생성", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeMailTemplateResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping("/v1/backoffice/mail/templates") + ApiResponse createTemplate( + @RequestBody BackofficeMailTemplateCreateRequest request + ); + + @Operation( + summary = "메일 템플릿 목록 조회", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BackofficeMailTemplateResponse.class))) + ) + }) + @GetMapping("/v1/backoffice/mail/templates") + ApiResponse> findTemplates(); + + @Operation( + summary = "메일 템플릿 삭제", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "템플릿이 삭제되었습니다.", + "error": null + } + """ + )) + ) + }) + @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") + ApiResponse deleteTemplate( + @PathVariable Long templateId + ); +} diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index deae9a0d..519bb666 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -19,6 +19,7 @@ @RequiredArgsConstructor public class ExpertJpa implements ExpertQueryPort, starlight.application.backoffice.expert.required.BackofficeExpertQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertCommandPort, starlight.application.expertReport.required.ExpertLookupPort, starlight.application.expertApplication.required.ExpertLookupPort { @@ -31,6 +32,32 @@ public Expert findByIdOrThrow(Long id) { ); } + @Override + public Expert findByIdWithCareersTagsCategories(Long id) { + try { + List experts = fetchWithCollections(List.of(id)); + if (experts.isEmpty()) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } + return experts.get(0); + } catch (ExpertException e) { + throw e; + } catch (Exception e) { + log.error("전문가 상세 조회 중 오류가 발생했습니다.", e); + throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR); + } + } + + @Override + public Expert save(Expert expert) { + return repository.save(expert); + } + + @Override + public void delete(Expert expert) { + repository.delete(expert); + } + @Override public Expert findByIdWithCareersAndTags(Long id) { try { diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java new file mode 100644 index 00000000..faea25f8 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java @@ -0,0 +1,99 @@ +package starlight.application.backoffice.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.expert.provided.BackofficeExpertCommandUseCase; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCareerUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; +import starlight.application.backoffice.expert.required.BackofficeExpertCommandPort; +import starlight.application.backoffice.expert.required.BackofficeExpertQueryPort; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.dto.ExpertCareerUpdate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class BackofficeExpertCommandService implements BackofficeExpertCommandUseCase { + + private final BackofficeExpertCommandPort expertCommandPort; + private final BackofficeExpertQueryPort expertQueryPort; + + @Override + public BackofficeExpertCreateResult createExpert(BackofficeExpertCreateInput input) { + Expert expert = Expert.createBackoffice( + input.name(), + input.email(), + input.oneLineIntroduction(), + input.tags(), + input.categories() + ); + + Expert savedExpert = expertCommandPort.save(expert); + + return BackofficeExpertCreateResult.from(savedExpert.getId()); + } + + @Override + public void updateExpert(BackofficeExpertUpdateInput input) { + Expert expert = expertQueryPort.findByIdWithCareersTagsCategories(input.expertId()); + + expert.updateBasicInfo( + input.name(), + input.email(), + input.oneLineIntroduction(), + input.detailedIntroduction(), + input.workedPeriod(), + input.mentoringPriceWon() + ); + + expert.replaceTags(input.tags()); + expert.replaceCategories(input.categories()); + + expert.syncCareers(toCareerUpdates(input.careers())); + } + + @Override + public void deleteExpert(Long expertId) { + Expert expert = expertQueryPort.findByIdOrThrow(expertId); + + expertCommandPort.delete(expert); + } + + @Override + public void updateActiveStatus(BackofficeExpertActiveStatusUpdateInput input) { + Expert expert = expertQueryPort.findByIdOrThrow(input.expertId()); + + expert.updateActiveStatus(input.activeStatus()); + } + + @Override + public void updateProfileImage(BackofficeExpertProfileImageUpdateInput input) { + Expert expert = expertQueryPort.findByIdOrThrow(input.expertId()); + + expert.updateProfileImageUrl(input.profileImageUrl()); + } + + private List toCareerUpdates(List inputs) { + if (inputs == null || inputs.isEmpty()) { + return List.of(); + } + + return inputs.stream() + .map(input -> new ExpertCareerUpdate( + input.id(), + input.orderIndex(), + input.careerTitle(), + input.careerExplanation(), + input.careerStartedAt(), + input.careerEndedAt() + )) + .toList(); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java index 9d7f6403..57ac2ae8 100644 --- a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java @@ -31,7 +31,22 @@ public List searchAll() { Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); return experts.stream() - .map(expert -> BackofficeExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) + .map(expert -> BackofficeExpertDetailResult.from( + expert, + countMap.getOrDefault(expert.getId(), 0L) + )) .toList(); } + + @Override + public BackofficeExpertDetailResult findById(Long expertId) { + Expert expert = expertQueryPort.findByIdOrThrow(expertId); + + Map countMap = expertApplicationLookupPort.countByExpertIds( + List.of(expertId) + ); + long count = countMap.getOrDefault(expertId, 0L); + + return BackofficeExpertDetailResult.from(expert, count); + } } diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java new file mode 100644 index 00000000..0d8ef20d --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java @@ -0,0 +1,20 @@ +package starlight.application.backoffice.expert.provided; + +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; + +public interface BackofficeExpertCommandUseCase { + + BackofficeExpertCreateResult createExpert(BackofficeExpertCreateInput input); + + void updateExpert(BackofficeExpertUpdateInput input); + + void deleteExpert(Long expertId); + + void updateActiveStatus(BackofficeExpertActiveStatusUpdateInput input); + + void updateProfileImage(BackofficeExpertProfileImageUpdateInput input); +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java index 97b4a7cf..1abf2b48 100644 --- a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java @@ -7,4 +7,6 @@ public interface BackofficeExpertQueryUseCase { List searchAll(); + + BackofficeExpertDetailResult findById(Long expertId); } diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java new file mode 100644 index 00000000..95f5c52a --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java @@ -0,0 +1,12 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.ExpertActiveStatus; + +public record BackofficeExpertActiveStatusUpdateInput( + Long expertId, + ExpertActiveStatus activeStatus +) { + public static BackofficeExpertActiveStatusUpdateInput of(Long expertId, ExpertActiveStatus activeStatus) { + return new BackofficeExpertActiveStatusUpdateInput(expertId, activeStatus); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java new file mode 100644 index 00000000..8ad95704 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java @@ -0,0 +1,13 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerUpdateInput( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java new file mode 100644 index 00000000..cca4a890 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java @@ -0,0 +1,23 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertCreateInput( + String name, + String email, + String oneLineIntroduction, + List tags, + List categories +) { + public static BackofficeExpertCreateInput of( + String name, + String email, + String oneLineIntroduction, + List tags, + List categories + ) { + return new BackofficeExpertCreateInput(name, email, oneLineIntroduction, tags, categories); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java new file mode 100644 index 00000000..24a92a29 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +public record BackofficeExpertProfileImageUpdateInput( + Long expertId, + String profileImageUrl +) { + public static BackofficeExpertProfileImageUpdateInput of(Long expertId, String profileImageUrl) { + return new BackofficeExpertProfileImageUpdateInput(expertId, profileImageUrl); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java new file mode 100644 index 00000000..2d7fe445 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java @@ -0,0 +1,44 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertUpdateInput( + Long expertId, + String name, + String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + List careers +) { + public static BackofficeExpertUpdateInput of( + Long expertId, + String name, + String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + List careers + ) { + return new BackofficeExpertUpdateInput( + expertId, + name, + email, + oneLineIntroduction, + detailedIntroduction, + workedPeriod, + mentoringPriceWon, + tags, + categories, + careers + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java new file mode 100644 index 00000000..7514b81c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.expert.provided.dto.result; + +public record BackofficeExpertCreateResult( + Long id +) { + public static BackofficeExpertCreateResult from(Long id) { + return new BackofficeExpertCreateResult(id); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java new file mode 100644 index 00000000..3b329949 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.required; + +import starlight.domain.expert.entity.Expert; + +public interface BackofficeExpertCommandPort { + + Expert save(Expert expert); + + void delete(Expert expert); +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java index 381519b6..c6da3181 100644 --- a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java @@ -6,5 +6,9 @@ public interface BackofficeExpertQueryPort { + Expert findByIdOrThrow(Long id); + + Expert findByIdWithCareersTagsCategories(Long id); + List findAllWithCareersTagsCategories(); } diff --git a/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java b/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java new file mode 100644 index 00000000..a4e0fa5f --- /dev/null +++ b/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java @@ -0,0 +1,12 @@ +package starlight.domain.expert.dto; + +import java.time.LocalDateTime; + +public record ExpertCareerUpdate( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { } diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index 8ef04d34..2107249b 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -5,14 +5,23 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.util.Assert; import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.enumerate.TagCategory; +import starlight.domain.expert.dto.ExpertCareerUpdate; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; import starlight.shared.AbstractEntity; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Getter @Entity @@ -59,4 +68,151 @@ public class Expert extends AbstractEntity { @Enumerated(EnumType.STRING) @Column(name = "category", length = 40, nullable = false) private Set categories = new LinkedHashSet<>(); + + public static Expert createBackoffice( + String name, + String email, + String oneLineIntroduction, + Collection tags, + Collection categories + ) { + Assert.hasText(name, "name must not be blank"); + Assert.hasText(email, "email must not be blank"); + + Expert expert = new Expert(); + expert.name = name; + expert.email = email; + expert.oneLineIntroduction = oneLineIntroduction; + expert.activeStatus = ExpertActiveStatus.INACTIVE; + + if (tags != null && !tags.isEmpty()) { + expert.tags.clear(); + expert.tags.addAll(tags); + } + + if (categories != null && !categories.isEmpty()) { + expert.categories.clear(); + expert.categories.addAll(categories); + } + + return expert; + } + + public void updateActiveStatus(ExpertActiveStatus activeStatus) { + Assert.notNull(activeStatus, "activeStatus must not be null"); + this.activeStatus = activeStatus; + } + + public void updateProfileImageUrl(String profileImageUrl) { + Assert.hasText(profileImageUrl, "profileImageUrl must not be blank"); + this.profileImageUrl = profileImageUrl; + } + + public void updateBasicInfo( + String name, String email, String oneLineIntroduction, + String detailedIntroduction, Long workedPeriod, Integer mentoringPriceWon + ) { + Assert.hasText(name, "name must not be blank"); + Assert.hasText(email, "email must not be blank"); + this.name = name; + this.email = email; + this.oneLineIntroduction = oneLineIntroduction; + this.detailedIntroduction = detailedIntroduction; + this.workedPeriod = workedPeriod; + this.mentoringPriceWon = mentoringPriceWon; + } + + public void replaceTags(Collection tags) { + this.tags.clear(); + if (tags != null && !tags.isEmpty()) { + this.tags.addAll(tags); + } + } + + public void replaceCategories(Collection categories) { + this.categories.clear(); + if (categories != null && !categories.isEmpty()) { + this.categories.addAll(categories); + } + } + + public void syncCareers(List updates) { + List careerUpdates = updates != null ? updates : List.of(); + + validateCareerUpdates(careerUpdates); + + Map careerById = careers.stream() + .filter(career -> career.getId() != null) + .collect(Collectors.toMap( + ExpertCareer::getId, + Function.identity(), + (a, b) -> a + )); + + Set requestedIds = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + careers.removeIf(career -> + career.getId() != null && !requestedIds.contains(career.getId()) + ); + + for (ExpertCareerUpdate update : careerUpdates) { + if (update.id() == null) { + careers.add(ExpertCareer.of( + this, + update.orderIndex(), + update.careerTitle(), + update.careerExplanation(), + update.careerStartedAt(), + update.careerEndedAt() + )); + continue; + } + + ExpertCareer career = careerById.get(update.id()); + if (career == null) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } + + career.update( + update.orderIndex(), + update.careerTitle(), + update.careerExplanation(), + update.careerStartedAt(), + update.careerEndedAt() + ); + } + } + + private void validateCareerUpdates(List careerUpdates) { + Set orderIndexes = careerUpdates.stream() + .map(ExpertCareerUpdate::orderIndex) + .collect(Collectors.toSet()); + + Set requestedIds = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + long requestedIdCount = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .count(); + + boolean hasDuplicateOrderIndex = orderIndexes.size() != careerUpdates.size(); + boolean hasDuplicateIds = requestedIds.size() != requestedIdCount; + boolean hasInvalidPeriod = careerUpdates.stream().anyMatch(update -> + update.orderIndex() == null + || update.orderIndex() < 0 + || update.careerStartedAt() == null + || update.careerEndedAt() == null + || update.careerStartedAt().isAfter(update.careerEndedAt()) + ); + + if (hasDuplicateOrderIndex || hasDuplicateIds || hasInvalidPeriod) { + throw new ExpertException(ExpertErrorType.EXPERT_CAREER_INVALID); + } + } } diff --git a/src/main/java/starlight/domain/expert/entity/ExpertCareer.java b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java index cd151cae..964699ea 100644 --- a/src/main/java/starlight/domain/expert/entity/ExpertCareer.java +++ b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java @@ -44,7 +44,8 @@ public static ExpertCareer of(Expert expert, int orderIndex, String title, Strin return expertCareer; } - public void update(String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + public void update(int orderIndex, String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + this.orderIndex = orderIndex; this.careerTitle = title; this.careerExplanation = explanation; this.careerStartedAt = startedAt; diff --git a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java index e8179cd8..144815ab 100644 --- a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java +++ b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java @@ -9,8 +9,9 @@ @RequiredArgsConstructor public enum ExpertErrorType implements ErrorType { - EXPERT_QUERY_ERROR(HttpStatus.NOT_FOUND, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), - EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."); + EXPERT_QUERY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), + EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + EXPERT_CAREER_INVALID(HttpStatus.BAD_REQUEST, "경력 정보가 올바르지 않습니다."); ; private final HttpStatus status; diff --git "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" index c45767a8..de2440f5 100644 --- "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" +++ "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" @@ -56,5 +56,21 @@ - Application 입력: `*Input` - Application 출력: `*Result` +## API 응답 규칙 +- 조회 API는 항상 데이터를 반환한다. +- 생성 API는 식별자 또는 핵심 결과만 반환한다. +- 수정/삭제 API는 기본적으로 `ApiResponse.success()`로 통일한다(응답 data 없음). +- 사용자 메시지가 필요한 액션(메일 전송 등)만 메시지 포함 응답을 사용한다. + +## 포맷팅 규칙 +- 컨트롤러 호출이 ~100자 이내면 한 줄로 유지한다. +- 인자가 래핑되면 한 줄에 한 인자로 멀티라인을 유지한다. +- 빌더나 `*Input.of(...)`는 인자가 2개 이상이면 멀티라인을 우선한다. +- 논리 단계별로 빈 줄을 넣어 구분한다(예: 조회 → 계산 → 반환). + +## 도메인 검증 규칙 +- `Assert`는 프로그래머 오류/불변식 위반에만 사용한다. +- 비즈니스 규칙 위반/사용자 입력 오류는 도메인 예외로 처리한다. + ## 로컬 실행 - `./gradlew bootRun --args='--spring.profiles.active=dev'` From c94e688b155464ca1c32f65222c6a31a1b6e3e9b Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 15:09:20 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[SRLT-132]=20Refactor:=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EB=A7=88=EC=8A=A4=ED=82=B9=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85/=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20-=20EmailMaskingUtils=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=8B=A0=EC=9E=90=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=A7=88=EC=8A=A4=ED=82=B9=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20BackofficeMailContentTypeParser=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=EC=9C=BC=EB=A1=9C=20ContentType=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC/=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20-=20=EB=A1=9C=EA=B7=B8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20=EC=88=98=EB=9F=89=EB=A7=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20-?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20(BackofficeException?= =?UTF-8?q?=20=ED=99=9C=EC=9A=A9)=20-=20CSRF=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4=EC=A0=95=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=ED=99=94=20(application-{env}.yaml=20=EC=88=98?= =?UTF-8?q?=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/mail/email/SmtpMailSender.java | 4 +-- .../BackofficeMailSendLogEventHandler.java | 14 ++++++-- .../mail/BackofficeMailSendService.java | 29 ++++++---------- .../mail/BackofficeMailTemplateService.java | 12 ++----- .../util/BackofficeMailContentTypeParser.java | 18 ++++++++++ .../mail/util/EmailMaskingUtils.java | 34 +++++++++++++++++++ .../starlight/bootstrap/SecurityConfig.java | 7 ++-- 7 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java create mode 100644 src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java diff --git a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java index c5c3d6f1..0eda1a13 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -39,9 +39,9 @@ public void send(BackofficeMailSendInput input, BackofficeMailContentType conten helper.setText(body, isHtml); javaMailSender.send(message); - log.info("[MAIL] sent to={} subject={}", input.to(), input.subject()); + log.info("[MAIL] sent recipients={} subject={}", input.to().size(), input.subject()); } catch (MessagingException e) { - log.error("[MAIL] send failed to={}", input.to(), e); + log.error("[MAIL] send failed recipients={}", input.to().size(), e); throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED, e); } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 41928262..0bb1d9ab 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -2,9 +2,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Component; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; +import starlight.application.backoffice.mail.util.EmailMaskingUtils; +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailSendLog; @Component @@ -15,7 +19,8 @@ public class BackofficeMailSendLogEventHandler { @EventListener public void handle(BackofficeMailSendEvent event) { - String recipients = String.join(",", event.to()); + String recipients = EmailMaskingUtils.maskRecipients(event.to()); + BackofficeMailSendLog log = BackofficeMailSendLog.create( recipients, event.subject(), @@ -23,6 +28,11 @@ public void handle(BackofficeMailSendEvent event) { event.success(), event.errorMessage() ); - logCommandPort.save(log); + + try { + logCommandPort.save(log); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_LOG_SAVE_FAILED); + } } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java index 07243780..536b72f7 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -1,17 +1,17 @@ package starlight.application.backoffice.mail; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; import starlight.application.backoffice.mail.required.MailSenderPort; +import starlight.application.backoffice.mail.util.BackofficeMailContentTypeParser; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; -import org.springframework.context.ApplicationEventPublisher; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; -import starlight.domain.backoffice.mail.BackofficeMailSendLog; @Service @RequiredArgsConstructor @@ -23,11 +23,13 @@ public class BackofficeMailSendService implements BackofficeMailSendUseCase { @Override @Transactional public void send(BackofficeMailSendInput input) { - BackofficeMailContentType contentType = parseContentType(input.contentType()); + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); try { validate(input, contentType); + mailSenderPort.send(input, contentType); + BackofficeMailSendEvent log = BackofficeMailSendEvent.of( input.to(), input.subject(), @@ -36,39 +38,30 @@ public void send(BackofficeMailSendInput input) { null ); eventPublisher.publishEvent(log); - - } catch (IllegalArgumentException exception) { + } catch (BackofficeException exception) { publishFailureEvent(input, contentType, exception.getMessage()); - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); + throw exception; } catch (Exception exception) { publishFailureEvent(input, contentType, exception.getMessage()); throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); } } - private BackofficeMailContentType parseContentType(String contentType) { - try { - return BackofficeMailContentType.from(contentType); - } catch (IllegalArgumentException exception) { - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); - } - } - private void validate(BackofficeMailSendInput input, BackofficeMailContentType contentType) { if (input.to() == null || input.to().isEmpty()) { - throw new IllegalArgumentException("recipient is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } if (input.subject() == null || input.subject().isBlank()) { - throw new IllegalArgumentException("subject is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } if (contentType == BackofficeMailContentType.HTML) { if (input.html() == null || input.html().isBlank()) { - throw new IllegalArgumentException("html body is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } } if (contentType == BackofficeMailContentType.TEXT) { if (input.text() == null || input.text().isBlank()) { - throw new IllegalArgumentException("text body is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java index bdd12e9f..2d588071 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java @@ -9,6 +9,7 @@ import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; import starlight.application.backoffice.mail.required.BackofficeMailTemplateCommandPort; import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; +import starlight.application.backoffice.mail.util.BackofficeMailContentTypeParser; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; @@ -26,7 +27,7 @@ public class BackofficeMailTemplateService implements BackofficeMailTemplateUseC @Override @Transactional public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input) { - BackofficeMailContentType contentType = parseContentType(input.contentType()); + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); BackofficeMailTemplate template = BackofficeMailTemplate.create( input.name(), input.title(), @@ -37,20 +38,13 @@ public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateI try { BackofficeMailTemplate saved = templateCommandPort.save(template); + return toResult(saved); } catch (DataAccessException exception) { throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_SAVE_FAILED); } } - private BackofficeMailContentType parseContentType(String contentType) { - try { - return BackofficeMailContentType.from(contentType); - } catch (IllegalArgumentException exception) { - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); - } - } - @Override @Transactional(readOnly = true) public List findTemplates() { diff --git a/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java b/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java new file mode 100644 index 00000000..6bf8717d --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java @@ -0,0 +1,18 @@ +package starlight.application.backoffice.mail.util; + +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +public final class BackofficeMailContentTypeParser { + + private BackofficeMailContentTypeParser() {} + + public static BackofficeMailContentType parse(String contentType) { + try { + return BackofficeMailContentType.from(contentType); + } catch (IllegalArgumentException exception) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); + } + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java b/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java new file mode 100644 index 00000000..9c35246c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java @@ -0,0 +1,34 @@ +package starlight.application.backoffice.mail.util; + +import java.util.List; +import java.util.stream.Collectors; + +public final class EmailMaskingUtils { + + private EmailMaskingUtils() { + } + + public static String maskRecipients(List recipients) { + if (recipients == null || recipients.isEmpty()) { + return ""; + } + return recipients.stream() + .map(EmailMaskingUtils::maskEmail) + .collect(Collectors.joining(",")); + } + + private static String maskEmail(String email) { + if (email == null || email.isBlank()) { + return "***"; + } + int atIndex = email.indexOf("@"); + if (atIndex <= 0) { + return "***"; + } + String local = email.substring(0, atIndex); + String domain = email.substring(atIndex + 1); + String maskedLocal = local.length() <= 1 ? "*" : local.charAt(0) + "***"; + String maskedDomain = domain.isBlank() ? "***" : domain; + return maskedLocal + "@" + maskedDomain; + } +} diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 055e7ada..4eeecc1a 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -47,12 +47,13 @@ @RequiredArgsConstructor public class SecurityConfig { - @Value("${cors.origin.server}") String ServerBaseUrl; + @Value("${cors.origin.server}") String serverBaseUrl; @Value("${cors.origin.client}") String clientBaseUrl; @Value("${cors.origin.office}") String officeBaseUrl; @Value("${cors.origin.develop}") String devBaseUrl; @Value("${backoffice.auth.username}") String backofficeUsername; @Value("${backoffice.auth.password-hash}") String backofficePasswordHash; + @Value("${backoffice.csrf.cookie-domain}") String backofficeCsrfCookieDomain; private final Environment environment; private final JwtFilter jwtFilter; @@ -70,7 +71,7 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep boolean isDevProfile = List.of(environment.getActiveProfiles()).contains("dev"); if (!isDevProfile) { csrfTokenRepository.setCookieCustomizer(cookie -> cookie - .domain("starlight-official.co.kr") + .domain(backofficeCsrfCookieDomain) .sameSite("None") .secure(true) ); @@ -148,7 +149,7 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( clientBaseUrl, - ServerBaseUrl, + serverBaseUrl, devBaseUrl, officeBaseUrl )); From 8168f2bb191597769ffcfa3f37bb50468b348045 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 15:15:49 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[SRLT-133]=20Refactor:=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=84=EC=86=A1=20=EC=97=90=EB=9F=AC=EA=B0=80=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mail/BackofficeMailSendLogEventHandler.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 0bb1d9ab..4db43b73 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -1,23 +1,27 @@ package starlight.application.backoffice.mail; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.dao.DataAccessException; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; import starlight.application.backoffice.mail.util.EmailMaskingUtils; -import starlight.domain.backoffice.exception.BackofficeErrorType; -import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailSendLog; +@Slf4j @Component @RequiredArgsConstructor public class BackofficeMailSendLogEventHandler { private final BackofficeMailSendLogCommandPort logCommandPort; - @EventListener + @Async("emailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BackofficeMailSendEvent event) { String recipients = EmailMaskingUtils.maskRecipients(event.to()); @@ -32,7 +36,7 @@ public void handle(BackofficeMailSendEvent event) { try { logCommandPort.save(log); } catch (DataAccessException exception) { - throw new BackofficeException(BackofficeErrorType.MAIL_LOG_SAVE_FAILED); + log.warn("[MAIL] send log save failed. subject={}", event.subject(), exception); } } } From d7fba08447c5469a0f9d7c6ede95e4216b06887e Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 15:46:03 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[SRLT-133]=20Refactor:=20log=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=AA=85=20=EC=B6=A9=EB=8F=8C=EC=9D=84=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/mail/BackofficeMailSendLogEventHandler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 4db43b73..3b09ddcf 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -25,7 +25,7 @@ public class BackofficeMailSendLogEventHandler { public void handle(BackofficeMailSendEvent event) { String recipients = EmailMaskingUtils.maskRecipients(event.to()); - BackofficeMailSendLog log = BackofficeMailSendLog.create( + BackofficeMailSendLog mailSendLog = BackofficeMailSendLog.create( recipients, event.subject(), event.contentType(), @@ -34,7 +34,7 @@ public void handle(BackofficeMailSendEvent event) { ); try { - logCommandPort.save(log); + logCommandPort.save(mailSendLog); } catch (DataAccessException exception) { log.warn("[MAIL] send log save failed. subject={}", event.subject(), exception); } From c1f611f2bdc804f6ec51aa0861c544a76bd4a0cb Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 18:17:42 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[SRLT-133]=20Refactor:=20PresignedUrlProv?= =?UTF-8?q?ider=20=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20NcpPresignedUrlPr?= =?UTF-8?q?ovider=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/image/webapi/BackofficeImageController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java index 06216431..5d4c2337 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -11,10 +11,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; -import starlight.application.aireport.required.PresignedUrlProvider; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -27,7 +27,7 @@ public class BackofficeImageController implements BackofficeImageApiDoc { private static final long BACKOFFICE_USER_ID = 0L; - private final PresignedUrlProvider presignedUrlProvider; + private final NcpPresignedUrlProvider presignedUrlProvider; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( From 67fafedfda5a25c9724a3137a68149f944505fde Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 19:38:17 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[SRLT-133]=20Refactor:=20Swagger=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EC=B5=9C=EC=8B=A0=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swagger/BackofficeExpertApiDoc.java | 171 +++++++++++++++--- .../webapi/BackofficeImageController.java | 6 +- .../webapi/swagger/BackofficeImageApiDoc.java | 80 +++++++- .../webapi/swagger/BackofficeMailApiDoc.java | 117 +++++++++++- 4 files changed, 331 insertions(+), 43 deletions(-) diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java index 136e93dd..dd0310e9 100644 --- a/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java @@ -39,6 +39,38 @@ public interface BackofficeExpertApiDoc { content = @Content( array = @ArraySchema(schema = @Schema(implementation = BackofficeExpertListResponse.class)) ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content(examples = { + @ExampleObject( + name = "전문가 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ), + @ExampleObject( + name = "신청 건수 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) ) }) @GetMapping @@ -89,29 +121,46 @@ ApiResponse create( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", content = @Content(examples = { @ExampleObject( - name = "전문가 없음", + name = "전문가 조회 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_NOT_FOUND", - "message": "해당 전문가를 찾을 수 없습니다." + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." } } """ ), @ExampleObject( - name = "조회 오류", + name = "신청 건수 조회 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_QUERY_ERROR", - "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." } } """ @@ -136,50 +185,68 @@ ApiResponse detail( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", - content = @Content(examples = @ExampleObject( - value = """ - { - "result": "ERROR", - "data": null, - "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." - } - } - """ - )) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "전문가 조회 실패", content = @Content(examples = { @ExampleObject( - name = "전문가 없음", + name = "요청 값 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_NOT_FOUND", - "message": "해당 전문가를 찾을 수 없습니다." + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." } } """ ), @ExampleObject( - name = "조회 오류", + name = "경력 정보 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_QUERY_ERROR", - "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + "code": "EXPERT_CAREER_INVALID", + "message": "경력 정보가 올바르지 않습니다." } } """ ) }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content(examples = @ExampleObject( + name = "전문가 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + )) ) }) @PatchMapping("/{expertId}") @@ -196,6 +263,38 @@ ApiResponse update( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) ) }) @PatchMapping("/{expertId}/active-status") @@ -228,6 +327,22 @@ ApiResponse updateActiveStatus( } """ )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) ) }) @PatchMapping("/{expertId}/profile-image") diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java index 5d4c2337..3fcce8b9 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -1,7 +1,6 @@ package starlight.adapter.backoffice.image.webapi; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; @@ -14,7 +13,6 @@ import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; -import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -31,14 +29,14 @@ public class BackofficeImageController implements BackofficeImageApiDoc { @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( - @RequestParam @ValidImageFileName String fileName + @RequestParam String fileName ) { return ApiResponse.success(presignedUrlProvider.getPreSignedUrl(BACKOFFICE_USER_ID, fileName)); } @PostMapping(value = "/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse finalizePublic( - @Valid @RequestBody BackofficeImagePublicRequest request + @RequestBody BackofficeImagePublicRequest request ) { return ApiResponse.success(presignedUrlProvider.makePublic(request.objectUrl())); } diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java index 68f6d473..4d44673d 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -1,5 +1,6 @@ package starlight.adapter.backoffice.image.webapi.swagger; +import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; +import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -33,15 +35,47 @@ public interface BackofficeImageApiDoc { ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", - description = "fileName 검증 실패", + description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "fileName 검증 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "fileName이 올바르지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Presigned URL 생성 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "fileName이 올바르지 않습니다." + "code": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." } } """ @@ -50,7 +84,7 @@ public interface BackofficeImageApiDoc { }) @GetMapping(value = "/v1/backoffice/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) ApiResponse getPresignedUrl( - @RequestParam String fileName + @RequestParam @ValidImageFileName String fileName ); @Operation( @@ -65,14 +99,46 @@ ApiResponse getPresignedUrl( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "JSON 형식 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 JSON 형식입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "이미지 공개 처리 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." + "code": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." } } """ @@ -81,6 +147,6 @@ ApiResponse getPresignedUrl( }) @PostMapping(value = "/v1/backoffice/images/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) ApiResponse finalizePublic( - @RequestBody BackofficeImagePublicRequest request + @Valid @RequestBody BackofficeImagePublicRequest request ); } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java index be24f355..766fe3aa 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java @@ -44,14 +44,59 @@ public interface BackofficeMailApiDoc { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "contentType 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_CONTENT_TYPE", + "message": "유효하지 않은 contentType입니다." + } + } + """ + ), + @ExampleObject( + name = "메일 요청 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_REQUEST", + "message": "메일 발송 요청이 유효하지 않습니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "메일 전송 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." + "code": "MAIL_SEND_FAILED", + "message": "메일 전송에 실패했습니다." } } """ @@ -76,14 +121,46 @@ ApiResponse send( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "contentType 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_CONTENT_TYPE", + "message": "유효하지 않은 contentType입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 저장 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." + "code": "MAIL_TEMPLATE_SAVE_FAILED", + "message": "메일 템플릿 저장에 실패했습니다." } } """ @@ -104,6 +181,22 @@ ApiResponse createTemplate( responseCode = "200", description = "성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = BackofficeMailTemplateResponse.class))) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_TEMPLATE_QUERY_FAILED", + "message": "메일 템플릿 조회에 실패했습니다." + } + } + """ + )) ) }) @GetMapping("/v1/backoffice/mail/templates") @@ -126,6 +219,22 @@ ApiResponse createTemplate( } """ )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 삭제 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_TEMPLATE_DELETE_FAILED", + "message": "메일 템플릿 삭제에 실패했습니다." + } + } + """ + )) ) }) @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") From 9fa71542cea5b3c7b8a5076bcc332b6b02046b43 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 26 Jan 2026 15:15:41 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[SRLT-133]=20Refactor:=20ExpertApplicatio?= =?UTF-8?q?nJpa=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=EC=9D=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../expertApplication/persistence/ExpertApplicationJpa.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java index 0f974f1a..71896ab7 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -16,7 +16,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class ExpertApplicationJpaPort implements ExpertApplicationQueryPort, +public class ExpertApplicationJpa implements ExpertApplicationQueryPort, starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort, starlight.application.expert.required.ExpertApplicationCountLookupPort, starlight.application.expertReport.required.ExpertApplicationCountLookupPort { From fb9942c16e7a570e0442407e8516c40887c7fc6c Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 16 Feb 2026 00:04:05 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[SRLT-133]=20Refactor:=20=EC=96=B4?= =?UTF-8?q?=EB=8C=91=ED=84=B0=20=EA=B3=84=EC=B8=B5=EC=9D=98=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EB=A1=9C=EC=A7=81=EC=9D=84=20shared=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/image/webapi/BackofficeImageController.java | 2 +- .../infrastructure/storage/NcpPresignedUrlProvider.java | 2 +- .../infrastructure/storage/NcpPresignedUrlProviderUnitTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/main/java/starlight/adapter/{aireport => shared}/infrastructure/storage/NcpPresignedUrlProvider.java (98%) diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java index 3fcce8b9..42af7d6b 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; import starlight.shared.apiPayload.response.ApiResponse; diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java similarity index 98% rename from src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java rename to src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java index 188211a7..0360597d 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.storage; +package starlight.adapter.shared.infrastructure.storage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index 22344d18..fe70868d 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -14,7 +14,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URL; From 08f2a215851323a836e8039bf122d807625a4ccb Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 16 Feb 2026 00:30:22 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[SRLT-133]=20Refactor:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: seongho5356 --- .../request/BackofficeExpertUpdateRequest.java | 2 +- .../webapi/BackofficeImageController.java | 10 ++++++---- .../webapi/swagger/BackofficeImageApiDoc.java | 18 ++++++++++++++++-- .../persistence/BusinessPlanQueryJpa.java | 4 ++-- .../adapter/member/persistence/MemberJpa.java | 4 ++-- .../storage/NcpPresignedUrlProvider.java | 4 +++- .../application/aireport/AiReportService.java | 14 +++++++------- ...java => BusinessPlanCommandLookupPort.java} | 2 +- ...t.java => BusinessPlanQueryLookupPort.java} | 4 ++-- .../expert/BackofficeExpertCommandService.java | 4 +++- .../BackofficeMailSendLogEventHandler.java | 3 +-- .../businessplan/BusinessPlanService.java | 6 +++--- ...erLookUpPort.java => MemberLookupPort.java} | 2 +- .../expert/ExpertDetailQueryService.java | 2 +- .../aireport/exception/AiReportErrorType.java | 1 + .../starlight/domain/expert/entity/Expert.java | 2 +- .../expert/exception/ExpertErrorType.java | 1 + ...234\352\260\200\354\235\264\353\223\234.md" | 2 ++ 18 files changed, 54 insertions(+), 31 deletions(-) rename src/main/java/starlight/application/aireport/required/{BusinessPlanCommandLookUpPort.java => BusinessPlanCommandLookupPort.java} (82%) rename src/main/java/starlight/application/aireport/required/{BusinessPlanQueryLookUpPort.java => BusinessPlanQueryLookupPort.java} (75%) rename src/main/java/starlight/application/businessplan/required/{MemberLookUpPort.java => MemberLookupPort.java} (79%) diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java index 740952f0..f8df7524 100644 --- a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java @@ -22,7 +22,7 @@ public record BackofficeExpertUpdateRequest( ) { public BackofficeExpertUpdateInput toInput(Long expertId) { List careerInputs = careers == null - ? List.of() + ? null : careers.stream() .map(career -> new BackofficeExpertCareerUpdateInput( career.id(), diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java index 42af7d6b..56ff7b47 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -1,6 +1,7 @@ package starlight.adapter.backoffice.image.webapi; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; @@ -10,9 +11,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; +import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; +import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -25,18 +27,18 @@ public class BackofficeImageController implements BackofficeImageApiDoc { private static final long BACKOFFICE_USER_ID = 0L; - private final NcpPresignedUrlProvider presignedUrlProvider; + private final PresignedUrlProviderPort presignedUrlProvider; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( - @RequestParam String fileName + @RequestParam @ValidImageFileName String fileName ) { return ApiResponse.success(presignedUrlProvider.getPreSignedUrl(BACKOFFICE_USER_ID, fileName)); } @PostMapping(value = "/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse finalizePublic( - @RequestBody BackofficeImagePublicRequest request + @Valid @RequestBody BackofficeImagePublicRequest request ) { return ApiResponse.success(presignedUrlProvider.makePublic(request.objectUrl())); } diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java index 4d44673d..32c220d8 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -30,7 +30,8 @@ public interface BackofficeImageApiDoc { responseCode = "200", description = "성공", content = @Content( - schema = @Schema(implementation = PreSignedUrlResponse.class) + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ApiResponse.class) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -94,7 +95,20 @@ ApiResponse getPresignedUrl( @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "성공" + description = "성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "https://bucket.example.com/path/to/object.jpg", + "error": null + } + """ + ) + ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java index f5dd0d6b..09a5eeda 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java @@ -16,8 +16,8 @@ @RequiredArgsConstructor public class BusinessPlanQueryJpa implements BusinessPlanCommandPort, BusinessPlanQueryPort, starlight.application.expert.required.BusinessPlanQueryLookupPort, - starlight.application.aireport.required.BusinessPlanCommandLookUpPort, - starlight.application.aireport.required.BusinessPlanQueryLookUpPort { + starlight.application.aireport.required.BusinessPlanCommandLookupPort, + starlight.application.aireport.required.BusinessPlanQueryLookupPort { private final BusinessPlanRepository businessPlanRepository; diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java index 76ec3636..4ffc200b 100644 --- a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.application.member.required.MemberCommandPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; @@ -13,7 +13,7 @@ @Repository @RequiredArgsConstructor -public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookUpPort { +public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookupPort { private final MemberRepository memberRepository; diff --git a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java index 0360597d..06de3103 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java @@ -13,6 +13,8 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import starlight.application.aireport.required.PresignedUrlProviderPort; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URLEncoder; @@ -84,7 +86,7 @@ public String makePublic(String objectUrl) { log.info("객체 공개 처리 완료(PUBLIC_READ): key={}", objectUrl); } catch (S3Exception e) { log.error("객체 공개 처리 실패 - Message: {}", e.getMessage()); - throw new RuntimeException("객체 공개 처리 실패: " + e.getMessage(), e); + throw new AiReportException(AiReportErrorType.OBJECT_ACL_UPDATE_FAILED, e); } return objectUrl; diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index f7b73f58..5de7c3b1 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -28,8 +28,8 @@ @Transactional public class AiReportService implements AiReportUseCase { - private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort; - private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort; + private final BusinessPlanCommandLookupPort businessPlanCommandLookupPort; + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; private final ReportGraderPort reportGrader; @@ -42,7 +42,7 @@ public class AiReportService implements AiReportUseCase { public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { log.info("사업계획서 AI 채점 시작. planId: {}, memberId: {}", planId, memberId); - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); checkBusinessPlanWritingCompleted(plan); @@ -78,8 +78,8 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { log.info("PDF 사업계획서 생성 및 AI 채점 시작. title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); - Long businessPlanId = businessPlanCommandLookUpPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(businessPlanId); + Long businessPlanId = businessPlanCommandLookupPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(businessPlanId); log.debug("OCR 시작. pdfUrl: {}", pdfUrl); String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); @@ -111,7 +111,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, @Override @Transactional(readOnly = true) public AiReportResult getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) @@ -143,7 +143,7 @@ private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan } plan.updateStatus(PlanStatus.AI_REVIEWED); - businessPlanCommandLookUpPort.save(plan); + businessPlanCommandLookupPort.save(plan); return aiReportCommandPort.save(aiReport); } diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java similarity index 82% rename from src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java rename to src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java index 0c25cefa..66a9dfb4 100644 --- a/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java @@ -2,7 +2,7 @@ import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanCommandLookUpPort { +public interface BusinessPlanCommandLookupPort { BusinessPlan save(BusinessPlan plan); Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java similarity index 75% rename from src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java rename to src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java index 0024cb71..f0ea03ae 100644 --- a/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java @@ -2,6 +2,6 @@ import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanQueryLookUpPort { +public interface BusinessPlanQueryLookupPort { BusinessPlan findByIdOrThrow(Long id); -} \ No newline at end of file +} diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java index faea25f8..0c680d46 100644 --- a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java @@ -56,7 +56,9 @@ public void updateExpert(BackofficeExpertUpdateInput input) { expert.replaceTags(input.tags()); expert.replaceCategories(input.categories()); - expert.syncCareers(toCareerUpdates(input.careers())); + if (input.careers() != null) { + expert.syncCareers(toCareerUpdates(input.careers())); + } } @Override diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 3b09ddcf..40620c70 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.dao.DataAccessException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -21,7 +20,7 @@ public class BackofficeMailSendLogEventHandler { private final BackofficeMailSendLogCommandPort logCommandPort; @Async("emailTaskExecutor") - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) public void handle(BackofficeMailSendEvent event) { String recipients = EmailMaskingUtils.maskRecipients(event.to()); diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java index dc5a964a..8675083f 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanService.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -15,7 +15,7 @@ import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.required.ChecklistGraderPort; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.application.businessplan.util.SubSectionSupportUtils; import starlight.domain.businessplan.entity.*; @@ -37,13 +37,13 @@ public class BusinessPlanService implements BusinessPlanUseCase { private final BusinessPlanCommandPort businessPlanCommandPort; private final BusinessPlanQueryPort businessPlanQueryPort; - private final MemberLookUpPort memberLookUpPort; + private final MemberLookupPort memberLookupPort; private final ChecklistGraderPort checklistGrader; private final ObjectMapper objectMapper; @Override public BusinessPlanResult.Result createBusinessPlan(Long memberId) { - Member member = memberLookUpPort.findByIdOrThrow(memberId); + Member member = memberLookupPort.findByIdOrThrow(memberId); String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; diff --git a/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java b/src/main/java/starlight/application/businessplan/required/MemberLookupPort.java similarity index 79% rename from src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java rename to src/main/java/starlight/application/businessplan/required/MemberLookupPort.java index 54445249..978d72a5 100644 --- a/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java +++ b/src/main/java/starlight/application/businessplan/required/MemberLookupPort.java @@ -2,6 +2,6 @@ import starlight.domain.member.entity.Member; -public interface MemberLookUpPort { +public interface MemberLookupPort { Member findByIdOrThrow(Long id); } diff --git a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java index 1625e4d5..3d20a8be 100644 --- a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java +++ b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java @@ -46,7 +46,7 @@ public List searchAllActive() { public ExpertDetailResult findById(Long expertId) { Expert expert = expertQueryPort.findByIdWithCareersAndTags(expertId); if (expert.getActiveStatus() != ExpertActiveStatus.ACTIVE) { - throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + throw new ExpertException(ExpertErrorType.EXPERT_NOT_ACTIVE); } Map countMap = expertApplicationLookupPort.countByExpertIds(List.of(expertId)); long count = countMap.getOrDefault(expertId, 0L); diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index 56353ae7..912baf9c 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -14,6 +14,7 @@ public enum AiReportErrorType implements ErrorType { UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."), AI_GRADING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 채점에 실패했습니다."), + OBJECT_ACL_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "객체 공개 처리에 실패했습니다."), AI_AGENT_DUPLICATED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 에이전트가 중복입니다."); ; diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index 2107249b..10fc42aa 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -173,7 +173,7 @@ public void syncCareers(List updates) { ExpertCareer career = careerById.get(update.id()); if (career == null) { - throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + throw new ExpertException(ExpertErrorType.EXPERT_CAREER_INVALID); } career.update( diff --git a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java index 144815ab..4453c73a 100644 --- a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java +++ b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java @@ -11,6 +11,7 @@ public enum ExpertErrorType implements ErrorType { EXPERT_QUERY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + EXPERT_NOT_ACTIVE(HttpStatus.FORBIDDEN, "비활성 전문가입니다."), EXPERT_CAREER_INVALID(HttpStatus.BAD_REQUEST, "경력 정보가 올바르지 않습니다."); ; diff --git "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" index de2440f5..f45704e2 100644 --- "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" +++ "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" @@ -30,6 +30,7 @@ - Inbound `provided`는 해당 도메인의 유스케이스만 노출한다. - Outbound 포트는 소비자 도메인에서 정의한다(`application//required`). - Cross-domain 조회는 `OtherDomainLookupPort` 규칙을 따른다. +- 포트/타입 네이밍에서 `Lookup`을 한 단어로 사용한다(`LookupPort`). `LookUp` 표기는 사용하지 않는다. - 다른 도메인의 `provided` 서비스를 직접 호출하지 않는다. 소비자 도메인에 `required` 포트를 정의하고, 제공 도메인의 어댑터가 구현한다. - Response DTO는 애플리케이션 DTO로만 변환하고 엔티티를 직접 받지 않는다. - 도메인 의미가 있는 포트는 소비자 도메인 `required`에 둔다. @@ -39,6 +40,7 @@ - Provided (inbound): `*UseCase` - Required (outbound): `*Port` - Cross-domain lookup: `OtherDomainLookupPort` +- Lookup 철자: `Lookup`만 사용 (`LookUp` 금지) - 컬렉션을 함께 로딩하는 경우 이름에 컬렉션을 명시한다. - 예: `findAllWithCareersTagsCategories`, `findByIdWithCareersAndTags` - 예: `fetchExpertsWithCareersByIds` From a9cacfea916ba47ab6284e271ff26ffa30bed741 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 16 Feb 2026 00:36:40 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[SRLT-133]=20Fix:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: seongho5356 --- .../NcpPresignedUrlProviderUnitTest.java | 7 ++-- .../AiReportServiceIntegrationTest.java | 12 +++--- .../aireport/AiReportServiceUnitTest.java | 38 +++++++++---------- .../BusinessPlanServiceIntegrationTest.java | 6 +-- .../BusinessPlanServiceUnitTest.java | 8 ++-- 5 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index fe70868d..2b3d8c40 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -15,6 +15,7 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URL; @@ -149,8 +150,8 @@ void makePublic_Failure_S3Exception() { // when & then assertThatThrownBy(() -> presignedUrlProvider.makePublic(objectUrl)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("객체 공개 처리 실패"); + .isInstanceOf(AiReportException.class) + .hasMessageContaining("객체 공개 처리에 실패했습니다."); verify(ncpS3Client).putObjectAcl(any(PutObjectAclRequest.class)); } @@ -178,4 +179,4 @@ void makePublic_InvalidUrl_NoPath() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("path가 없습니다"); } -} \ No newline at end of file +} diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index d1da1f1b..5de51af5 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -22,8 +22,8 @@ import starlight.application.aireport.required.ReportGraderPort; import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; -import starlight.application.aireport.required.BusinessPlanCommandLookUpPort; -import starlight.application.aireport.required.BusinessPlanQueryLookUpPort; +import starlight.application.aireport.required.BusinessPlanCommandLookupPort; +import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; @@ -193,8 +193,8 @@ BusinessPlanContentExtractor businessPlanContentExtractor() { } @Bean - BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanCommandLookUpPort() { + BusinessPlanCommandLookupPort businessPlanCommandLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandLookupPort() { @Override public BusinessPlan save(BusinessPlan plan) { return businessPlanRepository.save(plan); @@ -210,8 +210,8 @@ public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId } @Bean - BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanQueryLookUpPort() { + BusinessPlanQueryLookupPort businessPlanQueryLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanQueryLookupPort() { @Override public BusinessPlan findByIdOrThrow(Long id) { return businessPlanRepository.findById(id) diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 4529c9a9..1cc0c191 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -9,8 +9,8 @@ import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.OcrProviderPort; -import starlight.application.aireport.required.BusinessPlanCommandLookUpPort; -import starlight.application.aireport.required.BusinessPlanQueryLookUpPort; +import starlight.application.aireport.required.BusinessPlanCommandLookupPort; +import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; @@ -33,8 +33,8 @@ @DisplayName("AiReportService 유닛 테스트") class AiReportServiceUnitTest { - private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort = mock(BusinessPlanCommandLookUpPort.class); - private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort = mock(BusinessPlanQueryLookUpPort.class); + private final BusinessPlanCommandLookupPort businessPlanCommandLookupPort = mock(BusinessPlanCommandLookupPort.class); + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort = mock(BusinessPlanQueryLookupPort.class); private final AiReportQueryPort aiReportQuery = mock(AiReportQueryPort.class); private final AiReportCommandPort aiReportCommand = mock(AiReportCommandPort.class); private final ReportGraderPort aiReportGrader = mock(ReportGraderPort.class); @@ -55,7 +55,7 @@ void gradeBusinessPlan_createsNewReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); String extractedContent = "사업계획서 내용"; @@ -92,9 +92,9 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getBusinessPlanId()).thenReturn(planId); when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); - when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); + when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -103,7 +103,7 @@ void gradeBusinessPlan_createsNewReport() { assertThat(result).isNotNull(); verify(plan).updateStatus(PlanStatus.AI_REVIEWED); verify(aiReportCommand).save(any(AiReport.class)); - verify(businessPlanCommandLookUpPort).save(plan); + verify(businessPlanCommandLookupPort).save(plan); } @Test @@ -116,7 +116,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); AiReport existingReport = mock(AiReport.class); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(existingReport)); @@ -154,9 +154,9 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getBusinessPlanId()).thenReturn(planId); when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(existingReport)).thenReturn(existingReport); - when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); + when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -176,9 +176,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { Long memberId = 1L; BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(false); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -196,9 +196,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(false); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -217,7 +217,7 @@ void getAiReport_returnsResponse() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); String rawJson = """ { @@ -236,7 +236,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -257,10 +257,10 @@ void getAiReport_throwsExceptionWhenNotFound() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java index 61656949..7d951fa5 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java @@ -12,7 +12,7 @@ import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.businessplan.required.ChecklistGraderPort; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; @@ -50,8 +50,8 @@ ObjectMapper objectMapper() { } @Bean - MemberLookUpPort memberLookUpPort() { - return new MemberLookUpPort() { + MemberLookupPort memberLookupPort() { + return new MemberLookupPort() { @Override public Member findByIdOrThrow(Long memberId) { Member m = mock(Member.class); diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index 83b25714..3fc6c64d 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -21,7 +21,7 @@ import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.domain.businessplan.exception.BusinessPlanException; import starlight.shared.enumerate.SectionType; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.domain.member.entity.Member; import java.util.List; @@ -50,7 +50,7 @@ class BusinessPlanServiceUnitTest { private ObjectMapper objectMapper; @Mock - private MemberLookUpPort memberLookUpPort; + private MemberLookupPort memberLookupPort; @InjectMocks private BusinessPlanService sut; @@ -67,10 +67,10 @@ void setup() { when(objectMapper.writeValueAsString(any())).thenReturn("{}"); } catch (Exception ignored) { } - // memberLookUpPort 기본 스텁 + // memberLookupPort 기본 스텁 Member stubMember = mock(Member.class); when(stubMember.getName()).thenReturn("tester"); - when(memberLookUpPort.findByIdOrThrow(anyLong())).thenReturn(stubMember); + when(memberLookupPort.findByIdOrThrow(anyLong())).thenReturn(stubMember); } @Test