diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index 0eb8ca06..a5513466 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java @@ -2,16 +2,23 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import starlight.adapter.ai.util.AiReportResponseParser; import starlight.application.aireport.required.AiReportQuery; +import starlight.application.expert.required.AiReportSummaryLookupPort; import starlight.domain.aireport.entity.AiReport; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; @Component @RequiredArgsConstructor -public class AiReportJpa implements AiReportQuery { +public class AiReportJpa implements AiReportQuery, AiReportSummaryLookupPort { private final AiReportRepository aiReportRepository; + private final AiReportResponseParser responseParser; @Override public AiReport save(AiReport aiReport) { @@ -22,5 +29,21 @@ public AiReport save(AiReport aiReport) { public Optional findByBusinessPlanId(Long businessPlanId) { return aiReportRepository.findByBusinessPlanId(businessPlanId); } -} + @Override + public Map findTotalScoresByBusinessPlanIds(List businessPlanIds) { + if (businessPlanIds == null || businessPlanIds.isEmpty()) { + return Collections.emptyMap(); + } + + List reports = aiReportRepository.findAllByBusinessPlanIdIn(businessPlanIds); + Map totalScoreMap = new HashMap<>(); + + for (AiReport report : reports) { + Integer totalScore = responseParser.toResponse(report).totalScore(); + totalScoreMap.put(report.getBusinessPlanId(), totalScore != null ? totalScore : 0); + } + + return totalScoreMap; + } +} diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java index 31c245e4..64cc5b0d 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java @@ -3,10 +3,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import starlight.domain.aireport.entity.AiReport; +import java.util.Collection; import java.util.Optional; +import java.util.List; public interface AiReportRepository extends JpaRepository { Optional findByBusinessPlanId(Long businessPlanId); -} + List findAllByBusinessPlanIdIn(Collection businessPlanIds); +} diff --git a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java index 0d031795..442d4993 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java @@ -19,10 +19,10 @@ public class ImageController implements ImageApiDoc { @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( - @AuthenticationPrincipal AuthenticatedMember authDetails, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember, @RequestParam String fileName ) { - return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authDetails.getMemberId(), fileName)); + return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authenticatedMember.getMemberId(), fileName)); } @PostMapping("/upload-url/public") diff --git a/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java b/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java index 53d6e807..a9bc0b1b 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java +++ b/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java @@ -46,7 +46,7 @@ public interface ImageApiDoc { }) @GetMapping(value = "/v1/image/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) ApiResponse getPresignedUrl( - @AuthenticationPrincipal AuthenticatedMember authDetails, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember, @io.swagger.v3.oas.annotations.Parameter(description = "파일명", required = true) @RequestParam String fileName ); diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java index 9fd3faf1..8966aef2 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java @@ -1,17 +1,20 @@ package starlight.adapter.businessplan.persistence; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.expert.required.BusinessPlanLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.exception.BusinessPlanErrorType; import starlight.domain.businessplan.exception.BusinessPlanException; +import java.util.List; + @Repository @RequiredArgsConstructor -public class BusinessPlanJpa implements BusinessPlanQuery { +public class BusinessPlanJpa implements BusinessPlanQuery, BusinessPlanLookupPort { private final BusinessPlanRepository businessPlanRepository; @@ -43,4 +46,9 @@ public void delete(BusinessPlan businessPlan) { public Page findPreviewPage(Long memberId, Pageable pageable) { return businessPlanRepository.findAllByMemberIdOrderedByLastSavedAt(memberId, pageable); } + + @Override + public List findAllByMemberId(Long memberId) { + return businessPlanRepository.findAllByMemberIdOrderByLastSavedAt(memberId); + } } diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java index 390e4653..973da96d 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import starlight.domain.businessplan.entity.BusinessPlan; +import java.util.List; import java.util.Optional; public interface BusinessPlanRepository extends JpaRepository { @@ -23,6 +24,14 @@ ORDER BY COALESCE(bp.modifiedAt, bp.createdAt) DESC, bp.id DESC """) Page findAllByMemberIdOrderedByLastSavedAt(@Param("memberId") Long memberId, Pageable pageable); + @Query(""" + SELECT bp + FROM BusinessPlan bp + WHERE bp.memberId = :memberId + ORDER BY COALESCE(bp.modifiedAt, bp.createdAt) DESC, bp.id DESC + """) + List findAllByMemberIdOrderByLastSavedAt(@Param("memberId") Long memberId); + @Query(""" SELECT DISTINCT bp FROM BusinessPlan bp diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java index c9ed683d..ce98ccd8 100644 --- a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java +++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java @@ -1,14 +1,18 @@ package starlight.adapter.expert.webapi; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.expert.webapi.dto.ExpertAiReportBusinessPlanResponse; import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; import starlight.adapter.expert.webapi.dto.ExpertListResponse; -import starlight.adapter.expert.webapi.swagger.ExpertQueryApiDoc; +import starlight.adapter.expert.webapi.swagger.ExpertApiDoc; +import starlight.application.expert.provided.ExpertAiReportQueryUseCase; import starlight.application.expert.provided.ExpertDetailQueryUseCase; +import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; import java.util.List; @@ -16,9 +20,10 @@ @RestController @RequiredArgsConstructor @RequestMapping("/v1/experts") -public class ExpertController implements ExpertQueryApiDoc { +public class ExpertController implements ExpertApiDoc { private final ExpertDetailQueryUseCase expertDetailQuery; + private final ExpertAiReportQueryUseCase expertAiReportQuery; @GetMapping public ApiResponse> search() { @@ -31,4 +36,14 @@ public ApiResponse detail( ) { return ApiResponse.success(ExpertDetailResponse.from(expertDetailQuery.findById(expertId))); } + + @GetMapping("/{expertId}/business-plans/ai-reports") + public ApiResponse> aiReportBusinessPlans( + @PathVariable Long expertId, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ) { + return ApiResponse.success(ExpertAiReportBusinessPlanResponse.fromAll( + expertAiReportQuery.findAiReportBusinessPlans(expertId, authenticatedMember.getMemberId()) + )); + } } diff --git a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertAiReportBusinessPlanResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertAiReportBusinessPlanResponse.java new file mode 100644 index 00000000..c6779d69 --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertAiReportBusinessPlanResponse.java @@ -0,0 +1,27 @@ +package starlight.adapter.expert.webapi.dto; + +import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; + +import java.util.List; + +public record ExpertAiReportBusinessPlanResponse( + Long businessPlanId, + String businessPlanTitle, + Long requestCount, + boolean isOver70 +) { + public static ExpertAiReportBusinessPlanResponse from(ExpertAiReportBusinessPlanResult result) { + return new ExpertAiReportBusinessPlanResponse( + result.businessPlanId(), + result.businessPlanTitle(), + result.requestCount(), + result.isOver70() + ); + } + + public static List fromAll(List results) { + return results.stream() + .map(ExpertAiReportBusinessPlanResponse::from) + .toList(); + } +} diff --git a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertApiDoc.java similarity index 67% rename from src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java rename to src/main/java/starlight/adapter/expert/webapi/swagger/ExpertApiDoc.java index 25baa299..7c748d6f 100644 --- a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java +++ b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertApiDoc.java @@ -7,16 +7,19 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import starlight.adapter.expert.webapi.dto.ExpertAiReportBusinessPlanResponse; import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; import starlight.adapter.expert.webapi.dto.ExpertListResponse; import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.auth.AuthenticatedMember; import java.util.List; @Tag(name = "전문가", description = "전문가 관련 API") -public interface ExpertQueryApiDoc { +public interface ExpertApiDoc { @Operation( summary = "전문가 목록 조회", @@ -180,4 +183,82 @@ public interface ExpertQueryApiDoc { ApiResponse detail( @PathVariable Long expertId ); + + @Operation( + summary = "전문가 상세 내 AI 리포트 보유 사업계획서 목록", + description = "지정된 전문가의 전문가 상세 페이지에서 로그인한 사용자의 사업계획서 중 AI 리포트가 생성된 항목만 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = ExpertAiReportBusinessPlanResponse.class)), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "result": "SUCCESS", + "data": [ + { + "businessPlanId": 10, + "businessPlanTitle": "테스트 사업계획서", + "requestCount": 2, + "isOver70": true + }, + { + "businessPlanId": 11, + "businessPlanTitle": "신규 사업계획서", + "requestCount": 0, + "isOver70": false + } + ], + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "전문가 신청 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ), + @ExampleObject( + name = "AI 리포트 파싱 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "AI_RESPONSE_PARSING_FAILED", + "message": "AI 응답 파싱에 실패했습니다." + } + } + """ + ) + } + ) + ) + }) + @GetMapping("/{expertId}/business-plans/ai-reports") + ApiResponse> aiReportBusinessPlans( + @PathVariable Long expertId, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ); } diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java index 400ae80f..a9a9a860 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java @@ -32,16 +32,6 @@ public Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPla } } - @Override - public List findRequestedExpertIds(Long businessPlanId) { - try { - return repository.findRequestedExpertIdsByPlanId(businessPlanId); - } catch (Exception e) { - log.error("신청된 전문가 목록 조회 중 오류가 발생했습니다.", e); - throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); - } - } - @Override public ExpertApplication save(ExpertApplication application) { return repository.save(application); @@ -64,4 +54,25 @@ public Map countByExpertIds(List expertIds) { throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); } } + + @Override + public Map countByExpertIdAndBusinessPlanIds(Long expertId, List businessPlanIds) { + try { + if (expertId == null) { + return Collections.emptyMap(); + } + if (businessPlanIds == null || businessPlanIds.isEmpty()) { + return Collections.emptyMap(); + } + + return repository.countByExpertIdAndBusinessPlanIds(expertId, businessPlanIds).stream() + .collect(Collectors.toMap( + ExpertApplicationRepository.BusinessPlanIdCountProjection::getBusinessPlanId, + p -> (long) p.getCount() + )); + } catch (Exception e) { + log.error("사업계획서별 신청 건수 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } } diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java index a25d8f61..22ef34d7 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java @@ -11,13 +11,6 @@ public interface ExpertApplicationRepository extends JpaRepository findRequestedExpertIdsByPlanId(@Param("businessPlanId") Long businessPlanId); - interface ExpertIdCountProjection { Long getExpertId(); long getCount(); @@ -30,4 +23,21 @@ select e.expertId as expertId, count(e) as count group by e.expertId """) List countByExpertIds(@Param("expertIds") List expertIds); + + interface BusinessPlanIdCountProjection { + Long getBusinessPlanId(); + long getCount(); + } + + @Query(""" + select e.businessPlanId as businessPlanId, count(e) as count + from ExpertApplication e + where e.expertId = :expertId + and e.businessPlanId in :businessPlanIds + group by e.businessPlanId + """) + List countByExpertIdAndBusinessPlanIds( + @Param("expertId") Long expertId, + @Param("businessPlanIds") List businessPlanIds + ); } diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java index 1e206ca1..799ba9ea 100644 --- a/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java +++ b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java @@ -7,37 +7,26 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import starlight.adapter.expertApplication.webapi.swagger.ExpertApplicationApiDoc; -import starlight.application.expertApplication.provided.ExpertApplicationQueryUseCase; import starlight.application.expertApplication.provided.ExpertApplicationCommandUseCase; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; -import java.util.List; - @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/v1/expert-applications") public class ExpertApplicationController implements ExpertApplicationApiDoc { - private final ExpertApplicationQueryUseCase queryUseCase; private final ExpertApplicationCommandUseCase applicationServiceUseCase; - @GetMapping - public ApiResponse> search( - @RequestParam Long businessPlanId - ) { - return ApiResponse.success(queryUseCase.findRequestedExpertIds(businessPlanId)); - } - @PostMapping(value = "/{expertId}/request", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse requestFeedback( @PathVariable Long expertId, @RequestParam Long businessPlanId, @RequestParam("file") MultipartFile file, - @AuthenticationPrincipal AuthenticatedMember auth + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) throws Exception { - applicationServiceUseCase.requestFeedback(expertId, businessPlanId, file, auth.getMemberName()); + applicationServiceUseCase.requestFeedback(expertId, businessPlanId, file, authenticatedMember.getMemberName()); return ApiResponse.success("피드백 요청이 전달되었습니다."); } } diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java index 89115a08..a492d420 100644 --- a/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java +++ b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java @@ -16,80 +16,9 @@ import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; -import java.util.List; - @Tag(name = "전문가", description = "전문가 관련 API") public interface ExpertApplicationApiDoc { - @Operation( - summary = "피드백 요청한 전문가 목록 조회", - description = "특정 사업계획서에 피드백을 요청한 전문가들의 ID 목록을 조회합니다." - ) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "조회 성공", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "result": "SUCCESS", - "data": [1, 3, 5, 7], - "error": null - } - """ - ) - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "사업계획서 없음", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "result": "ERROR", - "data": null, - "error": { - "code": "BUSINESS_PLAN_NOT_FOUND", - "message": "해당 사업계획서가 존재하지 않습니다." - } - } - """ - ) - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "500", - description = "조회 오류", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "result": "ERROR", - "data": null, - "error": { - "code": "EXPERT_APPLICATION_QUERY_ERROR", - "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." - } - } - """ - ) - ) - ) - }) - ApiResponse> search( - @Parameter( - description = "사업계획서 ID", - required = true, - example = "1" - ) - @RequestParam Long businessPlanId - ); - @Operation( summary = "전문가에게 피드백 요청", description = """ @@ -297,7 +226,7 @@ ApiResponse requestFeedback( @RequestParam("file") MultipartFile file, @Parameter(hidden = true) - @AuthenticationPrincipal AuthenticatedMember auth + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) throws Exception; /** diff --git a/src/main/java/starlight/adapter/member/webapi/MemberController.java b/src/main/java/starlight/adapter/member/webapi/MemberController.java index 61a9a75c..bfa19d73 100644 --- a/src/main/java/starlight/adapter/member/webapi/MemberController.java +++ b/src/main/java/starlight/adapter/member/webapi/MemberController.java @@ -22,8 +22,10 @@ public class MemberController implements MemberApiDoc { @GetMapping public ApiResponse getMemberDetail( - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { - return ApiResponse.success(MemberDetailResponse.fromMember(memberQueryUseCase.getUserById(authDetails.getMemberId()))); + return ApiResponse.success(MemberDetailResponse.fromMember( + memberQueryUseCase.getUserById(authenticatedMember.getMemberId()) + )); } } diff --git a/src/main/java/starlight/adapter/member/webapi/swagger/MemberApiDoc.java b/src/main/java/starlight/adapter/member/webapi/swagger/MemberApiDoc.java index 74112686..9295dbf5 100644 --- a/src/main/java/starlight/adapter/member/webapi/swagger/MemberApiDoc.java +++ b/src/main/java/starlight/adapter/member/webapi/swagger/MemberApiDoc.java @@ -65,6 +65,6 @@ public interface MemberApiDoc { }) @GetMapping ApiResponse getMemberDetail( - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ); } diff --git a/src/main/java/starlight/adapter/order/webapi/OrderController.java b/src/main/java/starlight/adapter/order/webapi/OrderController.java index d979b7af..c9edc481 100644 --- a/src/main/java/starlight/adapter/order/webapi/OrderController.java +++ b/src/main/java/starlight/adapter/order/webapi/OrderController.java @@ -30,11 +30,11 @@ public class OrderController implements OrderApiDoc { @PostMapping("/request") public ApiResponse prepareOrder( @Valid @RequestBody OrderPrepareRequest request, - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { Orders order = orderPaymentService.prepare( request.orderCode(), - authDetails.getMemberId(), + authenticatedMember.getMemberId(), request.productCode() ); @@ -46,12 +46,12 @@ public ApiResponse prepareOrder( @PostMapping("/confirm") public ApiResponse confirmPayment( @Valid @RequestBody OrderConfirmRequest request, - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { Orders order = orderPaymentService.confirm( request.orderCode(), request.paymentKey(), - authDetails.getMemberId() + authenticatedMember.getMemberId() ); OrderConfirmResponse response = OrderConfirmResponse.from(order); @@ -75,9 +75,9 @@ public ApiResponse cancelPayment( @GetMapping public ApiResponse> getMyPayments( - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { - Long memberId = authDetails.getMemberId(); + Long memberId = authenticatedMember.getMemberId(); List history = orderPaymentService.getPaymentHistory(memberId); return ApiResponse.success(history); diff --git a/src/main/java/starlight/adapter/order/webapi/swagger/OrderApiDoc.java b/src/main/java/starlight/adapter/order/webapi/swagger/OrderApiDoc.java index c2b0ceb4..8c406bc7 100644 --- a/src/main/java/starlight/adapter/order/webapi/swagger/OrderApiDoc.java +++ b/src/main/java/starlight/adapter/order/webapi/swagger/OrderApiDoc.java @@ -117,7 +117,7 @@ public interface OrderApiDoc { @PostMapping("/request") ApiResponse prepareOrder( @Valid @RequestBody OrderPrepareRequest request, - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ); @Operation(summary = "결제 승인", security = @SecurityRequirement(name = "Bearer Authentication")) @@ -215,7 +215,7 @@ ApiResponse prepareOrder( @PostMapping("/confirm") ApiResponse confirmPayment( @Valid @RequestBody OrderConfirmRequest request, - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ); @Operation(summary = "결제 취소") @@ -328,6 +328,6 @@ ApiResponse cancelPayment( }) @GetMapping ApiResponse> getMyPayments( - @AuthenticationPrincipal AuthenticatedMember authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ); } diff --git a/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java b/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java new file mode 100644 index 00000000..8c472ba2 --- /dev/null +++ b/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java @@ -0,0 +1,61 @@ +package starlight.application.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.expert.provided.ExpertAiReportQueryUseCase; +import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; +import starlight.application.expert.required.AiReportSummaryLookupPort; +import starlight.application.expert.required.BusinessPlanLookupPort; +import starlight.application.expert.required.ExpertApplicationCountLookupPort; +import starlight.domain.businessplan.entity.BusinessPlan; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExpertAiReportQueryService implements ExpertAiReportQueryUseCase { + + private final BusinessPlanLookupPort businessPlanLookupPort; + private final AiReportSummaryLookupPort aiReportSummaryLookupPort; + private final ExpertApplicationCountLookupPort expertApplicationCountLookupPort; + + @Override + public List findAiReportBusinessPlans(Long expertId, Long memberId) { + + List plans = businessPlanLookupPort.findAllByMemberId(memberId); + if (plans.isEmpty()) { + return List.of(); + } + + List planIds = plans.stream() + .map(BusinessPlan::getId) + .toList(); + + Map totalScoreMap = aiReportSummaryLookupPort.findTotalScoresByBusinessPlanIds(planIds); + if (totalScoreMap.isEmpty()) { + return List.of(); + } + + List aiReportPlanIds = totalScoreMap.keySet().stream().toList(); + Map requestCountMap = expertApplicationCountLookupPort.countByExpertIdAndBusinessPlanIds(expertId, aiReportPlanIds); + + return plans.stream() + .filter(plan -> totalScoreMap.containsKey(plan.getId())) + .map(plan -> { + Integer totalScore = totalScoreMap.getOrDefault(plan.getId(), 0); + boolean isOver70 = totalScore >= 70; + Long requestCount = requestCountMap.getOrDefault(plan.getId(), 0L); + return new ExpertAiReportBusinessPlanResult( + plan.getId(), + plan.getTitle(), + requestCount, + isOver70 + ); + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/starlight/application/expert/provided/ExpertAiReportQueryUseCase.java b/src/main/java/starlight/application/expert/provided/ExpertAiReportQueryUseCase.java new file mode 100644 index 00000000..3a07be11 --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/ExpertAiReportQueryUseCase.java @@ -0,0 +1,10 @@ +package starlight.application.expert.provided; + +import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; + +import java.util.List; + +public interface ExpertAiReportQueryUseCase { + + List findAiReportBusinessPlans(Long expertId, Long memberId); +} diff --git a/src/main/java/starlight/application/expert/provided/dto/ExpertAiReportBusinessPlanResult.java b/src/main/java/starlight/application/expert/provided/dto/ExpertAiReportBusinessPlanResult.java new file mode 100644 index 00000000..82fa46cf --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/dto/ExpertAiReportBusinessPlanResult.java @@ -0,0 +1,9 @@ +package starlight.application.expert.provided.dto; + +public record ExpertAiReportBusinessPlanResult( + Long businessPlanId, + String businessPlanTitle, + Long requestCount, + boolean isOver70 +) { +} diff --git a/src/main/java/starlight/application/expert/required/AiReportSummaryLookupPort.java b/src/main/java/starlight/application/expert/required/AiReportSummaryLookupPort.java new file mode 100644 index 00000000..00db8d52 --- /dev/null +++ b/src/main/java/starlight/application/expert/required/AiReportSummaryLookupPort.java @@ -0,0 +1,9 @@ +package starlight.application.expert.required; + +import java.util.List; +import java.util.Map; + +public interface AiReportSummaryLookupPort { + + Map findTotalScoresByBusinessPlanIds(List businessPlanIds); +} diff --git a/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java b/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java new file mode 100644 index 00000000..63a11a09 --- /dev/null +++ b/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java @@ -0,0 +1,10 @@ +package starlight.application.expert.required; + +import starlight.domain.businessplan.entity.BusinessPlan; + +import java.util.List; + +public interface BusinessPlanLookupPort { + + List findAllByMemberId(Long memberId); +} diff --git a/src/main/java/starlight/application/expert/required/ExpertApplicationCountLookupPort.java b/src/main/java/starlight/application/expert/required/ExpertApplicationCountLookupPort.java index 8f0bd78f..14850807 100644 --- a/src/main/java/starlight/application/expert/required/ExpertApplicationCountLookupPort.java +++ b/src/main/java/starlight/application/expert/required/ExpertApplicationCountLookupPort.java @@ -6,4 +6,6 @@ public interface ExpertApplicationCountLookupPort { Map countByExpertIds(List expertIds); + + Map countByExpertIdAndBusinessPlanIds(Long expertId, List businessPlanIds); } diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationQueryService.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationQueryService.java deleted file mode 100644 index 79f763b8..00000000 --- a/src/main/java/starlight/application/expertApplication/ExpertApplicationQueryService.java +++ /dev/null @@ -1,25 +0,0 @@ -package starlight.application.expertApplication; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.expertApplication.provided.ExpertApplicationQueryUseCase; -import starlight.application.expertApplication.required.ExpertApplicationQueryPort; - -import java.util.List; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ExpertApplicationQueryService implements ExpertApplicationQueryUseCase { - - private final ExpertApplicationQueryPort expertApplicationQueryPort; - private final BusinessPlanQuery businessPlanQuery; - - @Override - public List findRequestedExpertIds(Long businessPlanId) { - businessPlanQuery.findByIdOrThrow(businessPlanId); - return expertApplicationQueryPort.findRequestedExpertIds(businessPlanId); - } -} diff --git a/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationQueryUseCase.java b/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationQueryUseCase.java deleted file mode 100644 index 0366b856..00000000 --- a/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationQueryUseCase.java +++ /dev/null @@ -1,8 +0,0 @@ -package starlight.application.expertApplication.provided; - -import java.util.List; - -public interface ExpertApplicationQueryUseCase { - - List findRequestedExpertIds(Long businessPlanId); -} diff --git a/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQueryPort.java b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQueryPort.java index 4c22587c..a954c8ef 100644 --- a/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQueryPort.java +++ b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQueryPort.java @@ -2,12 +2,8 @@ import starlight.domain.expertApplication.entity.ExpertApplication; -import java.util.List; - public interface ExpertApplicationQueryPort { Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId); - List findRequestedExpertIds(Long businessPlanId); - ExpertApplication save(ExpertApplication application); } diff --git a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java index 7a0d1cba..a4c7a490 100644 --- a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java +++ b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java @@ -1,5 +1,6 @@ package starlight.adapter.expert.webapi; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -11,13 +12,27 @@ import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import starlight.adapter.member.auth.security.filter.JwtFilter; +import starlight.application.expert.provided.ExpertAiReportQueryUseCase; import starlight.application.expert.provided.ExpertDetailQueryUseCase; +import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; import starlight.application.expert.provided.dto.ExpertCareerResult; import starlight.application.expert.provided.dto.ExpertDetailResult; import starlight.domain.expert.enumerate.TagCategory; +import starlight.shared.auth.AuthenticatedMember; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import java.util.Collections; import java.util.List; import java.util.Set; @@ -34,11 +49,14 @@ ) ) @AutoConfigureMockMvc(addFilters = false) +@Import(ExpertControllerTest.SecurityTestConfig.class) class ExpertControllerTest { @Autowired MockMvc mockMvc; @MockitoBean ExpertDetailQueryUseCase expertDetailQuery; + @MockitoBean + ExpertAiReportQueryUseCase expertAiReportQuery; @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; @Test @@ -74,6 +92,31 @@ void detail() throws Exception { .andExpect(jsonPath("$.data.tags").isArray()); } + @Test + @DisplayName("전문가 상세 AI 리포트 보유 사업계획서 목록 조회") + void aiReportBusinessPlans() throws Exception { + List results = List.of( + new ExpertAiReportBusinessPlanResult(10L, "테스트 사업계획서", 2L, true), + new ExpertAiReportBusinessPlanResult(11L, "신규 사업계획서", 0L, false) + ); + when(expertAiReportQuery.findAiReportBusinessPlans(7L, 100L)).thenReturn(results); + + mockMvc.perform(get("/v1/experts/7/business-plans/ai-reports") + .with(authenticatedMember(100L))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result").value("SUCCESS")) + .andExpect(jsonPath("$.data[0].businessPlanId").value(10L)) + .andExpect(jsonPath("$.data[0].requestCount").value(2L)) + .andExpect(jsonPath("$.data[0].isOver70").value(true)) + .andExpect(jsonPath("$.data[1].businessPlanId").value(11L)) + .andExpect(jsonPath("$.data[1].isOver70").value(false)); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + // helper private ExpertDetailResult expertResult(Long id, String name, Set cats) throws Exception { List careers = List.of( @@ -98,4 +141,38 @@ private ExpertDetailResult expertResult(Long id, String name, Set c cats.stream().map(TagCategory::name).toList() ); } + + private Authentication testAuthentication(Long memberId) { + AuthenticatedMember member = new TestAuthenticatedMember(memberId, "tester"); + return new UsernamePasswordAuthenticationToken(member, null, Collections.emptyList()); + } + + private RequestPostProcessor authenticatedMember(Long memberId) { + return request -> { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(testAuthentication(memberId)); + SecurityContextHolder.setContext(context); + return request; + }; + } + + private record TestAuthenticatedMember(Long memberId, String memberName) implements AuthenticatedMember { + @Override + public Long getMemberId() { + return memberId; + } + + @Override + public String getMemberName() { + return memberName; + } + } + + @TestConfiguration + static class SecurityTestConfig implements WebMvcConfigurer { + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new AuthenticationPrincipalArgumentResolver()); + } + } }