diff --git a/.github/workflows/ci-prd.yaml b/.github/workflows/ci-prd.yaml index 31ce928f..cae027c1 100644 --- a/.github/workflows/ci-prd.yaml +++ b/.github/workflows/ci-prd.yaml @@ -3,6 +3,8 @@ name: Deployment Workflow on: push: branches: [ "main" ] + pull_request: + branches: [ "main" ] jobs: build-and-push: diff --git a/.github/workflows/ci-stg.yaml b/.github/workflows/ci-stg.yaml index 69842721..36655ffb 100644 --- a/.github/workflows/ci-stg.yaml +++ b/.github/workflows/ci-stg.yaml @@ -3,8 +3,8 @@ name: Deployment Workflow on: push: branches: [ "develop" ] -# pull_request: -# branches: [ "develop" ] + pull_request: + branches: [ "develop" ] jobs: build-and-push: diff --git a/config b/config index a6482e68..9a66779b 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit a6482e68a406914b1a4badbcb235a62e05c80e35 +Subproject commit 9a66779b35ee2c8d9d4b90249fc732e7700dbc4e diff --git a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java index 7a22ab91..510d5181 100644 --- a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java +++ b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java @@ -5,10 +5,8 @@ import org.springframework.stereotype.Component; import starlight.adapter.ai.infra.OpenAiGenerator; import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.adapter.ai.util.BusinessPlanContentExtractor; -import starlight.application.aireport.dto.AiReportResponse; +import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.required.AiReportGrader; -import starlight.domain.businessplan.entity.BusinessPlan; /** * AI 리포트 채점을 오케스트레이션하는 컴포넌트 @@ -20,18 +18,12 @@ public class OpenAiReportGrader implements AiReportGrader { private final OpenAiGenerator chatClientGenerator; - private final BusinessPlanContentExtractor contentExtractor; private final AiReportResponseParser responseParser; @Override - public AiReportResponse grade(BusinessPlan businessPlan) { - // 1. BusinessPlan에서 컨텐츠 추출 - String businessPlanContent = contentExtractor.extractContent(businessPlan); + public AiReportResponse gradeContent(String content){ + String llmResponse = chatClientGenerator.generateReport(content); - // 2. LLM 호출 - String llmResponse = chatClientGenerator.generateReport(businessPlanContent); - - // 3. 응답 파싱 return responseParser.parse(llmResponse); } } diff --git a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java index c418866d..5bdb02b7 100644 --- a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java +++ b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java @@ -7,7 +7,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import starlight.application.aireport.dto.AiReportResponse; +import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.domain.aireport.entity.AiReport; import java.util.ArrayList; diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java index 35e5c35b..b8f0e84c 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -2,26 +2,28 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import starlight.adapter.auth.security.auth.AuthDetails; -import starlight.application.aireport.dto.AiReportResponse; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.provided.AiReportService; import starlight.shared.apiPayload.response.ApiResponse; @Validated @RestController @RequiredArgsConstructor -@RequestMapping("/v1/ai-reports/{planId}") +@RequestMapping("/v1/ai-reports") @Tag(name = "AI 리포트", description = "AI 리포트 채점 및 조회 API") public class AiReportController { private final AiReportService aiReportService; @Operation(summary = "사업계획서를 AI로 채점 및 생성합니다.") - @PostMapping("/grade") + @PostMapping("/evaluation/{planId}") public ApiResponse gradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId @@ -29,8 +31,21 @@ public ApiResponse gradeBusinessPlan( return ApiResponse.success(aiReportService.gradeBusinessPlan(planId, authDetails.getMemberId())); } + @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.") + @PostMapping("/evaluation/pdf") + public ApiResponse createAndGradeBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + ) { + return ApiResponse.success(aiReportService.createAndGradePdfBusinessPlan( + request.title(), + request.pdfUrl(), + authDetails.getMemberId() + )); + } + @Operation(summary = "AI 리포트를 조회합니다.") - @GetMapping + @GetMapping("/{planId}") public ApiResponse getAiReport( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java b/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java index 695b526e..99fa1cc3 100644 --- a/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java +++ b/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java @@ -40,6 +40,13 @@ public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2Authenticatio memberRepository.save(Member.newSocial(parsed.name(), parsed.email(), parsed.provider(), parsed.providerId(), null, MemberType.FOUNDER, parsed.profileImageUrl())) ); + String newImage = parsed.profileImageUrl(); + if (newImage != null && !newImage.isBlank() && (member.getProfileImageUrl() == null || !member.getProfileImageUrl().equals(newImage))) { + member.updateProfileImage(newImage); + + memberRepository.save(member); + } + return AuthDetails.of(member, oAuth2User.getAttributes(), parsed.nameAttributeKey()); } } diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java index a1facdaf..ce461cc3 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java @@ -1,6 +1,8 @@ package starlight.adapter.businessplan.persistence; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Repository; import starlight.application.businessplan.required.BusinessPlanQuery; import starlight.domain.businessplan.entity.BusinessPlan; @@ -29,4 +31,9 @@ public BusinessPlan save(BusinessPlan businessPlan) { public void delete(BusinessPlan businessPlan) { businessPlanRepository.delete(businessPlan); } + + @Override + public Page findPreviewPage(Long memberId, Pageable pageable) { + return businessPlanRepository.findAllByMemberIdOrderedByLastSavedAt(memberId, pageable); + } } diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java index 5b34b257..10beb7a6 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java @@ -1,7 +1,11 @@ package starlight.adapter.businessplan.persistence; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import starlight.domain.businessplan.entity.BusinessPlan; import java.util.Optional; @@ -10,4 +14,12 @@ public interface BusinessPlanRepository extends JpaRepository findById(Long id); + + @Query(""" + SELECT bp + FROM BusinessPlan bp + WHERE bp.memberId = :memberId + ORDER BY COALESCE(bp.modifiedAt, bp.createdAt) DESC, bp.id DESC + """) + Page findAllByMemberIdOrderedByLastSavedAt(@Param("memberId") Long memberId, Pageable pageable); } diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java index 7beeaddd..69f5e9fe 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java @@ -2,22 +2,27 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import starlight.adapter.auth.security.auth.AuthDetails; import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateRequest; -import starlight.adapter.businessplan.webapi.dto.BusinessPlanResponse; -import starlight.adapter.businessplan.webapi.dto.SubSectionRequest; -import starlight.application.businessplan.dto.SubSectionResponse; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.shared.apiPayload.response.ApiResponse; +import java.util.List; + @Validated @RestController @RequiredArgsConstructor @@ -28,83 +33,130 @@ public class BusinessPlanController { private final BusinessPlanService businessPlanService; private final ObjectMapper objectMapper; - @Operation(summary = "사업 계획서를 삭제합니다.") - @DeleteMapping("/{planId}") - public ApiResponse deleteBusinessPlan( + @GetMapping + @Operation(summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)") + public ApiResponse getBusinessPlanList( + @AuthenticationPrincipal AuthDetails authDetails, + @Parameter(description = "페이지 번호 (1 이상 정수)") @RequestParam(defaultValue = "1") int page, + @Parameter(description = "페이지 크기 (기본 3)") @RequestParam(defaultValue = "3") int size + ) { + int zeroBasedPage = Math.max(0, page - 1); + Pageable pageable = PageRequest.of(zeroBasedPage, size); + return ApiResponse.success(businessPlanService.getBusinessPlanList( + authDetails.getMemberId(), pageable + )); + } + + @GetMapping("/{planId}/subsections") + @Operation(summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)") + public ApiResponse getBusinessPlanDetail( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ) { + return ApiResponse.success(businessPlanService.getBusinessPlanDetail( + planId, authDetails.getMemberId() + )); + } + + @GetMapping("/{planId}/titles") + @Operation(summary = "사업 계획서의 제목을 조회합니다.") + public ApiResponse getBusinessPlanTitle( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - businessPlanService.deleteBusinessPlan(planId, authDetails.getMemberId()); - return ApiResponse.success(); + return ApiResponse.success(businessPlanService + .getBusinessPlanInfo(planId, authDetails.getMemberId()) + .title() + ); } @PostMapping @Operation(summary = "사업 계획서를 생성합니다.") - public ApiResponse createBusinessPlan( + public ApiResponse createBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails - ) { - BusinessPlan businessPlan = businessPlanService.createBusinessPlan(authDetails.getMemberId()); + ) { + return ApiResponse.success(businessPlanService.createBusinessPlan(authDetails.getMemberId())); + } - return ApiResponse.success(BusinessPlanResponse.from(businessPlan.getId(), businessPlan.getTitle(), businessPlan.getPlanStatus())); + @PostMapping("/pdf") + @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성합니다.") + public ApiResponse createBusinessPlanWithPdfAndAiReport( + @AuthenticationPrincipal AuthDetails authDetails, + @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + ) { + return ApiResponse.success(businessPlanService.createBusinessPlanWithPdf( + request.title(), request.pdfUrl(), authDetails.getMemberId() + )); } @PatchMapping("/{planId}") @Operation(summary = "사업 계획서 제목을 수정합니다.") - public ApiResponse updateBusinessPlanTitle( + public ApiResponse updateBusinessPlanTitle( @AuthenticationPrincipal AuthDetails authDetails, @RequestBody @Valid BusinessPlanCreateRequest request, @PathVariable Long planId ) { - BusinessPlan businessPlan = businessPlanService.updateBusinessPlanTitle(planId, authDetails.getMemberId(), request.title()); + return ApiResponse.success(businessPlanService.updateBusinessPlanTitle( + planId, request.title(), authDetails.getMemberId() + )); + } - return ApiResponse.success(BusinessPlanResponse.from(businessPlan.getId(), businessPlan.getTitle(), businessPlan.getPlanStatus())); + @Operation(summary = "사업 계획서를 삭제합니다.") + @DeleteMapping("/{planId}") + public ApiResponse deleteBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ) { + return ApiResponse.success(businessPlanService.deleteBusinessPlan( + planId, authDetails.getMemberId() + )); } @Operation(summary = "서브섹션을 생성 또는 수정합니다.") @PostMapping("/{planId}/subsections") - public ApiResponse createOrUpdateSubSection( + public ApiResponse upsertSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, - @Valid @RequestBody SubSectionRequest request + @Valid @RequestBody SubSectionCreateRequest request ) { - return ApiResponse.success(businessPlanService.createOrUpdateSubSection( + return ApiResponse.success(businessPlanService.upsertSubSection( planId, objectMapper.valueToTree(request), request.checks(), request.subSectionType(), authDetails.getMemberId() )); } @Operation(summary = "서브섹션을 조회합니다.") @GetMapping("/{planId}/subsections/{subSectionType}") - public ApiResponse getSubSection( + public ApiResponse getSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @PathVariable SubSectionType subSectionType ) { - return ApiResponse.success(businessPlanService.getSubSection( - planId, subSectionType, authDetails.getMemberId()) - ); + return ApiResponse.success(businessPlanService.getSubSectionDetail( + planId, subSectionType, authDetails.getMemberId() + )); } - @Operation(summary = "서브섹션을 삭제합니다.") - @DeleteMapping("/{planId}/subsections/{subSectionType}") - public ApiResponse deleteSubSection( + @Operation(summary = "서브섹션의 체크리스트를 점검 후 업데이트합니다.") + @PostMapping("/{planId}/subsections/check-and-update") + public ApiResponse> checkAndUpdateSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, - @PathVariable SubSectionType subSectionType + @Valid @RequestBody SubSectionCreateRequest request ) { - return ApiResponse.success(businessPlanService.deleteSubSection( - planId, subSectionType, authDetails.getMemberId()) - ); + return ApiResponse.success(businessPlanService.checkAndUpdateSubSection( + planId, objectMapper.valueToTree(request), request.subSectionType(), authDetails.getMemberId() + )); } - @Operation(summary = "서브섹션의 체크리스트를 점검 후 업데이트합니다.") - @PostMapping("/{planId}/subsections/check-and-update") - public ApiResponse> checkAndUpdateSubSection( + @Operation(summary = "서브섹션을 삭제합니다.") + @DeleteMapping("/{planId}/subsections/{subSectionType}") + public ApiResponse deleteSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, - @Valid @RequestBody SubSectionRequest request + @PathVariable SubSectionType subSectionType ) { - return ApiResponse.success(businessPlanService.checkAndUpdateSubSection( - planId, objectMapper.valueToTree(request), request.subSectionType(), authDetails.getMemberId() + return ApiResponse.success(businessPlanService.deleteSubSection( + planId, subSectionType, authDetails.getMemberId() )); } } diff --git a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java index 55b82edf..71df82ec 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java @@ -10,7 +10,7 @@ import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.webapi.swagger.SpellCheckApiDoc; import starlight.application.businessplan.required.SpellChecker; -import starlight.adapter.businessplan.webapi.dto.SubSectionRequest; +import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.shared.apiPayload.response.ApiResponse; @@ -26,9 +26,9 @@ public class SpellController implements SpellCheckApiDoc { @Override public ApiResponse check( - @Valid @RequestBody SubSectionRequest subSectionRequest + @Valid @RequestBody SubSectionCreateRequest subSectionCreateRequest ) { - String text = PlainTextExtractUtils.extractPlainText(objectMapper, subSectionRequest); + String text = PlainTextExtractUtils.extractPlainText(objectMapper, subSectionCreateRequest); List typos = spellChecker.check(text); String corrected = spellChecker.applyTopSuggestions(text, typos); diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java new file mode 100644 index 00000000..a2b85000 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java @@ -0,0 +1,13 @@ +package starlight.adapter.businessplan.webapi.dto; + +import jakarta.validation.constraints.NotBlank; + +public record BusinessPlanCreateWithPdfRequest( + @NotBlank(message = "제목은 필수입니다.") + String title, + + @NotBlank(message = "PDF URL은 필수입니다.") + String pdfUrl +) {} + + diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanResponse.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanResponse.java deleted file mode 100644 index 056e3cb2..00000000 --- a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package starlight.adapter.businessplan.webapi.dto; - -import starlight.domain.businessplan.enumerate.PlanStatus; - -public record BusinessPlanResponse ( - Long businessPlanId, - String title, - PlanStatus planStatus -){ - public static BusinessPlanResponse from(Long businessPlanId, String title, PlanStatus planStatus) { - return new BusinessPlanResponse(businessPlanId, title, planStatus); - } -} diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java similarity index 66% rename from src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionRequest.java rename to src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java index a438b4dd..4914fc90 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionRequest.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java @@ -1,6 +1,7 @@ package starlight.adapter.businessplan.webapi.dto; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import jakarta.validation.Valid; @@ -9,10 +10,10 @@ import java.util.List; -public record SubSectionRequest( +public record SubSectionCreateRequest( @NotNull SubSectionType subSectionType, @NotNull List checks, - @Valid @NotNull SubSectionRequest.Meta meta, + @Valid @NotNull SubSectionCreateRequest.Meta meta, @Valid @NotNull List<@Valid Block> blocks) { public record Meta( @NotBlank String author, @@ -20,7 +21,7 @@ public record Meta( } public record Block( - @Valid @NotNull SubSectionRequest.BlockMeta meta, + @Valid @NotNull SubSectionCreateRequest.BlockMeta meta, @Valid List<@Valid Content> content) { } @@ -30,30 +31,36 @@ public record BlockMeta( @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) @JsonSubTypes({ - @JsonSubTypes.Type(value = SubSectionRequest.TextItem.class, name = "text"), - @JsonSubTypes.Type(value = SubSectionRequest.ImageItem.class, name = "image"), - @JsonSubTypes.Type(value = SubSectionRequest.TableItem.class, name = "table") + @JsonSubTypes.Type(value = SubSectionCreateRequest.TextItem.class, name = "text"), + @JsonSubTypes.Type(value = SubSectionCreateRequest.ImageItem.class, name = "image"), + @JsonSubTypes.Type(value = SubSectionCreateRequest.TableItem.class, name = "table") }) public sealed interface Content - permits SubSectionRequest.TextItem, SubSectionRequest.ImageItem, SubSectionRequest.TableItem { + permits SubSectionCreateRequest.TextItem, SubSectionCreateRequest.ImageItem, SubSectionCreateRequest.TableItem { String type(); } public record TextItem( @NotBlank String type, - @NotBlank String value) implements SubSectionRequest.Content { + @NotBlank String value) implements SubSectionCreateRequest.Content { } public record ImageItem( @NotBlank String type, @NotBlank @Size(max = 1024) String src, - @Size(max = 255) String caption) implements SubSectionRequest.Content { + @JsonProperty(defaultValue = "400") Integer width, + @JsonProperty(defaultValue = "400") Integer height, + @Size(max = 255) String caption) implements SubSectionCreateRequest.Content { + public ImageItem { + width = width != null ? width : 400; + height = height != null ? height : 400; + } } public record TableItem( @NotBlank String type, @NotEmpty List<@NotBlank String> columns, - @NotEmpty List<@NotEmpty List> rows) implements SubSectionRequest.Content { + @NotEmpty List<@NotEmpty List> rows) implements SubSectionCreateRequest.Content { @AssertTrue(message = "table rows must match columns length") @JsonIgnore diff --git a/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java b/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java index 5b44ee89..b3fb621c 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java @@ -9,7 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.PostMapping; import starlight.adapter.businessplan.webapi.dto.SpellCheckResponse; -import starlight.adapter.businessplan.webapi.dto.SubSectionRequest; +import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; import starlight.shared.apiPayload.response.ApiResponse; @Tag(name = "사업계획서", description = "사업계획서 API") @@ -87,6 +87,6 @@ ApiResponse check( ) ) ) - @org.springframework.web.bind.annotation.RequestBody SubSectionRequest subSectionRequest + @org.springframework.web.bind.annotation.RequestBody SubSectionCreateRequest subSectionCreateRequest ); } \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index 2fe4d09a..e3a18e2b 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -24,12 +24,19 @@ public class ExpertJpa implements ExpertQuery { private final ExpertRepository repository; @Override - public Expert getOrThrow(Long id) { + public Expert findById(Long id) { return repository.findById(id).orElseThrow( () -> new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND) ); } + @Override + public Expert findByIdWithDetails(Long id) { + return repository.findByIdWithDetails(id).orElseThrow( + () -> new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND) + ); + } + @Override public List findAllWithDetails() { try { @@ -53,7 +60,7 @@ public List findByAllCategories(Collection categories) { @Override public Map findExpertMapByIds(Set expertIds) { - List experts = repository.findAllById(expertIds); + List experts = repository.findAllWithDetailsByIds(expertIds); return experts.stream() .collect(Collectors.toMap(Expert::getId, Function.identity())); diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java index 78bcb98e..dfe059d9 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java @@ -9,6 +9,8 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; +import java.util.Set; public interface ExpertRepository extends JpaRepository { @@ -16,6 +18,10 @@ public interface ExpertRepository extends JpaRepository { @EntityGraph(attributePaths = {"categories", "careers", "tags"}) List findAllWithDetails(); + @Query("select distinct e from Expert e where e.id in :expertIds") + @EntityGraph(attributePaths = {"categories", "careers", "tags"}) + List findAllWithDetailsByIds(Set expertIds); + @Query(""" select distinct e from Expert e where e.id in ( select e2.id @@ -28,4 +34,13 @@ having count(distinct c2) = :size) @EntityGraph(attributePaths = {"categories", "careers", "tags"}) List findByAllCategories(@Param("cats") Collection cats, @Param("size") long size); + + @Query(""" + select e from Expert e + left join fetch e.categories + left join fetch e.careers + left join fetch e.tags + where e.id = :id + """) + Optional findByIdWithDetails(@Param("id") Long id); } diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java index df5e4717..4b5cf662 100644 --- a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java +++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java @@ -5,7 +5,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.expert.dto.ExpertListResponse; +import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; import starlight.adapter.expert.webapi.swagger.ExpertQueryApiDoc; import starlight.application.expert.provided.ExpertFinder; import starlight.domain.expert.entity.Expert; @@ -23,13 +23,13 @@ public class ExpertController implements ExpertQueryApiDoc { private final ExpertFinder expertFinder; @GetMapping - public ApiResponse> search( + public ApiResponse> search( @RequestParam(name = "categories", required = false) Set categories ) { List experts = (categories == null || categories.isEmpty()) ? expertFinder.loadAll() : expertFinder.findByAllCategories(categories); - return ApiResponse.success(ExpertListResponse.fromAll(experts)); + return ApiResponse.success(ExpertDetailResponse.fromAll(experts)); } } diff --git a/src/main/java/starlight/adapter/expert/dto/ExpertListResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java similarity index 73% rename from src/main/java/starlight/adapter/expert/dto/ExpertListResponse.java rename to src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java index cd565719..f02ce085 100644 --- a/src/main/java/starlight/adapter/expert/dto/ExpertListResponse.java +++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java @@ -1,13 +1,12 @@ -package starlight.adapter.expert.dto; +package starlight.adapter.expert.webapi.dto; import starlight.domain.expert.entity.Expert; import starlight.domain.expert.enumerate.TagCategory; import java.util.Collection; import java.util.List; -import java.util.Set; -public record ExpertListResponse( +public record ExpertDetailResponse( Long id, @@ -27,13 +26,13 @@ public record ExpertListResponse( List categories ) { - public static ExpertListResponse from(Expert expert) { + public static ExpertDetailResponse from(Expert expert) { List categories = expert.getCategories().stream() .map(TagCategory::name) .distinct() .toList(); - return new ExpertListResponse( + return new ExpertDetailResponse( expert.getId(), expert.getName(), expert.getProfileImageUrl(), @@ -46,7 +45,7 @@ public static ExpertListResponse from(Expert expert) { ); } - public static List fromAll(Collection experts){ - return experts.stream().map(ExpertListResponse::from).toList(); + public static List fromAll(Collection experts){ + return experts.stream().map(ExpertDetailResponse::from).toList(); } } diff --git a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java index fdf3c3b0..99187fc4 100644 --- a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java +++ b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java @@ -9,7 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; -import starlight.adapter.expert.dto.ExpertListResponse; +import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; import starlight.domain.expert.enumerate.TagCategory; import starlight.shared.apiPayload.response.ApiResponse; @@ -36,7 +36,7 @@ public interface ExpertQueryApiDoc { description = "성공", content = @Content( mediaType = "application/json", - array = @ArraySchema(schema = @Schema(implementation = ExpertListResponse.class)), + array = @ArraySchema(schema = @Schema(implementation = ExpertDetailResponse.class)), examples = @ExampleObject( name = "성공 예시", value = """ @@ -70,7 +70,7 @@ public interface ExpertQueryApiDoc { ), }) @GetMapping - ApiResponse> search( + ApiResponse> search( @RequestParam(name = "categories", required = false) Set categories ); diff --git a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java index 69099016..5b62567c 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java @@ -6,7 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import starlight.adapter.expertReport.webapi.dto.ExpertReportResponse; -import starlight.adapter.expertReport.webapi.dto.SaveExpertReportRequest; +import starlight.adapter.expertReport.webapi.dto.UpsertExpertReportRequest; import starlight.adapter.expertReport.webapi.mapper.ExpertReportMapper; import starlight.application.expertReport.provided.ExpertReportService; import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto; @@ -25,7 +25,7 @@ public class ExpertReportController { private final ExpertReportMapper mapper; private final ExpertReportService expertReportService; - @Operation(summary = "전문가 리포트 목록을 조회합니다.") + @Operation(summary = "전문가 리포트 목록을 조회합니다. (사용자 사용)") @GetMapping public ApiResponse> getExpertReports( @RequestParam Long businessPlanId @@ -34,35 +34,35 @@ public ApiResponse> getExpertReports( .getExpertReportsWithExpertByBusinessPlanId(businessPlanId); List responses = dtos.stream() - .map(dto -> ExpertReportResponse.of( + .map(dto -> ExpertReportResponse.fromEntities( dto.report(), - dto.expert().getName() + dto.expert() )) .toList(); return ApiResponse.success(responses); } - @Operation(summary = "전문가 리포트를 조회합니다.") + @Operation(summary = "전문가 리포트를 조회합니다. (전문가 사용)") @GetMapping("/{token}") public ApiResponse getExpertReport( @PathVariable String token ) { ExpertReportWithExpertDto dto = expertReportService.getExpertReportWithExpert(token); - ExpertReportResponse response = ExpertReportResponse.of( + ExpertReportResponse response = ExpertReportResponse.fromEntities( dto.report(), - dto.expert().getName() + dto.expert() ); return ApiResponse.success(response); } - @Operation(summary = "전문가 리포트를 저장합니다.") + @Operation(summary = "전문가 리포트를 저장합니다 (전문가 사용)") @PostMapping("/{token}") public ApiResponse save( @PathVariable String token, - @Valid @RequestBody SaveExpertReportRequest request + @Valid @RequestBody UpsertExpertReportRequest request ) { List details = mapper.toEntityList(request.details()); diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java index 7f88ccb2..b18164ca 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java @@ -8,9 +8,6 @@ public record CreateExpertReportDetailRequest( @NotNull(message = "평가 타입은 필수입니다") CommentType commentType, - @NotBlank(message = "제목은 필수입니다") - String title, - @NotBlank(message = "내용은 필수입니다") String content ) { } \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java index cb48d05b..0390448b 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java @@ -6,14 +6,11 @@ public record ExpertReportDetailResponse( CommentType commentType, - String title, - String content ) { public static ExpertReportDetailResponse from(ExpertReportDetail detail) { return new ExpertReportDetailResponse( detail.getCommentType(), - detail.getTitle(), detail.getContent() ); } diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java index c16e165e..d53d86c8 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java @@ -1,12 +1,14 @@ package starlight.adapter.expertReport.webapi.dto; +import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; +import starlight.domain.expert.entity.Expert; import starlight.domain.expertReport.entity.ExpertReport; import starlight.domain.expertReport.enumerate.SubmitStatus; import java.util.List; public record ExpertReportResponse( - String expertName, + ExpertDetailResponse expertDetailResponse, SubmitStatus status, @@ -16,9 +18,9 @@ public record ExpertReportResponse( List details ) { - public static ExpertReportResponse of(ExpertReport report, String expertName) { + public static ExpertReportResponse fromEntities(ExpertReport report, Expert expert) { return new ExpertReportResponse( - expertName, + ExpertDetailResponse.from(expert), report.getSubmitStatus(), report.canEdit(), report.getOverallComment(), @@ -30,7 +32,7 @@ public static ExpertReportResponse of(ExpertReport report, String expertName) { public static ExpertReportResponse from(ExpertReport report) { return new ExpertReportResponse( - report.getToken(), + null, report.getSubmitStatus(), report.canEdit(), report.getOverallComment(), diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/SaveExpertReportRequest.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java similarity index 69% rename from src/main/java/starlight/adapter/expertReport/webapi/dto/SaveExpertReportRequest.java rename to src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java index 967abaed..1f2c429c 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/SaveExpertReportRequest.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java @@ -1,18 +1,16 @@ package starlight.adapter.expertReport.webapi.dto; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import starlight.domain.expertReport.enumerate.SaveType; import java.util.List; -public record SaveExpertReportRequest( +public record UpsertExpertReportRequest( @NotNull(message = "저장 유형은 필수입니다") SaveType saveType, String overallComment, - @NotEmpty(message = "최소 1개 이상의 평가 항목이 필요합니다") List<@Valid CreateExpertReportDetailRequest> details ) { } \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java index 53d63238..89cb75e0 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java @@ -11,7 +11,6 @@ public class ExpertReportMapper { public ExpertReportDetail toEntity(CreateExpertReportDetailRequest dto) { return ExpertReportDetail.create( dto.commentType(), - dto.title(), dto.content() ); } diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java new file mode 100644 index 00000000..3d5280aa --- /dev/null +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -0,0 +1,22 @@ +package starlight.adapter.member.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.member.required.MemberQuery; +import starlight.domain.member.entity.Member; +import starlight.domain.member.exception.MemberErrorType; +import starlight.domain.member.exception.MemberException; + +@Repository +@RequiredArgsConstructor +public class MemberJpa implements MemberQuery { + + private final MemberRepository memberRepository; + + @Override + public Member getOrThrow(Long id) { + return memberRepository.findById(id).orElseThrow( + () -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND) + ); + } +} diff --git a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java index 0af61ad7..0db896a8 100644 --- a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java +++ b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java @@ -1,17 +1,21 @@ package starlight.application.aireport; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.dto.AiReportResponse; import starlight.application.aireport.provided.AiReportService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.required.AiReportGrader; import starlight.application.aireport.required.AiReportQuery; +import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.infrastructure.provided.OcrProvider; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -26,40 +30,48 @@ public class AiReportServiceImpl implements AiReportService { private final BusinessPlanQuery businessPlanQuery; + private final BusinessPlanService businessPlanService; private final AiReportQuery aiReportQuery; private final AiReportGrader aiReportGrader; private final ObjectMapper objectMapper; + private final OcrProvider ocrProvider; private final AiReportResponseParser responseParser; + private final BusinessPlanContentExtractor contentExtractor; @Override public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { + BusinessPlan plan = businessPlanQuery.getOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); + checkBusinessPlanWritingCompleted(plan); - // 권한 및 작성 완료 검증 (LLM 호출 전에 검증) - Optional existingReport = getOwnedAiReport(plan, memberId); + AiReportResponse gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan)); - // LLM 채점 - AiReportResponse gradingResult = aiReportGrader.grade(plan); + String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); - // AiReportResponse를 JsonNode로 변환하여 RawJson - JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); - String rawJsonString; - try { - rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to convert JsonNode to string", e); - } + AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - // AiReport 생성 또는 업데이트 - AiReport aiReport; + return responseParser.toResponse(aiReportQuery.save(aiReport)); + } - if (existingReport.isPresent()) { - aiReport = existingReport.get(); - aiReport.update(rawJsonString); - } else { - aiReport = AiReport.create(planId, rawJsonString); - plan.updateStatus(PlanStatus.AI_REVIEWED); - } + @Override + public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { + + BusinessPlanResponse.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf( + title, + pdfUrl, + memberId + ); + Long businessPlanId = businessPlanResult.businessPlanId(); + BusinessPlan plan = businessPlanQuery.getOrThrow(businessPlanId); + + String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); + + AiReportResponse gradingResult = aiReportGrader.gradeContent(pdfText); + + String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + + AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); return responseParser.toResponse(aiReportQuery.save(aiReport)); } @@ -68,23 +80,50 @@ public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { @Transactional(readOnly = true) public AiReportResponse getAiReport(Long planId, Long memberId) { BusinessPlan plan = businessPlanQuery.getOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); - AiReport aiReport = getOwnedAiReport(plan, memberId) + AiReport aiReport = aiReportQuery.findByBusinessPlanId(planId) .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); return responseParser.toResponse(aiReport); } - private Optional getOwnedAiReport(BusinessPlan plan, Long memberId) { + private String getRawJsonAiReportResponseFromGradingResult(AiReportResponse gradingResult) { + JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); + String rawJsonString; + try { + rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to convert JsonNode to string", e); + } + return rawJsonString; + } + + private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) { + Optional existingReport = aiReportQuery.findByBusinessPlanId(plan.getId()); - // 소유자 검증 및 작성 완료 검증 + AiReport aiReport; + if (existingReport.isPresent()) { + aiReport = existingReport.get(); + aiReport.update(rawJsonString); + } else { + aiReport = AiReport.create(plan.getId(), rawJsonString); + } + plan.updateStatus(PlanStatus.AI_REVIEWED); + businessPlanQuery.save(plan); + + return aiReport; + } + + private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) { if (!plan.isOwnedBy(memberId)) { throw new AiReportException(AiReportErrorType.UNAUTHORIZED_ACCESS); } + } + + private void checkBusinessPlanWritingCompleted(BusinessPlan plan) { if (!plan.areWritingCompleted()) { throw new AiReportException(AiReportErrorType.NOT_READY_FOR_AI_REPORT); } - - return aiReportQuery.findByBusinessPlanId(plan.getId()); } } diff --git a/src/main/java/starlight/application/aireport/provided/AiReportService.java b/src/main/java/starlight/application/aireport/provided/AiReportService.java index 18e46235..6c618fd6 100644 --- a/src/main/java/starlight/application/aireport/provided/AiReportService.java +++ b/src/main/java/starlight/application/aireport/provided/AiReportService.java @@ -1,10 +1,11 @@ package starlight.application.aireport.provided; -import starlight.application.aireport.dto.AiReportResponse; +import starlight.application.aireport.provided.dto.AiReportResponse; public interface AiReportService { AiReportResponse gradeBusinessPlan(Long businessPlanId, Long memberId); - AiReportResponse getAiReport(Long businessPlanId, Long memberId); -} + AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId); + AiReportResponse getAiReport(Long businessPlanId, Long memberId); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/dto/AiReportResponse.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java similarity index 73% rename from src/main/java/starlight/application/aireport/dto/AiReportResponse.java rename to src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java index 02901edd..93ec0c04 100644 --- a/src/main/java/starlight/application/aireport/dto/AiReportResponse.java +++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java @@ -1,4 +1,4 @@ -package starlight.application.aireport.dto; +package starlight.application.aireport.provided.dto; import com.fasterxml.jackson.annotation.JsonRawValue; import java.util.List; @@ -41,11 +41,8 @@ public static AiReportResponse fromGradingResult( List strengths, List weaknesses ) { - Integer totalScore = (problemRecognitionScore != null ? problemRecognitionScore : 0) + - (feasibilityScore != null ? feasibilityScore : 0) + - (growthStrategyScore != null ? growthStrategyScore : 0) + - (teamCompetenceScore != null ? teamCompetenceScore : 0); - + Integer totalScore = sumTotalScore(problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore); + return new AiReportResponse( null, null, @@ -59,5 +56,12 @@ public static AiReportResponse fromGradingResult( weaknesses ); } + + private static Integer sumTotalScore(Integer problemRecognitionScore, Integer feasibilityScore, Integer growthStrategyScore, Integer teamCompetenceScore) { + return (problemRecognitionScore != null ? problemRecognitionScore : 0) + + (feasibilityScore != null ? feasibilityScore : 0) + + (growthStrategyScore != null ? growthStrategyScore : 0) + + (teamCompetenceScore != null ? teamCompetenceScore : 0); + } } diff --git a/src/main/java/starlight/application/aireport/required/AiReportGrader.java b/src/main/java/starlight/application/aireport/required/AiReportGrader.java index eb70ef5c..0ba2d255 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportGrader.java +++ b/src/main/java/starlight/application/aireport/required/AiReportGrader.java @@ -1,8 +1,7 @@ package starlight.application.aireport.required; -import starlight.application.aireport.dto.AiReportResponse; -import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.application.aireport.provided.dto.AiReportResponse; public interface AiReportGrader { - AiReportResponse grade(BusinessPlan businessPlan); + AiReportResponse gradeContent(String content); } \ No newline at end of file diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java index 2266c16f..905aa1b4 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java @@ -5,22 +5,29 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.application.businessplan.dto.SubSectionResponse; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; import starlight.application.businessplan.provided.BusinessPlanService; import starlight.application.businessplan.required.BusinessPlanQuery; import starlight.application.businessplan.required.ChecklistGrader; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.application.businessplan.util.SubSectionSupportUtils; +import starlight.application.member.required.MemberQuery; import starlight.domain.businessplan.entity.*; import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.domain.member.entity.Member; import starlight.shared.enumerate.SectionType; import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.domain.businessplan.exception.BusinessPlanErrorType; import starlight.domain.businessplan.exception.BusinessPlanException; +import java.util.Arrays; import java.util.List; +import java.util.Objects; @Service @RequiredArgsConstructor @@ -28,34 +35,88 @@ public class BusinessPlanServiceImpl implements BusinessPlanService { private final BusinessPlanQuery businessPlanQuery; + private final MemberQuery memberQuery; private final ChecklistGrader checklistGrader; private final ObjectMapper objectMapper; @Override - public BusinessPlan createBusinessPlan(Long memberId) { - BusinessPlan plan = BusinessPlan.create(memberId); + public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { + Member member = memberQuery.getOrThrow(memberId); - return businessPlanQuery.save(plan); + String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; + + BusinessPlan plan = BusinessPlan.create(planTitle, memberId); + + return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "Business plan created"); } @Override - public void deleteBusinessPlan(Long planId, Long memberId) { + public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf( + title, + memberId, + pdfUrl + ); + + return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "PDF Business plan created"); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - businessPlanQuery.delete(plan); + return BusinessPlanResponse.Result.from(plan, "Business plan retrieved"); } @Override - public BusinessPlan updateBusinessPlanTitle(Long planId, Long memberId, String title) { + @Transactional(readOnly = true) + public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + List subSectionDetailList = Arrays.stream(SubSectionType.values()) + .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type)) + .filter(Objects::nonNull) + .map(SubSectionResponse.Detail::from) + .toList(); + + return BusinessPlanResponse.Detail.from(plan, subSectionDetailList); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { + Page page = businessPlanQuery.findPreviewPage(memberId, pageable); + List content = page.getContent().stream() + .map(BusinessPlanResponse.Preview::from) + .toList(); + + return BusinessPlanResponse.PreviewPage.from(content, page); + } + + @Override + public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); plan.updateTitle(title); - return businessPlanQuery.save(plan); + businessPlanQuery.save(plan); + + return plan.getTitle(); + } + + @Override + public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + BusinessPlanResponse.Result result = BusinessPlanResponse.Result.from(plan, "Business plan deleted"); + businessPlanQuery.delete(plan); + + return result; } @Override - public SubSectionResponse.Created createOrUpdateSubSection( + public SubSectionResponse.Result upsertSubSection( Long planId, JsonNode jsonNode, List checks, @@ -71,33 +132,32 @@ public SubSectionResponse.Created createOrUpdateSubSection( String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); - SubSection targetSubSection; String message; if (subSection == null) { SubSection newSubSection = SubSection.create(subSectionType, content, rawJsonStr, checks); section.putSubSection(newSubSection); - targetSubSection = newSubSection; - message = "created"; + message = "Subsection created"; } else { subSection.update(content, rawJsonStr, checks); - targetSubSection = subSection; - message = "updated"; + message = "Subsection updated"; } if (plan.areWritingCompleted()) { plan.updateStatus(PlanStatus.WRITTEN_COMPLETED); - message = "writing completed"; + message = "Subsection writing completed"; } - businessPlanQuery.save(plan); + BusinessPlan savedPlan = businessPlanQuery.save(plan); + SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType) + .getSubSectionByType(subSectionType); - return SubSectionResponse.Created.create(subSectionType, targetSubSection.getId(), message); + return SubSectionResponse.Result.from(persistedSubSection, message); } @Override @Transactional(readOnly = true) - public SubSectionResponse.Retrieved getSubSection(Long planId, SubSectionType subSectionType, Long memberId) { + public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); SectionType sectionType = subSectionType.getSectionType(); @@ -106,31 +166,15 @@ public SubSectionResponse.Retrieved getSubSection(Long planId, SubSectionType su throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); } - return SubSectionResponse.Retrieved.create( - "retrieved", - subSection.getRawJson().asTree() - ); + return SubSectionResponse.Detail.from(subSection); } @Override - public SubSectionResponse.Deleted deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - SectionType sectionType = subSectionType.getSectionType(); - BaseSection section = getSectionByPlanAndType(plan, sectionType); - SubSection target = section.getSubSectionByType(subSectionType); - if (target == null) { - throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); - } - section.removeSubSection(subSectionType); - - businessPlanQuery.save(plan); - - return SubSectionResponse.Deleted.create(subSectionType, null, "deleted"); - } - - @Override - public List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, Long memberId) { + public List checkAndUpdateSubSection( + Long planId, + JsonNode jsonNode, + SubSectionType subSectionType, + Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); SectionType sectionType = subSectionType.getSectionType(); @@ -141,11 +185,9 @@ public List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, Su String newContent = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); - // 이전 정보 추출 String previousContent = subSection.getContent(); List previousChecks = subSection.getChecks(); - // 통합된 check 메소드 사용 (이전 정보가 없으면 null 전달) List checks = checklistGrader.check(subSectionType, newContent, previousContent, previousChecks); SubSectionSupportUtils.requireSize(checks, SubSection.getCHECKLIST_SIZE()); @@ -158,11 +200,28 @@ public List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, Su return checks; } + @Override + public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + BaseSection section = getSectionByPlanAndType(plan, sectionType); + SubSection target = section.getSubSectionByType(subSectionType); + if (target == null) { + throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); + } + SubSectionResponse.Result result = SubSectionResponse.Result.from(target, "Subsection deleted"); + section.removeSubSection(subSectionType); + + businessPlanQuery.save(plan); + + return result; + } + private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List checks) { ObjectNode updatedJsonNode = (ObjectNode) objectMapper.valueToTree(jsonNode); - // 기존 checks 배열이 있으면 가져오고 업데이트 ArrayNode checkListArray; if (updatedJsonNode.has("checks") && updatedJsonNode.get("checks").isArray()) { checkListArray = (ArrayNode) updatedJsonNode.get("checks"); @@ -184,7 +243,7 @@ private BusinessPlan getOwnedBusinessPlanOrThrow(Long planId, Long memberId) { return businessPlan; } - private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type){ + private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type) { return switch (type) { case OVERVIEW -> plan.getOverview(); case PROBLEM_RECOGNITION -> plan.getProblemRecognition(); diff --git a/src/main/java/starlight/application/businessplan/dto/SubSectionResponse.java b/src/main/java/starlight/application/businessplan/dto/SubSectionResponse.java deleted file mode 100644 index dcb2defd..00000000 --- a/src/main/java/starlight/application/businessplan/dto/SubSectionResponse.java +++ /dev/null @@ -1,39 +0,0 @@ -package starlight.application.businessplan.dto; - -import com.fasterxml.jackson.databind.JsonNode; -import starlight.domain.businessplan.enumerate.SubSectionType; -import java.util.List; - -public record SubSectionResponse() { - - public record Created( - SubSectionType subSectionType, - Long subSectionId, - String message) { - public static SubSectionResponse.Created create( - SubSectionType subSectionType, Long subSectionId, String message - ) { - return new SubSectionResponse.Created(subSectionType, subSectionId, message); - } - } - - public record Retrieved( - String message, - JsonNode content - ) { - public static SubSectionResponse.Retrieved create(String message, JsonNode content) { - return new SubSectionResponse.Retrieved(message, content); - } - } - - public record Deleted( - SubSectionType subSectionType, - Long subSectionId, - String message - ) { - public static SubSectionResponse.Deleted create( - SubSectionType subSectionType, Long subSectionId, String message) { - return new SubSectionResponse.Deleted(subSectionType, subSectionId, message); - } - } -} \ No newline at end of file diff --git a/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java index e2835ea9..79f5d48f 100644 --- a/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java +++ b/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java @@ -1,25 +1,38 @@ package starlight.application.businessplan.provided; import com.fasterxml.jackson.databind.JsonNode; -import starlight.application.businessplan.dto.SubSectionResponse; -import starlight.domain.businessplan.entity.BusinessPlan; +import org.springframework.data.domain.Pageable; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; public interface BusinessPlanService { - BusinessPlan createBusinessPlan(Long memberId); + BusinessPlanResponse.Result createBusinessPlan(Long memberId); - void deleteBusinessPlan(Long planId, Long memberId); + BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); - BusinessPlan updateBusinessPlanTitle(Long planId, Long memberId, String title); + BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId); - SubSectionResponse.Created createOrUpdateSubSection(Long planId, JsonNode jsonNode, List checks, SubSectionType subSectionType, Long memberId); + BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId); - SubSectionResponse.Retrieved getSubSection(Long planId, SubSectionType subSectionType, Long memberId); + BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable); + + String updateBusinessPlanTitle(Long planId, String title, Long memberId); + + BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId); + + SubSectionResponse.Result upsertSubSection(Long planId, JsonNode jsonNode, List checks, + SubSectionType subSectionType, Long memberId); + + SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId); + + List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, + Long memberId); + + SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId); - SubSectionResponse.Deleted deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId); - List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, Long memberId); } diff --git a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java new file mode 100644 index 00000000..6f837466 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java @@ -0,0 +1,93 @@ +package starlight.application.businessplan.provided.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.data.domain.Page; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record BusinessPlanResponse() { + + public record Result( + Long businessPlanId, + String title, + PlanStatus planStatus, + String message + ) { + public static Result from(BusinessPlan businessPlan, String message) { + return new Result( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.getPlanStatus(), + message + ); + } + } + + public record Detail( + Long businessPlanId, + String title, + PlanStatus planStatus, + List subSectionDetailList + ) { + public static Detail from( + BusinessPlan businessPlan, + List subSectionDetailList + ) { + return new Detail( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.getPlanStatus(), + subSectionDetailList + ); + } + } + + public record Preview( + Long businessPlanId, + String title, + String pdfUrl, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime lastSavedAt, + PlanStatus planStatus + ) { + public static Preview from(BusinessPlan businessPlan) { + LocalDateTime lastSavedAt = businessPlan.getModifiedAt() != null + ? businessPlan.getModifiedAt() + : businessPlan.getCreatedAt(); + + return new Preview( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.isPdfBased() ? businessPlan.getPdfUrl() : null, + lastSavedAt, + businessPlan.getPlanStatus() + ); + } + } + + public record PreviewPage( + List content, + int page, + int size, + int totalPages, + long totalElements, + int numberOfElements, + boolean first, + boolean last + ) { + public static PreviewPage from(List content, Page page) { + return new BusinessPlanResponse.PreviewPage( + content, + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumberOfElements(), + page.isFirst(), + page.isLast() + ); + } + } +} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java new file mode 100644 index 00000000..019721d6 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java @@ -0,0 +1,39 @@ +package starlight.application.businessplan.provided.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import starlight.domain.businessplan.entity.SubSection; +import starlight.domain.businessplan.enumerate.SubSectionType; + +public record SubSectionResponse() { + + public record Result( + SubSectionType subSectionType, + Long subSectionId, + String message + ) { + public static Result from( + SubSection subSection, + String message + ) { + return new Result( + subSection.getSubSectionType(), + subSection.getId(), + message + ); + } + } + + public record Detail( + SubSectionType subSectionType, + Long subSectionId, + JsonNode content + ) { + public static Detail from(SubSection subSection) { + return new Detail( + subSection.getSubSectionType(), + subSection.getId(), + subSection.getRawJson().asTree() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java index 87c440fe..1fe30fa9 100644 --- a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java @@ -1,5 +1,7 @@ package starlight.application.businessplan.required; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; import starlight.domain.businessplan.entity.BusinessPlan; public interface BusinessPlanQuery { @@ -9,4 +11,6 @@ public interface BusinessPlanQuery { BusinessPlan save(BusinessPlan businessPlan); void delete(BusinessPlan businessPlan); + + Page findPreviewPage(Long memberId, Pageable pageable); } diff --git a/src/main/java/starlight/adapter/ai/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java similarity index 98% rename from src/main/java/starlight/adapter/ai/util/BusinessPlanContentExtractor.java rename to src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java index 118be2f3..5f3e2eed 100644 --- a/src/main/java/starlight/adapter/ai/util/BusinessPlanContentExtractor.java +++ b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java @@ -1,4 +1,4 @@ -package starlight.adapter.ai.util; +package starlight.application.businessplan.util; import org.springframework.stereotype.Component; import starlight.domain.businessplan.entity.BaseSection; diff --git a/src/main/java/starlight/application/expert/ExpertQueryService.java b/src/main/java/starlight/application/expert/ExpertQueryService.java index ee5b0081..c21c258e 100644 --- a/src/main/java/starlight/application/expert/ExpertQueryService.java +++ b/src/main/java/starlight/application/expert/ExpertQueryService.java @@ -22,7 +22,12 @@ public class ExpertQueryService implements ExpertFinder { @Override public Expert findById(Long id) { - return expertQuery.getOrThrow(id); + return expertQuery.findById(id); + } + + @Override + public Expert findByIdWithDetails(Long id) { + return expertQuery.findByIdWithDetails(id); } @Override diff --git a/src/main/java/starlight/application/expert/provided/ExpertFinder.java b/src/main/java/starlight/application/expert/provided/ExpertFinder.java index 1f420639..5fa1a25a 100644 --- a/src/main/java/starlight/application/expert/provided/ExpertFinder.java +++ b/src/main/java/starlight/application/expert/provided/ExpertFinder.java @@ -11,6 +11,8 @@ public interface ExpertFinder { Expert findById(Long id); + Expert findByIdWithDetails(Long id); + List loadAll(); List findByAllCategories(Collection categories); diff --git a/src/main/java/starlight/application/expert/required/ExpertQuery.java b/src/main/java/starlight/application/expert/required/ExpertQuery.java index 3024326d..925dcd7a 100644 --- a/src/main/java/starlight/application/expert/required/ExpertQuery.java +++ b/src/main/java/starlight/application/expert/required/ExpertQuery.java @@ -10,11 +10,13 @@ public interface ExpertQuery { + Expert findById(Long id); + + Expert findByIdWithDetails(Long id); + Map findExpertMapByIds(Set expertIds); List findAllWithDetails(); List findByAllCategories(Collection categories); - - Expert getOrThrow(Long id); } diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java index 0ca156d6..2f6e3e6e 100644 --- a/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java +++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java @@ -14,6 +14,7 @@ import starlight.application.expertApplication.required.ExpertApplicationQuery; import starlight.application.expertReport.provided.ExpertReportService; import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; import starlight.domain.expert.entity.Expert; import starlight.domain.expertApplication.entity.ExpertApplication; import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; @@ -47,7 +48,9 @@ public void requestFeedback(Long expertId, Long planId, MultipartFile file, Stri validateFile(file); BusinessPlan plan = planQuery.getOrThrow(planId); - Expert expert = expertQuery.getOrThrow(expertId); + Expert expert = expertQuery.findById(expertId); + + plan.updateStatus(PlanStatus.EXPERT_MATCHED); registerApplicationRecord(expertId, planId); diff --git a/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java b/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java index 5a82a63f..44295a5d 100644 --- a/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java +++ b/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java @@ -5,10 +5,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import starlight.application.businessplan.required.BusinessPlanQuery; import starlight.application.expert.provided.ExpertFinder; import starlight.application.expertReport.provided.ExpertReportService; import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto; import starlight.application.expertReport.required.ExpertReportQuery; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; import starlight.domain.expert.entity.Expert; import starlight.domain.expertReport.entity.ExpertReport; import starlight.domain.expertReport.entity.ExpertReportDetail; @@ -36,6 +39,7 @@ public class ExpertReportServiceImpl implements ExpertReportService { private final ExpertReportQuery expertReportQuery; private final ExpertFinder expertFinder; + private final BusinessPlanQuery businessPlanQuery; private final SecureRandom secureRandom = new SecureRandom(); @Override @@ -64,8 +68,15 @@ public ExpertReport saveReport( report.updateDetails(details); switch (saveType) { - case TEMPORARY -> report.temporarySave(); - case FINAL -> report.submit(); + case TEMPORARY -> { + report.temporarySave(); + } + case FINAL -> { + report.submit(); + BusinessPlan plan = businessPlanQuery.getOrThrow(report.getBusinessPlanId()); + plan.updateStatus(PlanStatus.FINALIZED); + } + } return expertReportQuery.save(report); @@ -76,7 +87,7 @@ public ExpertReportWithExpertDto getExpertReportWithExpert(String token) { ExpertReport report = expertReportQuery.findByTokenWithDetails(token); report.incrementViewCount(); - Expert expert = expertFinder.findById(report.getExpertId()); + Expert expert = expertFinder.findByIdWithDetails(report.getExpertId()); return ExpertReportWithExpertDto.of(report, expert); } diff --git a/src/main/java/starlight/application/member/required/MemberQuery.java b/src/main/java/starlight/application/member/required/MemberQuery.java new file mode 100644 index 00000000..02691c80 --- /dev/null +++ b/src/main/java/starlight/application/member/required/MemberQuery.java @@ -0,0 +1,8 @@ +package starlight.application.member.required; + +import starlight.domain.member.entity.Member; + +public interface MemberQuery { + + Member getOrThrow(Long id); +} diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 2a206743..45856d33 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -73,6 +73,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/v1/auth/**","/v1/user/**", "/v1/experts").permitAll() .requestMatchers("/login/**", "/oauth2/**", "/login/oauth2/**", "/public/**").permitAll() .requestMatchers("/v3/api-docs/**", "/v1/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**").permitAll() + + // Expert Application + .requestMatchers(HttpMethod.GET, "/v1/expert-reports/*").permitAll() + .requestMatchers(HttpMethod.POST, "/v1/expert-reports/*").permitAll() + .anyRequest().authenticated()) .oauth2Login(oauth -> oauth .loginPage("/login/oauth2/code/kakao") @@ -83,7 +88,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { response.sendError(HttpServletResponse.SC_UNAUTHORIZED); }) ); - return http.build(); } diff --git a/src/main/java/starlight/domain/businessplan/entity/BusinessPlan.java b/src/main/java/starlight/domain/businessplan/entity/BusinessPlan.java index 5e904836..d58a9cf3 100644 --- a/src/main/java/starlight/domain/businessplan/entity/BusinessPlan.java +++ b/src/main/java/starlight/domain/businessplan/entity/BusinessPlan.java @@ -15,7 +15,7 @@ public class BusinessPlan extends AbstractEntity { @Column(nullable = false) private Long memberId; - @Column + @Column(nullable = false) private String title; @Column(length = 512) @@ -40,10 +40,12 @@ public class BusinessPlan extends AbstractEntity { @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "businessPlan") private TeamCompetence teamCompetence; - public static BusinessPlan create(Long memberId) { + public static BusinessPlan create(String title, Long memberId) { + Assert.notNull(title, "title must not be null"); Assert.notNull(memberId, "memberId must not be null"); BusinessPlan businessPlan = new BusinessPlan(); + businessPlan.title = title; businessPlan.memberId = memberId; businessPlan.planStatus = PlanStatus.STARTED; @@ -52,14 +54,16 @@ public static BusinessPlan create(Long memberId) { return businessPlan; } - public static BusinessPlan createWithPdf(String title, Long memberId, String pdfUrl, PlanStatus planStatus) { + public static BusinessPlan createWithPdf(String title, Long memberId, String pdfUrl) { + Assert.notNull(title, "title must not be null"); Assert.notNull(memberId, "memberId must not be null"); + Assert.notNull(pdfUrl, "pdfUrl must not be null"); BusinessPlan businessPlan = new BusinessPlan(); businessPlan.title = title; businessPlan.memberId = memberId; businessPlan.pdfUrl = pdfUrl; - businessPlan.planStatus = (planStatus != null) ? planStatus : PlanStatus.STARTED; + businessPlan.planStatus = PlanStatus.WRITTEN_COMPLETED; businessPlan.initializeSections(); @@ -70,6 +74,10 @@ public boolean isOwnedBy(Long memberId) { return this.memberId.equals(memberId); } + public boolean isPdfBased() { + return this.pdfUrl != null; + } + public void updateTitle(String title) { this.title = title; } diff --git a/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java b/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java index 39df3483..0a985304 100644 --- a/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java +++ b/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java @@ -17,26 +17,20 @@ public class ExpertReportDetail extends AbstractEntity { @Column(nullable = false, length = 30) private CommentType commentType; - @Column(nullable = false) - private String title; - @Column(columnDefinition = "TEXT", nullable = false) private String content; - public static ExpertReportDetail create(CommentType commentType, String title, String content) { + public static ExpertReportDetail create(CommentType commentType, String content) { Assert.notNull(commentType, "commentType은 필수입니다"); - Assert.hasText(title, "title은 필수입니다"); Assert.hasText(content, "content는 필수입니다"); ExpertReportDetail detail = new ExpertReportDetail(); detail.commentType = commentType; - detail.title = title; detail.content = content; return detail; } - public void update(String title, String content) { - this.title = title; + public void update(String content) { this.content = content; } } diff --git a/src/main/java/starlight/domain/member/entity/Member.java b/src/main/java/starlight/domain/member/entity/Member.java index cde8748a..f67d9678 100644 --- a/src/main/java/starlight/domain/member/entity/Member.java +++ b/src/main/java/starlight/domain/member/entity/Member.java @@ -5,6 +5,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.SQLDelete; +import org.springframework.util.Assert; import starlight.domain.member.enumerate.MemberType; import starlight.shared.AbstractEntity; @@ -14,7 +15,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member extends AbstractEntity { - @Column(columnDefinition = "varchar(320)") + @Column(nullable = false, columnDefinition = "varchar(320)") private String name; @Column(nullable = false, columnDefinition = "varchar(320)") @@ -47,7 +48,7 @@ public static Member create(String name, String email, String phoneNumber, Membe member.memberType = memberType != null ? memberType : MemberType.FOUNDER; member.credential = credential; member.provider = "starlight"; - member.providerId = profileImageUrl; + member.profileImageUrl = profileImageUrl; return member; } @@ -60,4 +61,10 @@ public static Member newSocial(String name, String email, String provider, return member; } + + public void updateProfileImage(String profileImageUrl) { + Assert.notNull(profileImageUrl, "profileImageUrl must not be null"); + + this.profileImageUrl = profileImageUrl; + } } diff --git a/src/main/resources/templates/feedback-request.html b/src/main/resources/templates/feedback-request.html index f3e10990..19ea41b8 100644 --- a/src/main/resources/templates/feedback-request.html +++ b/src/main/resources/templates/feedback-request.html @@ -1,40 +1,159 @@ - + + 사업계획서 피드백 요청 + - -
-

