diff --git a/stock-diary/build.gradle b/stock-diary/build.gradle index 385f72f..df9c22c 100644 --- a/stock-diary/build.gradle +++ b/stock-diary/build.gradle @@ -61,6 +61,11 @@ dependencies { // AWS SDK S3 (MinIO compatible) implementation 'software.amazon.awssdk:s3:2.20.0' + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' diff --git a/stock-diary/src/main/java/com/ogd/stockdiary/application/config/QueryDslConfig.java b/stock-diary/src/main/java/com/ogd/stockdiary/application/config/QueryDslConfig.java new file mode 100644 index 0000000..142599e --- /dev/null +++ b/stock-diary/src/main/java/com/ogd/stockdiary/application/config/QueryDslConfig.java @@ -0,0 +1,21 @@ +package com.ogd.stockdiary.application.config; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/stock-diary/src/main/java/com/ogd/stockdiary/application/report/repository/PrincipleCheckQueryDslAdapter.java b/stock-diary/src/main/java/com/ogd/stockdiary/application/report/repository/PrincipleCheckQueryDslAdapter.java new file mode 100644 index 0000000..54aaf66 --- /dev/null +++ b/stock-diary/src/main/java/com/ogd/stockdiary/application/report/repository/PrincipleCheckQueryDslAdapter.java @@ -0,0 +1,134 @@ +package com.ogd.stockdiary.application.report.repository; + +import static com.ogd.stockdiary.domain.image.entity.QPrincipleCheckImage.principleCheckImage; +import static com.ogd.stockdiary.domain.investmentprinciple.entity.QInvestmentPrinciple.investmentPrinciple; +import static com.ogd.stockdiary.domain.principlecheck.entity.QPrincipleCheck.principleCheck; +import static com.ogd.stockdiary.domain.principlecheck.entity.QPrincipleCheckLink.principleCheckLink; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Repository; + +import com.ogd.stockdiary.domain.fileclient.port.out.FileClientPort; +import com.ogd.stockdiary.domain.report.port.out.PrincipleCheckPort; +import com.ogd.stockdiary.domain.report.vo.PrincipleCheckData; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PrincipleCheckQueryDslAdapter implements PrincipleCheckPort { + + private final JPAQueryFactory queryFactory; + private final FileClientPort fileClientPort; + + @Override + public List findByRetrospectionId(Long retrospectionId) { + // 1. PrincipleCheck 기본 데이터 조회 + List checks = queryFactory + .select(Projections.constructor(PrincipleCheckDto.class, + principleCheck.id, + investmentPrinciple.principle, + principleCheck.status.stringValue(), + principleCheck.reason)) + .from(principleCheck) + .join(principleCheck.principle, investmentPrinciple) + .where(principleCheck.retrospection.id.eq(retrospectionId)) + .fetch(); + + if (checks.isEmpty()) { + return List.of(); + } + + // 2. checkId 리스트 추출 + List checkIds = checks.stream() + .map(PrincipleCheckDto::checkId) + .collect(Collectors.toList()); + + // 3. 이미지 다운로드 URL 맵 조회 (checkId -> List) + Map> imageUrlsMap = getImageUrlsMap(checkIds); + + // 4. 링크 맵 조회 (checkId -> List) + Map> linksMap = getLinksMap(checkIds); + + // 5. VO로 변환 + return checks.stream() + .map(dto -> new PrincipleCheckData( + dto.principleName, + dto.status, + dto.reason, + imageUrlsMap.getOrDefault(dto.checkId, List.of()), + linksMap.getOrDefault(dto.checkId, List.of()))) + .collect(Collectors.toList()); + } + + /** + * 이미지 다운로드 URL 맵 조회 + * objectKey를 조회한 후 download pre-signed URL로 변환 + */ + private Map> getImageUrlsMap(List checkIds) { + List imageKeyDtos = queryFactory + .select(Projections.constructor(ImageKeyDto.class, + principleCheckImage.principleCheck.id, + principleCheckImage.image.objectKey)) + .from(principleCheckImage) + .where(principleCheckImage.principleCheck.id.in(checkIds)) + .fetch(); + + // objectKey를 download URL로 변환 + return imageKeyDtos.stream() + .collect(Collectors.groupingBy( + ImageKeyDto::checkId, + Collectors.mapping( + dto -> fileClientPort.getDownloadPreSignedUrl(dto.objectKey, 3600), + Collectors.toList()))); + } + + /** + * 링크 맵 조회 + */ + private Map> getLinksMap(List checkIds) { + List linkDtos = queryFactory + .select(Projections.constructor(LinkDto.class, + principleCheckLink.principleCheck.id, + principleCheckLink.linkUrl)) + .from(principleCheckLink) + .where(principleCheckLink.principleCheck.id.in(checkIds)) + .fetch(); + + return linkDtos.stream() + .collect(Collectors.groupingBy( + LinkDto::checkId, + Collectors.mapping(LinkDto::linkUrl, Collectors.toList()))); + } + + /** + * 중간 DTO - PrincipleCheck 데이터 + */ + private record PrincipleCheckDto( + Long checkId, + String principleName, + String status, + String reason) { + } + + /** + * 중간 DTO - 이미지 objectKey (download URL 변환용) + */ + private record ImageKeyDto( + Long checkId, + String objectKey) { + } + + /** + * 중간 DTO - 링크 + */ + private record LinkDto( + Long checkId, + String linkUrl) { + } +} diff --git a/stock-diary/src/main/java/com/ogd/stockdiary/application/report/service/ReportService.java b/stock-diary/src/main/java/com/ogd/stockdiary/application/report/service/ReportService.java index 04c6b52..eee9c15 100644 --- a/stock-diary/src/main/java/com/ogd/stockdiary/application/report/service/ReportService.java +++ b/stock-diary/src/main/java/com/ogd/stockdiary/application/report/service/ReportService.java @@ -22,8 +22,10 @@ import com.ogd.stockdiary.domain.report.port.in.CreateFeedbackCommand; import com.ogd.stockdiary.domain.report.port.in.CreateFeedbackUseCase; import com.ogd.stockdiary.domain.report.port.out.FeedbackRepository; +import com.ogd.stockdiary.domain.report.port.out.PrincipleCheckPort; import com.ogd.stockdiary.domain.report.port.out.ReportPromptLoader; import com.ogd.stockdiary.domain.report.port.out.RetrospectionForReportRepository; +import com.ogd.stockdiary.domain.report.vo.PrincipleCheckData; import com.ogd.stockdiary.domain.retrospection.entity.Order; import com.ogd.stockdiary.domain.retrospection.entity.Retrospection; import com.ogd.stockdiary.domain.retrospection.port.out.RetrospectionRepository; @@ -37,6 +39,7 @@ public class ReportService implements CreateFeedbackUseCase { private final RetrospectionForReportRepository retrospectionForReportRepository; private final RetrospectionRepository retrospectionRepository; private final FeedbackRepository feedbackRepository; + private final PrincipleCheckPort principleCheckPort; private final ReportPromptLoader reportPromptLoader; private final ChatModel chatModel; private final ObjectMapper objectMapper; @@ -56,9 +59,19 @@ public Feedback createFeedbackUseCase(CreateFeedbackCommand command) Order order = retrospectionForReport.getOrder(); String content = retrospectionForReport.getContent(); + // 투자원칙 체크 데이터 조회 + List principleChecks = principleCheckPort + .findByRetrospectionId(command.retrospectionId()); + + // 투자원칙 체크 데이터를 프롬프트 형식으로 변환 + String principleChecksText = PrincipleCheckData.toPromptFormat(principleChecks); + String userText = """ Please analyze the symbol {symbol} in the {market} market based on the order: {order}. This is user message : {content}. + + Investment Principles Checked: + {principleChecks} """; // 시스템 메시지를 로더에서 불러오기 @@ -66,7 +79,12 @@ public Feedback createFeedbackUseCase(CreateFeedbackCommand command) PromptTemplate promptTemplate = new PromptTemplate(userText); - Map variables = Map.of("symbol", symbol, "market", market, "order", order, "content", content); + Map variables = Map.of( + "symbol", symbol, + "market", market, + "order", order, + "content", content, + "principleChecks", principleChecksText); // 플레이스 홀더 넣은 유저 메시지 구성 Message userMessage = promptTemplate.createMessage(variables); diff --git a/stock-diary/src/main/java/com/ogd/stockdiary/domain/report/port/out/PrincipleCheckPort.java b/stock-diary/src/main/java/com/ogd/stockdiary/domain/report/port/out/PrincipleCheckPort.java new file mode 100644 index 0000000..2a25da7 --- /dev/null +++ b/stock-diary/src/main/java/com/ogd/stockdiary/domain/report/port/out/PrincipleCheckPort.java @@ -0,0 +1,21 @@ +package com.ogd.stockdiary.domain.report.port.out; + +import java.util.List; + +import com.ogd.stockdiary.domain.report.vo.PrincipleCheckData; + +/** + * 투자원칙 체크 데이터 조회 Port + * Report 도메인이 다른 도메인에 의존하지 않도록 추상화 + */ +public interface PrincipleCheckPort { + + /** + * 특정 회고에 대한 모든 투자원칙 체크 데이터 조회 + * + * @param retrospectionId + * 회고 ID + * @return 투자원칙 체크 데이터 목록 + */ + List findByRetrospectionId(Long retrospectionId); +} diff --git a/stock-diary/src/main/java/com/ogd/stockdiary/domain/report/vo/PrincipleCheckData.java b/stock-diary/src/main/java/com/ogd/stockdiary/domain/report/vo/PrincipleCheckData.java new file mode 100644 index 0000000..77c5686 --- /dev/null +++ b/stock-diary/src/main/java/com/ogd/stockdiary/domain/report/vo/PrincipleCheckData.java @@ -0,0 +1,62 @@ +package com.ogd.stockdiary.domain.report.vo; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Report 도메인에서 사용하는 투자원칙 체크 데이터 + * AI에 전달할 정보만 포함 (읽기 전용 VO) + */ +public record PrincipleCheckData( + String principleName, // 투자원칙 내용 + String status, // 체크 상태 (CHECKED, NOT_CHECKED, VIOLATED) + String reason, // 체크 이유/설명 + List imageUrls, // 관련 이미지 다운로드 URL 목록 + List links // 관련 링크 목록 +) { + + /** + * 빈 데이터 생성 헬퍼 + */ + public static PrincipleCheckData empty() { + return new PrincipleCheckData("", "", "", List.of(), List.of()); + } + + /** + * AI 프롬프트용 포맷팅 + */ + public String toPromptFormat() { + StringBuilder sb = new StringBuilder(); + sb.append("- 원칙: ").append(principleName).append("\n"); + sb.append(" 준수 여부: ").append(status).append("\n"); + + if (reason != null && !reason.isBlank()) { + sb.append(" 사유: ").append(reason).append("\n"); + } + + if (!imageUrls.isEmpty()) { + sb.append(" 첨부 이미지:\n"); + imageUrls.forEach(url -> sb.append(" - ").append(url).append("\n")); + } + + if (!links.isEmpty()) { + sb.append(" 참고 링크:\n"); + links.forEach(link -> sb.append(" - ").append(link).append("\n")); + } + + return sb.toString(); + } + + /** + * 여러 체크 데이터를 프롬프트 형식으로 변환 + */ + public static String toPromptFormat(List checks) { + if (checks == null || checks.isEmpty()) { + return "투자원칙 체크 데이터 없음"; + } + + return checks.stream() + .map(PrincipleCheckData::toPromptFormat) + .collect(Collectors.joining("\n")); + } +}