멘토님, 안녕하세요!

-

님의 사업계획서가 도착했습니다.

+ + + + + +
+ + + + + + + + + - - + + + + +
- + \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java index b559377b..c26b05d8 100644 --- a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java +++ b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java @@ -4,9 +4,7 @@ import org.junit.jupiter.api.Test; import starlight.adapter.ai.infra.OpenAiGenerator; import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.adapter.ai.util.BusinessPlanContentExtractor; -import starlight.application.aireport.dto.AiReportResponse; -import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.application.aireport.provided.dto.AiReportResponse; import java.util.List; @@ -18,11 +16,10 @@ class OpenAiReportGraderTest { @Test - @DisplayName("BusinessPlan을 채점하여 AiReportResponse를 반환한다") - void grade_returnsAiReportResponse() { + @DisplayName("컨텐츠를 채점하여 AiReportResponse를 반환한다") + void gradeContent_returnsAiReportResponse() { // given - BusinessPlan businessPlan = mock(BusinessPlan.class); - String extractedContent = "사업계획서 내용"; + String content = "사업계획서 내용"; String llmResponse = """ { "problemRecognitionScore": 20, @@ -44,11 +41,8 @@ void grade_returnsAiReportResponse() { } """; - BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); - when(contentExtractor.extractContent(businessPlan)).thenReturn(extractedContent); - OpenAiGenerator generator = mock(OpenAiGenerator.class); - when(generator.generateReport(extractedContent)).thenReturn(llmResponse); + when(generator.generateReport(content)).thenReturn(llmResponse); AiReportResponseParser parser = mock(AiReportResponseParser.class); AiReportResponse expectedResponse = AiReportResponse.fromGradingResult( @@ -59,10 +53,10 @@ void grade_returnsAiReportResponse() { ); when(parser.parse(llmResponse)).thenReturn(expectedResponse); - OpenAiReportGrader sut = new OpenAiReportGrader(generator, contentExtractor, parser); + OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); // when - AiReportResponse result = sut.grade(businessPlan); + AiReportResponse result = sut.gradeContent(content); // then assertThat(result).isNotNull(); @@ -75,37 +69,31 @@ void grade_returnsAiReportResponse() { assertThat(result.weaknesses()).hasSize(1); assertThat(result.sectionScores()).hasSize(1); - verify(contentExtractor).extractContent(businessPlan); - verify(generator).generateReport(extractedContent); + verify(generator).generateReport(content); verify(parser).parse(llmResponse); } @Test @DisplayName("각 컴포넌트가 순서대로 호출된다") - void grade_callsComponentsInOrder() { + void gradeContent_callsComponentsInOrder() { // given - BusinessPlan businessPlan = mock(BusinessPlan.class); - String extractedContent = "사업계획서 내용"; + String content = "사업계획서 내용"; String llmResponse = "{}"; - BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); - when(contentExtractor.extractContent(any())).thenReturn(extractedContent); - OpenAiGenerator generator = mock(OpenAiGenerator.class); when(generator.generateReport(any())).thenReturn(llmResponse); AiReportResponseParser parser = mock(AiReportResponseParser.class); when(parser.parse(any())).thenReturn(AiReportResponse.fromGradingResult(0, 0, 0, 0, List.of(), List.of(), List.of())); - OpenAiReportGrader sut = new OpenAiReportGrader(generator, contentExtractor, parser); + OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); // when - sut.grade(businessPlan); + sut.gradeContent(content); // then - var inOrder = inOrder(contentExtractor, generator, parser); - inOrder.verify(contentExtractor).extractContent(businessPlan); - inOrder.verify(generator).generateReport(extractedContent); + var inOrder = inOrder(generator, parser); + inOrder.verify(generator).generateReport(content); inOrder.verify(parser).parse(llmResponse); } } diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java index fda698c8..f52c326f 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java @@ -15,8 +15,12 @@ import starlight.adapter.aireport.persistence.AiReportRepository; import starlight.adapter.businessplan.persistence.BusinessPlanJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; -import starlight.application.aireport.dto.AiReportResponse; +import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.required.AiReportGrader; +import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.infrastructure.provided.OcrProvider; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; @@ -49,9 +53,10 @@ class AiReportServiceImplIntegrationTest { @TestConfiguration static class TestBeans { + @Bean AiReportGrader aiReportGrader() { - return businessPlan -> { + return content -> { // 간단한 mock 응답 반환 return AiReportResponse.fromGradingResult( 20, 25, 30, 20, @@ -71,6 +76,94 @@ ObjectMapper objectMapper() { AiReportResponseParser responseParser() { return new AiReportResponseParser(new ObjectMapper()); } + + @Bean + BusinessPlanService businessPlanService(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanService() { + @Override + public starlight.application.businessplan.provided.dto.BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, org.springframework.data.domain.Pageable pageable) { + throw new UnsupportedOperationException("Not implemented in test"); + } + @Override + public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { + BusinessPlan plan = BusinessPlan.create("default title", memberId); + BusinessPlan saved = businessPlanRepository.save(plan); + return BusinessPlanResponse.Result.from(saved, "Business plan created"); + } + + @Override + public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); + BusinessPlan saved = businessPlanRepository.save(plan); + return BusinessPlanResponse.Result.from(saved, "PDF Business plan created"); + } + + @Override + public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public starlight.application.businessplan.provided.dto.SubSectionResponse.Result upsertSubSection( + Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, List checks, + starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public starlight.application.businessplan.provided.dto.SubSectionResponse.Detail getSubSectionDetail( + Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public List checkAndUpdateSubSection(Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, + starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public starlight.application.businessplan.provided.dto.SubSectionResponse.Result deleteSubSection( + Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + }; + } + + @Bean + OcrProvider ocrProvider() { + return new OcrProvider() { + @Override + public starlight.shared.dto.infrastructure.OcrResponse ocrPdfByUrl(String pdfUrl) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public String ocrPdfTextByUrl(String pdfUrl) { + return "PDF에서 추출한 텍스트 내용입니다. 이것은 테스트용 OCR 결과입니다."; + } + }; + } + + @Bean + BusinessPlanContentExtractor businessPlanContentExtractor() { + return new BusinessPlanContentExtractor(); + } } /** @@ -115,7 +208,7 @@ private void createAllSubSections(BusinessPlan plan) { void gradeBusinessPlan_createsNewReport() { // given Long memberId = 1L; - BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create(memberId)); + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); createAllSubSections(plan); businessPlanRepository.save(plan); em.flush(); @@ -154,7 +247,7 @@ void gradeBusinessPlan_createsNewReport() { void gradeBusinessPlan_updatesExistingReport() { // given Long memberId = 1L; - BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create(memberId)); + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); createAllSubSections(plan); businessPlanRepository.save(plan); em.flush(); @@ -185,7 +278,7 @@ void gradeBusinessPlan_updatesExistingReport() { void getAiReport_returnsResponse() { // given Long memberId = 1L; - BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create(memberId)); + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); createAllSubSections(plan); businessPlanRepository.save(plan); em.flush(); @@ -216,7 +309,7 @@ void getAiReport_returnsResponse() { void convertToJsonNode_and_toResponse_workCorrectly() { // given Long memberId = 1L; - BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create(memberId)); + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); createAllSubSections(plan); businessPlanRepository.save(plan); em.flush(); @@ -242,5 +335,70 @@ void convertToJsonNode_and_toResponse_workCorrectly() { assertThat(retrievedResult.weaknesses()).hasSize(gradingResult.weaknesses().size()); assertThat(retrievedResult.sectionScores()).hasSize(gradingResult.sectionScores().size()); } + + @Test + @DisplayName("PDF URL을 기반으로 사업계획서를 생성하고 AI 리포트를 생성한다") + void createAndGradePdfBusinessPlan_createsBusinessPlanAndReport() { + // given + Long memberId = 1L; + String title = "테스트 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + + // when + AiReportResponse result = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isNotNull(); + assertThat(result.businessPlanId()).isNotNull(); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + + // BusinessPlan이 생성되었는지 확인 + BusinessPlan createdPlan = businessPlanRepository.findById(result.businessPlanId()).orElseThrow(); + assertThat(createdPlan.getTitle()).isEqualTo(title); + assertThat(createdPlan.getPdfUrl()).isEqualTo(pdfUrl); + assertThat(createdPlan.getMemberId()).isEqualTo(memberId); + assertThat(createdPlan.getPlanStatus()).isEqualTo(PlanStatus.AI_REVIEWED); + + // AiReport가 생성되었는지 확인 + Optional savedReport = aiReportRepository.findByBusinessPlanId(result.businessPlanId()); + assertThat(savedReport).isPresent(); + assertThat(savedReport.get().getBusinessPlanId()).isEqualTo(result.businessPlanId()); + } + + @Test + @DisplayName("PDF 기반으로 생성한 사업계획서의 리포트를 조회할 수 있다") + void createAndGradePdfBusinessPlan_canRetrieveReport() { + // given + Long memberId = 1L; + String title = "테스트 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + + // when - PDF로 사업계획서 생성 및 채점 + AiReportResponse createdResult = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + Long planId = createdResult.businessPlanId(); + em.flush(); + em.clear(); + + // when - 리포트 조회 + AiReportResponse retrievedResult = sut.getAiReport(planId, memberId); + + // then + assertThat(retrievedResult).isNotNull(); + assertThat(retrievedResult.id()).isEqualTo(createdResult.id()); + assertThat(retrievedResult.businessPlanId()).isEqualTo(planId); + assertThat(retrievedResult.totalScore()).isEqualTo(95); + assertThat(retrievedResult.problemRecognitionScore()).isEqualTo(createdResult.problemRecognitionScore()); + assertThat(retrievedResult.feasibilityScore()).isEqualTo(createdResult.feasibilityScore()); + assertThat(retrievedResult.growthStrategyScore()).isEqualTo(createdResult.growthStrategyScore()); + assertThat(retrievedResult.teamCompetenceScore()).isEqualTo(createdResult.teamCompetenceScore()); + } } diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java index ddf77bf1..ffdcdf3f 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java @@ -4,10 +4,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.dto.AiReportResponse; +import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.required.AiReportGrader; import starlight.application.aireport.required.AiReportQuery; +import starlight.application.businessplan.provided.BusinessPlanService; import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.infrastructure.provided.OcrProvider; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -27,10 +30,13 @@ class AiReportServiceImplUnitTest { private final BusinessPlanQuery businessPlanQuery = mock(BusinessPlanQuery.class); + private final BusinessPlanService businessPlanService = mock(BusinessPlanService.class); private final AiReportQuery aiReportQuery = mock(AiReportQuery.class); private final AiReportGrader aiReportGrader = mock(AiReportGrader.class); private final ObjectMapper objectMapper = new ObjectMapper(); + private final OcrProvider ocrProvider = mock(OcrProvider.class); private final AiReportResponseParser responseParser = new AiReportResponseParser(objectMapper); + private final BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); private AiReportServiceImpl sut; @@ -47,13 +53,16 @@ void gradeBusinessPlan_createsNewReport() { when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); + String extractedContent = "사업계획서 내용"; + when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); + AiReportResponse gradingResult = AiReportResponse.fromGradingResult( 20, 25, 30, 20, List.of(), List.of(), List.of() ); - when(aiReportGrader.grade(plan)).thenReturn(gradingResult); + when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -72,7 +81,7 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.save(any(AiReport.class))).thenReturn(savedReport); - sut = new AiReportServiceImpl(businessPlanQuery, aiReportQuery, aiReportGrader, objectMapper, responseParser); + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); @@ -98,13 +107,16 @@ void gradeBusinessPlan_updatesExistingReport() { AiReport existingReport = mock(AiReport.class); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(existingReport)); + String extractedContent = "사업계획서 내용"; + when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); + AiReportResponse gradingResult = AiReportResponse.fromGradingResult( 20, 25, 30, 20, List.of(), List.of(), List.of() ); - when(aiReportGrader.grade(plan)).thenReturn(gradingResult); + when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -122,7 +134,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.save(existingReport)).thenReturn(existingReport); - sut = new AiReportServiceImpl(businessPlanQuery, aiReportQuery, aiReportGrader, objectMapper, responseParser); + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); @@ -130,7 +142,8 @@ void gradeBusinessPlan_updatesExistingReport() { // then assertThat(result).isNotNull(); verify(existingReport).update(anyString()); - verify(plan, never()).updateStatus(any()); + // 기존 리포트가 있어도 상태는 AI_REVIEWED로 갱신됨 + verify(plan).updateStatus(PlanStatus.AI_REVIEWED); } @Test @@ -143,7 +156,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { when(plan.isOwnedBy(memberId)).thenReturn(false); when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, aiReportQuery, aiReportGrader, objectMapper, responseParser); + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -163,7 +176,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { when(plan.areWritingCompleted()).thenReturn(false); when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, aiReportQuery, aiReportGrader, objectMapper, responseParser); + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -201,7 +214,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportServiceImpl(businessPlanQuery, aiReportQuery, aiReportGrader, objectMapper, responseParser); + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResponse result = sut.getAiReport(planId, memberId); @@ -225,7 +238,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportServiceImpl(businessPlanQuery, aiReportQuery, aiReportGrader, objectMapper, responseParser); + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java index 514f36e1..29870e9d 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java @@ -12,13 +12,17 @@ import starlight.adapter.businessplan.persistence.BusinessPlanJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.member.required.MemberQuery; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.domain.member.entity.Member; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) @@ -43,19 +47,32 @@ ChecklistGrader checklistGrader() { ObjectMapper objectMapper() { return new ObjectMapper(); } + + @Bean + MemberQuery memberQuery() { + return new MemberQuery() { + @Override + public Member getOrThrow(Long memberId) { + Member m = mock(Member.class); + when(m.getName()).thenReturn("tester"); + return m; + } + }; + } } @Test void create_and_update_title_and_delete_with_subsections_cleanup() { // create - BusinessPlan created = sut.createBusinessPlan(1L); - Long planId = created.getId(); + var createdPreview = sut.createBusinessPlan(1L); + Long planId = createdPreview.businessPlanId(); assertThat(planId).isNotNull(); // attach a subsection to overview SubSection s1 = SubSection.create(SubSectionType.OVERVIEW_BASIC, "c", "{}", List.of(false, false, false, false, false)); - created.getOverview().putSubSection(s1); - businessPlanRepository.save(created); + BusinessPlan createdEntity = businessPlanRepository.findById(planId).orElseThrow(); + createdEntity.getOverview().putSubSection(s1); + businessPlanRepository.save(createdEntity); em.flush(); em.clear(); @@ -64,14 +81,36 @@ void create_and_update_title_and_delete_with_subsections_cleanup() { assertThat(reloaded.getOverview().getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNotNull(); // update title - BusinessPlan updated = sut.updateBusinessPlanTitle(planId, created.getMemberId(), "new-title"); - assertThat(updated.getTitle()).isEqualTo("new-title"); + String updatedTitle = sut.updateBusinessPlanTitle(planId, "new-title", createdEntity.getMemberId()); + assertThat(updatedTitle).isEqualTo("new-title"); // delete plan -> cascade로 subsections도 함께 삭제 - sut.deleteBusinessPlan(planId, created.getMemberId()); + sut.deleteBusinessPlan(planId, createdEntity.getMemberId()); // SubSection이 cascade로 삭제되었는지 확인 BusinessPlan afterDelete = businessPlanRepository.findById(planId).orElse(null); assertThat(afterDelete).isNull(); } + + @Test + void createBusinessPlanWithPdf_createsPlanWithPdfInfo() { + // given + String title = "PDF 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + Long memberId = 1L; + + // when + var createdResult = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); + Long planId = createdResult.businessPlanId(); + + // then + assertThat(planId).isNotNull(); + assertThat(createdResult.title()).isEqualTo(title); + + BusinessPlan createdPlan = businessPlanRepository.findById(planId).orElseThrow(); + assertThat(createdPlan.getTitle()).isEqualTo(title); + assertThat(createdPlan.getPdfUrl()).isEqualTo(pdfUrl); + assertThat(createdPlan.getMemberId()).isEqualTo(memberId); + assertThat(createdPlan.getPlanStatus()).isEqualTo(starlight.domain.businessplan.enumerate.PlanStatus.WRITTEN_COMPLETED); + } } diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java index 1aa4386f..53b922f8 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java @@ -9,7 +9,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import starlight.application.businessplan.dto.SubSectionResponse; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; import starlight.application.businessplan.required.BusinessPlanQuery; import starlight.application.businessplan.required.ChecklistGrader; import starlight.domain.businessplan.entity.BusinessPlan; @@ -19,11 +20,15 @@ import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.domain.businessplan.exception.BusinessPlanException; import starlight.shared.enumerate.SectionType; +import starlight.application.member.required.MemberQuery; +import starlight.domain.member.entity.Member; import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -40,20 +45,28 @@ class BusinessPlanServiceImplUnitTest { @Mock private ObjectMapper objectMapper; + @Mock + private MemberQuery memberQuery; + @InjectMocks private BusinessPlanServiceImpl sut; private BusinessPlan buildPlanWithSections(Long memberId) { - BusinessPlan plan = BusinessPlan.create(memberId); - return plan; + return BusinessPlan.create("default title", memberId); } @BeforeEach void setup() { - when(objectMapper.valueToTree(any())).thenReturn(new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode()); + when(objectMapper.valueToTree(any())) + .thenReturn(new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode()); try { when(objectMapper.writeValueAsString(any())).thenReturn("{}"); - } catch (Exception ignored) {} + } catch (Exception ignored) { + } + // memberQuery 기본 스텁 + Member stubMember = mock(Member.class); + when(stubMember.getName()).thenReturn("tester"); + when(memberQuery.getOrThrow(anyLong())).thenReturn(stubMember); } @Test @@ -62,9 +75,28 @@ void createBusinessPlan_savesRoot() { when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - BusinessPlan created = sut.createBusinessPlan(1L); + BusinessPlanResponse.Result created = sut.createBusinessPlan(1L); + + assertThat(created).isNotNull(); + assertThat(created.message()).isEqualTo("Business plan created"); + verify(businessPlanQuery).save(any(BusinessPlan.class)); + } + + @Test + @DisplayName("PDF URL을 기반으로 사업계획서를 생성하면 저장된다") + void createBusinessPlanWithPdf_savesRoot() { + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + String title = "테스트 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + Long memberId = 1L; + + BusinessPlanResponse.Result created = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); assertThat(created).isNotNull(); + assertThat(created.message()).isEqualTo("PDF Business plan created"); + assertThat(created.title()).isEqualTo(title); verify(businessPlanQuery).save(any(BusinessPlan.class)); } @@ -77,9 +109,9 @@ void updateTitle_checksOwnership_thenSaves() { when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - BusinessPlan updated = sut.updateBusinessPlanTitle(100L, 10L, "new-title"); + String updatedTitle = sut.updateBusinessPlanTitle(100L, "new-title", 10L); - assertThat(updated).isNotNull(); + assertThat(updatedTitle).isEqualTo("new-title"); verify(businessPlanQuery).save(plan); } @@ -91,7 +123,7 @@ void updateTitle_unauthorized_throws() { when(businessPlanQuery.getOrThrow(100L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, - () -> sut.updateBusinessPlanTitle(100L, 10L, "title")); + () -> sut.updateBusinessPlanTitle(100L, "title", 10L)); } @Test @@ -99,36 +131,47 @@ void updateTitle_unauthorized_throws() { void deleteBusinessPlan_cascadeDeletesSubSections() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(true); + when(plan.getId()).thenReturn(100L); when(businessPlanQuery.getOrThrow(100L)).thenReturn(plan); - assertDoesNotThrow(() -> sut.deleteBusinessPlan(100L, 10L)); + BusinessPlanResponse.Result deleted = sut.deleteBusinessPlan(100L, 10L); + + assertThat(deleted).isNotNull(); + assertThat(deleted.businessPlanId()).isEqualTo(100L); + assertThat(deleted.message()).isEqualTo("Business plan deleted"); verify(businessPlanQuery).delete(plan); } @Test @DisplayName("서브섹션 생성: 없으면 신규 생성 후 부모 섹션에 연결하여 저장") - void createOrUpdateSubSection_creates_whenNotExists() { + void upsertSubSection_creates_whenNotExists() { // given BusinessPlan plan = buildPlanWithSections(10L); Overview overview = plan.getOverview(); - + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); jsonNode.putArray("content"); when(objectMapper.valueToTree(any())).thenReturn(jsonNode); - try { when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); } catch (Exception ignored) {} + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } // when List checks = List.of(false, false, false, false, false); - SubSectionResponse.Created res = sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); + SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.OVERVIEW_BASIC, 10L); // then assertThat(res).isNotNull(); - assertThat(res.message()).isEqualTo("created"); + assertThat(res.message()).isEqualTo("Subsection created"); + assertThat(res.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNotNull(); assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC).getSubSectionType()) .isEqualTo(SubSectionType.OVERVIEW_BASIC); @@ -137,82 +180,90 @@ void createOrUpdateSubSection_creates_whenNotExists() { @Test @DisplayName("서브섹션 생성: 기존 존재하면 업데이트 경로") - void createOrUpdateSubSection_updates_whenExists() { + void upsertSubSection_updates_whenExists() { BusinessPlan plan = buildPlanWithSections(10L); Overview overview = plan.getOverview(); - + // 기존 SubSection 생성 및 설정 - SubSection existing = SubSection.create(SubSectionType.OVERVIEW_BASIC, "old", "{}", List.of(false, false, false, false, false)); + SubSection existing = SubSection.create(SubSectionType.OVERVIEW_BASIC, "old", "{}", + List.of(false, false, false, false, false)); overview.putSubSection(existing); - + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); jsonNode.putArray("content"); when(objectMapper.valueToTree(any())).thenReturn(jsonNode); - try { when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); } catch (Exception ignored) {} + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } List checks = List.of(false, false, false, false, false); - SubSectionResponse.Created res = sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); - - assertThat(res.message()).isEqualTo("updated"); + SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.OVERVIEW_BASIC, 10L); + + assertThat(res.message()).isEqualTo("Subsection updated"); verify(businessPlanQuery).save(plan); } @Test @DisplayName("서브섹션 생성: 소유자 아님이면 예외") - void createOrUpdateSubSection_unauthorized_throws() { + void upsertSubSection_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); - com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); jsonNode.putArray("content"); List checks = List.of(false, false, false, false, false); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, - () -> sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L)); + () -> sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L)); } @Test - @DisplayName("서브섹션 조회: 본문과 체크리스트를 함께 반환") - void getSubSection_returnsContentAndChecks() { + @DisplayName("서브섹션 조회: 상세 정보를 반환한다") + void getSubSectionDetail_returnsContent() { BusinessPlan plan = buildPlanWithSections(10L); Overview overview = plan.getOverview(); - - SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", List.of(true, false, true, false, true)); + + SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", + List.of(true, false, true, false, true)); overview.putSubSection(sub); - + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); - SubSectionResponse.Retrieved res = sut.getSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); + SubSectionResponse.Detail detail = sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L); - assertThat(res).isNotNull(); - assertThat(res.message()).isEqualTo("retrieved"); - assertThat(res.content()).isNotNull(); + assertThat(detail).isNotNull(); + assertThat(detail.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); + assertThat(detail.content()).isNotNull(); } @Test @DisplayName("서브섹션 조회: 없으면 예외") - void getSubSection_notFound_throws() { + void getSubSectionDetail_notFound_throws() { BusinessPlan plan = buildPlanWithSections(10L); when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, - () -> sut.getSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L)); + () -> sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L)); } @Test @DisplayName("서브섹션 조회: 소유자 아님이면 예외") - void getSubSection_unauthorized_throws() { + void getSubSectionDetail_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, - () -> sut.getSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L)); + () -> sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L)); } @Test @@ -220,17 +271,21 @@ void getSubSection_unauthorized_throws() { void deleteSubSection_success() { BusinessPlan plan = buildPlanWithSections(10L); Overview overview = plan.getOverview(); - - SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", List.of(false, false, false, false, false)); + + SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", + List.of(false, false, false, false, false)); overview.putSubSection(sub); - + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - SubSectionResponse.Deleted res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); + SubSectionResponse.Result res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); assertThat(res).isNotNull(); + assertThat(res.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); + assertThat(res.subSectionId()).isNull(); + assertThat(res.message()).isEqualTo("Subsection deleted"); assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNull(); verify(businessPlanQuery).save(plan); } @@ -246,30 +301,113 @@ void deleteSubSection_unauthorized_throws() { () -> sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L)); } -// @Test -// @DisplayName("서브섹션 체크: 체크리스트가 저장된다") -// void checkAndUpdateSubSection_savesChecks() { -// BusinessPlan plan = buildPlanWithSections(10L); -// Overview overview = plan.getOverview(); -// -// SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", List.of(false, false, false, false, false)); -// overview.putSubSection(sub); -// -// when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); -// when(businessPlanQuery.save(any(BusinessPlan.class))) -// .thenAnswer(invocation -> invocation.getArgument(0)); -// when(checklistGrader.check(eq(SubSectionType.OVERVIEW_BASIC), anyString())) -// .thenReturn(List.of(true, true, true, true, true)); -// -// JsonNode node = mock(JsonNode.class); -// when(objectMapper.valueToTree(any())).thenReturn(node); -// try { when(objectMapper.writeValueAsString(eq(node))).thenReturn("{}"); } catch (Exception ignored) {} -// -// List result = sut.checkAndUpdateSubSection(1L, node, SubSectionType.OVERVIEW_BASIC, 10L); -// -// assertThat(result).containsExactly(true, true, true, true, true); -// verify(businessPlanQuery).save(plan); -// } + @Test + @DisplayName("사업계획서 목록 조회(PreviewPage): 매핑 필드를 올바르게 반환한다") + void getBusinessPlanList_returnsPreviewPage() { + // given + BusinessPlan plan = buildPlanWithSections(1L); + Pageable pageable = PageRequest.of(1, 3); // 내부 0-base 가정, 여기선 1페이지(=두번째) 요청 + when(businessPlanQuery.findPreviewPage(any(Long.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(plan), pageable, 7)); + + // when + BusinessPlanResponse.PreviewPage res = sut.getBusinessPlanList(1L, pageable); + + // then + assertThat(res.totalElements()).isEqualTo(7); + assertThat(res.size()).isEqualTo(3); + assertThat(res.page()).isEqualTo(pageable.getPageNumber() + 1); // 1-base + assertThat(res.totalPages()).isEqualTo((int) Math.ceil(7 / 3.0)); + assertThat(res.numberOfElements()).isEqualTo(1); + assertThat(res.content()).hasSize(1); + assertThat(res.content().get(0).businessPlanId()).isEqualTo(plan.getId()); + verify(businessPlanQuery).findPreviewPage(any(Long.class), any(Pageable.class)); + } + + @Test + @DisplayName("사업계획서 전체 서브섹션을 조회하면 존재하는 서브섹션만 반환한다") + void getBusinessPlanSubSections_returnsExistingSubSectionList() { + BusinessPlan plan = buildPlanWithSections(10L); + + SubSection overview = SubSection.create(SubSectionType.OVERVIEW_BASIC, "overview", "{\"text\":\"overview\"}", + List.of(false, false, false, false, false)); + plan.getOverview().putSubSection(overview); + + SubSection problem = SubSection.create(SubSectionType.PROBLEM_BACKGROUND, "problem", "{\"text\":\"problem\"}", + List.of(false, false, false, false, false)); + plan.getProblemRecognition().putSubSection(problem); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + BusinessPlanResponse.Detail detail = sut.getBusinessPlanDetail(1L, 10L); + + assertThat(detail.title()).isEqualTo(plan.getTitle()); + assertThat(detail.subSectionDetailList()).hasSize(2); + assertThat(detail.subSectionDetailList()) + .extracting(SubSectionResponse.Detail::subSectionType) + .containsExactly(SubSectionType.OVERVIEW_BASIC, SubSectionType.PROBLEM_BACKGROUND); + assertThat(detail.subSectionDetailList().get(0).content().path("text").asText()).isEqualTo("overview"); + assertThat(detail.subSectionDetailList().get(1).content().path("text").asText()).isEqualTo("problem"); + } + + @Test + @DisplayName("사업계획서 전체 서브섹션 조회: 소유자 아님이면 예외") + void getBusinessPlanDetail_unauthorized_throws() { + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(10L)).thenReturn(false); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.getBusinessPlanDetail(1L, 10L)); + } + + @Test + @DisplayName("서브섹션 체크: 체크리스트가 저장된다") + void checkAndUpdateSubSection_savesChecks() { + BusinessPlan plan = buildPlanWithSections(10L); + Overview overview = plan.getOverview(); + + List previousChecks = List.of(false, false, false, false, false); + SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "previous-content", "{}", previousChecks); + overview.putSubSection(sub); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + List updatedChecks = List.of(true, true, true, true, true); + when(checklistGrader.check( + eq(SubSectionType.OVERVIEW_BASIC), + eq("updated content"), + eq("previous-content"), + anyList())).thenReturn(updatedChecks); + + com.fasterxml.jackson.databind.ObjectMapper realObjectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = realObjectMapper.createObjectNode(); + jsonNode.putArray("content") + .addObject() + .put("type", "text") + .put("value", "updated content"); + jsonNode.putArray("checks") + .add(false) + .add(false) + .add(false) + .add(false) + .add(false); + + when(objectMapper.valueToTree(any())).thenReturn(jsonNode); + try { + when(objectMapper.writeValueAsString(any())).thenReturn(jsonNode.toString()); + } catch (Exception ignored) { + } + + List result = sut.checkAndUpdateSubSection(1L, jsonNode, SubSectionType.OVERVIEW_BASIC, 10L); + + assertThat(result).containsExactlyElementsOf(updatedChecks); + assertThat(sub.getChecks()).containsExactlyElementsOf(updatedChecks); + assertThat(sub.getContent()).isEqualTo("updated content"); + verify(businessPlanQuery).save(plan); + } @Test @DisplayName("서브섹션 체크: 없으면 예외") @@ -302,56 +440,67 @@ void createSubSection_forEachSectionType() { when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); jsonNode.putArray("content"); when(objectMapper.valueToTree(any())).thenReturn(jsonNode); - try { when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); } catch (Exception ignored) {} + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } List checks = List.of(false, false, false, false, false); - SubSectionResponse.Created r1 = sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.PROBLEM_BACKGROUND, 10L); - SubSectionResponse.Created r2 = sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.FEASIBILITY_STRATEGY, 10L); - SubSectionResponse.Created r3 = sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.GROWTH_MODEL, 10L); - SubSectionResponse.Created r4 = sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.TEAM_FOUNDER, 10L); - - assertThat(r1.message()).isEqualTo("created"); - assertThat(r2.message()).isEqualTo("created"); - assertThat(r3.message()).isEqualTo("created"); - assertThat(r4.message()).isEqualTo("created"); + SubSectionResponse.Result r1 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.PROBLEM_BACKGROUND, 10L); + SubSectionResponse.Result r2 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.FEASIBILITY_STRATEGY, 10L); + SubSectionResponse.Result r3 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.GROWTH_MODEL, + 10L); + SubSectionResponse.Result r4 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_FOUNDER, + 10L); + + assertThat(r1.message()).isEqualTo("Subsection created"); + assertThat(r2.message()).isEqualTo("Subsection created"); + assertThat(r3.message()).isEqualTo("Subsection created"); + assertThat(r4.message()).isEqualTo("Subsection created"); } @Test @DisplayName("서브섹션 생성: 모든 서브섹션이 생성되면 상태가 DRAFTED로 변경된다") - void createOrUpdateSubSection_allSubSectionsCreated_updatesStatusToDrafted() { + void upsertSubSection_allSubSectionsCreated_updatesStatusToDrafted() { // given BusinessPlan plan = spy(buildPlanWithSections(10L)); doReturn(true).when(plan).isOwnedBy(10L); - + // 모든 서브섹션을 생성 (마지막 하나만 남음) List allTypes = List.of( - SubSectionType.OVERVIEW_BASIC, - SubSectionType.PROBLEM_BACKGROUND, SubSectionType.PROBLEM_PURPOSE, SubSectionType.PROBLEM_MARKET, - SubSectionType.FEASIBILITY_STRATEGY, SubSectionType.FEASIBILITY_MARKET, - SubSectionType.GROWTH_MODEL, SubSectionType.GROWTH_FUNDING, SubSectionType.GROWTH_ENTRY, - SubSectionType.TEAM_FOUNDER - ); - + SubSectionType.OVERVIEW_BASIC, + SubSectionType.PROBLEM_BACKGROUND, SubSectionType.PROBLEM_PURPOSE, SubSectionType.PROBLEM_MARKET, + SubSectionType.FEASIBILITY_STRATEGY, SubSectionType.FEASIBILITY_MARKET, + SubSectionType.GROWTH_MODEL, SubSectionType.GROWTH_FUNDING, SubSectionType.GROWTH_ENTRY, + SubSectionType.TEAM_FOUNDER); + for (SubSectionType type : allTypes) { SubSection sub = SubSection.create(type, "content", "{}", List.of(false, false, false, false, false)); getSectionByPlanAndType(plan, type.getSectionType()).putSubSection(sub); } - + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); jsonNode.putArray("content"); when(objectMapper.valueToTree(any())).thenReturn(jsonNode); - try { when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); } catch (Exception ignored) {} + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } // when - 마지막 서브섹션 생성 List checks = List.of(false, false, false, false, false); - sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.TEAM_MEMBERS, 10L); + sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_MEMBERS, 10L); // then - 상태가 WRITTEN_COMPLETED로 변경되어야 함 verify(plan).updateStatus(starlight.domain.businessplan.enumerate.PlanStatus.WRITTEN_COMPLETED); @@ -359,51 +508,55 @@ void createOrUpdateSubSection_allSubSectionsCreated_updatesStatusToDrafted() { @Test @DisplayName("서브섹션 생성: 일부만 생성되면 상태가 변경되지 않는다") - void createOrUpdateSubSection_partialSubSections_noStatusChange() { + void upsertSubSection_partialSubSections_noStatusChange() { // given BusinessPlan plan = spy(buildPlanWithSections(10L)); doReturn(true).when(plan).isOwnedBy(10L); - + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); jsonNode.putArray("content"); when(objectMapper.valueToTree(any())).thenReturn(jsonNode); - try { when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); } catch (Exception ignored) {} + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } // when - 첫 번째 서브섹션만 생성 List checks = List.of(false, false, false, false, false); - sut.createOrUpdateSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); + sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); // then - 상태가 변경되지 않아야 함 (모든 서브섹션이 생성되지 않았으므로) verify(plan, never()).updateStatus(any()); } -// @Test -// @DisplayName("서브섹션 삭제: 모든 서브섹션이 생성되지 않으면 상태가 STARTED로 변경된다") -// void deleteSubSection_notAllSubSectionsCreated_updatesStatusToStarted() { -// // given -// BusinessPlan plan = spy(buildPlanWithSections(10L)); -// doReturn(true).when(plan).isOwnedBy(10L); -// -// // 모든 서브섹션 생성 -// for (SubSectionType type : SubSectionType.values()) { -// SubSection sub = SubSection.create(type, "content", "{}", List.of(false, false, false, false, false)); -// getSectionByPlanAndType(plan, type.getSectionType()).putSubSection(sub); -// } -// -// when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); -// when(businessPlanQuery.save(any(BusinessPlan.class))) -// .thenAnswer(invocation -> invocation.getArgument(0)); -// -// // when - 서브섹션 삭제 -// sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); -// -// // then - 상태가 STARTED로 변경되어야 함 -// verify(plan).updateStatus(starlight.domain.businessplan.enumerate.PlanStatus.STARTED); -// } + @Test + @DisplayName("서브섹션 삭제: 삭제 시 상태 변경이 발생하지 않는다") + void deleteSubSection_noStatusChange() { + // given + BusinessPlan plan = spy(buildPlanWithSections(10L)); + doReturn(true).when(plan).isOwnedBy(10L); + + // 모든 서브섹션 생성 + for (SubSectionType type : SubSectionType.values()) { + SubSection sub = SubSection.create(type, "content", "{}", List.of(false, false, false, false, false)); + getSectionByPlanAndType(plan, type.getSectionType()).putSubSection(sub); + } + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when - 서브섹션 삭제 + sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); + + // then - 상태가 변경되지 않아야 함 + verify(plan, never()).updateStatus(any()); + } private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type) { return switch (type) { diff --git a/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java b/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java index b658b752..066a71b2 100644 --- a/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java +++ b/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java @@ -121,8 +121,8 @@ void updateDetails_Success() { // given ExpertReport report = ExpertReport.create(1L, 10L, "token"); List details = List.of( - ExpertReportDetail.create(CommentType.STRENGTH, "강점", "좋습니다"), - ExpertReportDetail.create(CommentType.WEAKNESS, "약점", "개선 필요") + ExpertReportDetail.create(CommentType.STRENGTH, "좋습니다"), + ExpertReportDetail.create(CommentType.WEAKNESS, "개선 필요") ); // when @@ -152,16 +152,14 @@ void incrementViewCount_Success() { void createDetail_Success() { // given CommentType type = CommentType.STRENGTH; - String title = "강점 분석"; String content = "시장 분석이 우수합니다."; // when - ExpertReportDetail detail = ExpertReportDetail.create(type, title, content); + ExpertReportDetail detail = ExpertReportDetail.create(type, content); // then assertThat(detail).isNotNull(); assertThat(detail.getCommentType()).isEqualTo(type); - assertThat(detail.getTitle()).isEqualTo(title); assertThat(detail.getContent()).isEqualTo(content); } @@ -170,7 +168,7 @@ void createDetail_Success() { void createDetail_EmptyContent_ThrowsException() { // when & then assertThatThrownBy(() -> - ExpertReportDetail.create(CommentType.STRENGTH, "title", "")) + ExpertReportDetail.create(CommentType.STRENGTH, "")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("content는 필수입니다"); }