diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index 690bd76f..f72227cf 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -86,6 +86,10 @@ jobs: MONTHLY_V2_BASELINE_PROMPT_B64="${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }}" MONTHLY_V2_BASELINE_PROMPT="$(printf '%s' "$MONTHLY_V2_BASELINE_PROMPT_B64" | base64 -d | tr -d '\r')" export MONTHLY_V2_BASELINE_PROMPT + + MONTHLY_V2_COMPARISON_PROMPT_B64="${{ secrets.MONTHLY_V2_COMPARISON_PROMPT_B64 }}" + MONTHLY_V2_COMPARISON_PROMPT="$(printf '%s' "$MONTHLY_V2_COMPARISON_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V2_COMPARISON_PROMPT EVIDENCE_CARD_PROMPT_B64="${{ secrets.EVIDENCE_CARD_PROMPT_B64 }}" EVIDENCE_CARD_PROMPT="$(printf '%s' "$EVIDENCE_CARD_PROMPT_B64" | base64 -d | tr -d '\r')" @@ -117,6 +121,7 @@ jobs: JWT_SECRET="${{ secrets.JWT_SECRET }}" \ FRONTEND_DEV_URL="${{ secrets.FRONTEND_DEV_URL }}" \ FRONTEND_DEV_URL_2="${{ secrets.FRONTEND_DEV_URL_2 }}" \ + FRONTEND_DEV_URL_3="${{ secrets.FRONTEND_DEV_URL_3 }}" \ SWAGGER_SERVER_URL="${{ secrets.SWAGGER_SERVER_URL }}" \ SWAGGER_ORIGIN_PUBLIC_IP="${{ secrets.SWAGGER_ORIGIN_PUBLIC_IP }}" \ SWAGGER_ORIGIN_DOMAIN="${{ secrets.SWAGGER_ORIGIN_DOMAIN }}" \ diff --git a/.github/workflows/deploy-to-prod-ec2.yml b/.github/workflows/deploy-to-prod-ec2.yml index 75b327a9..b27ad5a1 100644 --- a/.github/workflows/deploy-to-prod-ec2.yml +++ b/.github/workflows/deploy-to-prod-ec2.yml @@ -86,6 +86,10 @@ jobs: MONTHLY_V2_BASELINE_PROMPT_B64="${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }}" MONTHLY_V2_BASELINE_PROMPT="$(printf '%s' "$MONTHLY_V2_BASELINE_PROMPT_B64" | base64 -d | tr -d '\r')" export MONTHLY_V2_BASELINE_PROMPT + + MONTHLY_V2_COMPARISON_PROMPT_B64="${{ secrets.MONTHLY_V2_COMPARISON_PROMPT_B64 }}" + MONTHLY_V2_COMPARISON_PROMPT="$(printf '%s' "$MONTHLY_V2_COMPARISON_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V2_COMPARISON_PROMPT EVIDENCE_CARD_PROMPT_B64="${{ secrets.EVIDENCE_CARD_PROMPT_B64 }}" EVIDENCE_CARD_PROMPT="$(printf '%s' "$EVIDENCE_CARD_PROMPT_B64" | base64 -d | tr -d '\r')" diff --git a/.gitignore b/.gitignore index 66d84356..044f3c21 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ node_modules # 오늘의 리포트 프롬프트 /src/main/resources/secret/ -AGENTS.md \ No newline at end of file +AGENTS.md +.codex/ +PROJECT_LOG.md \ No newline at end of file diff --git a/README.md b/README.md index e87f19a6..09a747b1 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,192 @@ -# ⏳ Nadab BE +# Nadab Backend -### 🔗️ Note -> 이 문서는 [Saerok 프로젝트](https://github.com/DevKor-github/saerok-BE)의 README를 참고하여 구성하였으며, Nadab의 컨벤션에 맞게 수정되었습니다. +

+ Nadab 서비스 소개 +

+ +사용자의 일상 답변을 바탕으로 AI 일간·주간·월간·유형 리포트를 생성하고, 친구·소셜 상호작용과 운영 기능을 제공하는 Spring Boot 백엔드 서버입니다. + +--- + +## 서비스 링크 + +- [App Store](https://apps.apple.com/us/app/%EB%82%98%EB%8B%B5-ai-%EC%9E%90%EA%B8%B0%EC%84%B1%EC%B0%B0-%EA%B8%B0%EB%A1%9D-%EB%B0%8F-%EA%B0%90%EC%A0%95-%EB%B6%84%EC%84%9D-%EC%9D%BC%EA%B8%B0/id6761776437) +- [Google Play](https://play.google.com/store/apps/details?id=com.nadab.app) +- [Web](https://www.nadab.app/) --- -## 🛠️ Stacks +## 기술 스택 -- JAVA 21 -- Spring Boot 3.5.7 -- PostgreSQL -- JPA (Hibernate) -- Gradle -- GitHub Actions + AWS EC2 (CI/CD) +| 구분 | 기술 | +| --- | --- | +| Language | Java 21 | +| Framework | Spring Boot 3.5.7, Spring Security, Spring Data JPA | +| Database | PostgreSQL, Flyway | +| AI | Spring AI, OpenAI, Google Gemini | +| Infrastructure | AWS EC2, S3, KMS, Firebase Cloud Messaging | +| API / View | Swagger/OpenAPI, Thymeleaf | +| Test | JUnit 5, Mockito, AssertJ, Testcontainers | +| Build / CI/CD | Gradle, GitHub Actions | --- -## 🧾 Commit Convention +## 주요 기능 -Nadab은 Conventional Commits 규칙을 기반으로 commitlint를 사용해 커밋 메시지 품질을 관리합니다. +- OAuth 및 이메일 기반 인증, JWT 토큰 관리 +- 오늘의 질문과 답변 기록, 이미지 처리 +- AI 기반 일간·주간·월간·유형 리포트 생성 +- 친구, 피드 공유, 댓글, 좋아요, 신고·차단 +- 푸시 알림, 이메일 인증, 알림 설정 +- 앱 버전, 사용자 통계 등 관리자 기능 +- 사용자 지갑 및 크리스탈 이력 관리 -모든 커밋은 아래와 같이 지정된 type / scope / subject 형식을 따라야 하며, 규칙을 위반하면 커밋이 거부됩니다. +--- + +## 로컬 개발 환경 설정 + +### 1. 저장소 클론 + +```bash +git clone https://github.com/DevKor-github/nadab-BE.git +cd nadab-BE ``` -(): - +커밋 도구를 사용하려면 Node.js를 설치한 뒤 패키지를 설치합니다. + +```bash +npm install ``` +### 2. JDK 설정 + +- JDK 21을 설치합니다. +- IntelliJ에서 프로젝트 루트 또는 `build.gradle`을 프로젝트로 엽니다. +- Project SDK와 Gradle JVM을 JDK 21로 설정합니다. -### 1. type (무엇을 했는가) +### 3. PostgreSQL 준비 -| 타입 | 설명 | -|------|------| -| ✨ `feat` | 새로운 기능 추가 | -| 🐛 `fix` | 버그 수정 | -| 📚 `docs` | 문서 관련 변경 (예: README, 주석) | -| 💎 `style` | 코드 의미에는 영향을 주지 않는 스타일 변경 (포맷, 세미콜론 등) | -| 📦 `refactor` | 리팩토링 (기능 변경 없이 구조 개선) | -| 🚀 `perf` | 성능 개선 | -| 🚨 `test` | 테스트 코드 추가/보완 | -| ⚙️ `ci` | CI 설정 및 관련 스크립트 변경 | -| ♻️ `chore` | 기타 작업 (예: 패키지 설치, 설정 파일 변경, 디렉토리 정리 등) | -| 🗑 `revert` | 이전 커밋 되돌리기 | +로컬 PostgreSQL에 사용할 데이터베이스를 생성합니다. 애플리케이션 시작 시 Flyway가 `src/main/resources/db/migration`의 마이그레이션을 자동 적용하며, Hibernate는 스키마를 `validate` 모드로 검증합니다. --- -### 2. scope (어디를 바꿨는가) +## 테스트 + +단위 테스트는 JUnit 5, Mockito, AssertJ를 사용합니다. 데이터베이스 통합 테스트는 PostgreSQL Testcontainers를 사용하므로 Docker Desktop이 실행 중이어야 합니다. + +```powershell +# 전체 테스트 +.\gradlew.bat test + +# 단일 테스트 클래스 +.\gradlew.bat test --tests "com.devkor.ifive.nadab.domain.auth.application.AuthServiceV2Test" +``` -| Scope | 설명 | -|:-------------|:---------------------------------------------| -| 🔐 `auth` | 인증/인가 도메인 | -| 🙋‍♂️ `user` | 사용자 도메인 | -| 🤖 `ai` | AI 처리 | -| 👥 `friend` | 친구 도메인 | -| 📧 `notify` | 알림/이메일 전송 | -| 🛠️ `infra` | 설정/인프라 | -| 🗄️ `db` | 데이터베이스 및 마이그레이션 | -| 🌎 `global` | 공용 설정 및 전역 리소스 | +`develop` 브랜치 대상 Pull Request에는 GitHub Actions가 전체 테스트를 자동 실행합니다. --- -### 3. subject (무슨 일이 일어났는가) +## 배포 환경 -- 변경된 내용을 한 줄로 간결하게 요약합니다. -- 한국어로 자연스럽게 작성하며, 읽는 사람이 바로 이해할 수 있도록 합니다. -- 문장 끝에는 마침표(.)를 붙이지 않습니다. +- `develop` 브랜치 push: 개발 EC2 자동 배포 +- `main` 브랜치 push: 운영 EC2 자동 배포 +- GitHub Actions에서 JDK 21로 실행 JAR을 빌드합니다. +- 빌드 산출물을 SCP로 EC2에 전송하고 프로필별 환경 변수를 주입해 애플리케이션을 재시작합니다. +- PostgreSQL, S3, KMS, Firebase 및 외부 AI API는 환경별 자격 증명으로 연동합니다. -> 예: 회원 탈퇴 기능 추가, 토큰 재발급 로직 수정, 빌드 스크립트 구조 개선 +배포 관련 secret은 GitHub Environment의 `dev`, `prod` 설정에서 관리하며 저장소에 직접 기록하지 않습니다. --- -### 4. body (왜 그렇게 했는가) +## 브랜치 전략 -- 선택 항목이지만, 기능 수정이나 리팩토링 등 맥락이 필요한 변경이라면 작성합니다. -- “무엇을 바꿨고, 왜 바꿨는지”를 중심으로 구체적으로 설명합니다. -> 예: -사용자 조회 시 불필요한 쿼리가 반복 실행되어 응답 속도가 저하되었습니다. -쿼리 캐싱을 적용하여 데이터 접근 횟수를 줄이고 성능을 개선했습니다. +- `main`: 운영 배포 브랜치 +- `develop`: 개발 통합 및 개발 서버 배포 브랜치 +- 기능 개발 브랜치: `feat/*`, `fix/*`, `refactor/*`, `chore/*` 등 작업 목적에 맞게 생성 + +기능 브랜치는 `develop`을 기준으로 작업하고 Pull Request를 통해 병합합니다. 운영 반영은 `develop`에서 검증된 변경을 `main`으로 병합해 진행합니다. --- -### 설치 및 사용법 +## 프로젝트 구조 + +```text +src +├─ main +│ ├─ java/com/devkor/ifive/nadab +│ │ ├─ domain # 비즈니스 도메인 +│ │ └─ global # 공통 설정, 보안, 예외, 인프라, 유틸리티 +│ └─ resources +│ ├─ db/migration # Flyway 마이그레이션 +│ ├─ templates # 관리자·통계 Thymeleaf 화면 +│ └─ application*.yml +└─ test + └─ java/com/devkor/ifive/nadab + └─ infra # Testcontainers 및 테스트 빌더 +``` + +각 도메인은 기능에 따라 다음 계층을 조합합니다. + +- `api`: Controller와 요청·응답 DTO +- `application`: 유스케이스, 트랜잭션, 이벤트, 스케줄러 +- `core`: Entity, Repository, Domain Service, 내부 모델 +- `infra`: 외부 AI API, 스토리지 등 외부 연동 구현 + +공통 설정과 여러 도메인이 함께 사용하는 기능은 `global`에 둡니다. 데이터베이스 스키마 변경은 JPA 설정에 의존하지 않고 Flyway 마이그레이션으로 관리합니다. + +--- + +## 커밋 컨벤션 + +Conventional Commits 기반의 commitlint, Husky, Commitizen을 사용합니다. + +```text +(): + + +``` + +### Type + +| Type | 설명 | +| --- | --- | +| `feat` | 새로운 기능 추가 | +| `fix` | 버그 수정 | +| `docs` | 문서 변경 | +| `chore` | 빌드·설정 등 기타 작업 | +| `style` | 동작에 영향을 주지 않는 스타일 변경 | +| `refactor` | 기능 변경 없는 구조 개선 | +| `test` | 테스트 추가 또는 보완 | +| `perf` | 성능 개선 | +| `ci` | CI/CD 설정 변경 | +| `revert` | 이전 커밋 되돌리기 | + +### Scope + +허용 scope는 다음과 같습니다. + +```text +admin, auth, user, ai, report, moderation, search, stats, +friend, social, notify, infra, db, global +``` + +- subject는 변경 내용을 한국어로 간결하게 작성합니다. +- subject 끝에 마침표를 붙이지 않습니다. +- 맥락이 필요한 변경은 body에 무엇을 바꿨고 왜 바꿨는지 작성합니다. +- scope는 생략할 수 있지만, 사용할 경우 허용 목록 중 하나를 선택해야 합니다. + +```bash +# 직접 작성 +git commit -m "feat(report): 월간 리포트 조회 기능 추가" + +# Commitizen 프롬프트 사용 +npm run commit +``` + +--- -- 터미널에서 `npm install`을 실행하여 commitlint를 설치합니다. -- 커밋 시 터미널에서 `npm run commit` 입력을 통해 커밋합니다. +## 참고 +- 마이그레이션: `src/main/resources/db/migration` +- CI/CD: `.github/workflows` diff --git a/docs/images/nadab-banner.png b/docs/images/nadab-banner.png new file mode 100644 index 00000000..2e6be9de Binary files /dev/null and b/docs/images/nadab-banner.png differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da4..4f5eb9dc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java index 54b8ad5b..580d9a54 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java @@ -20,4 +20,9 @@ public String adminRoot() { public String adminVersionPage() { return "admin/version"; } + + @GetMapping("/admin/tabs/app-version-history") + public String adminVersionHistoryPage() { + return "admin/version-history"; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java index 7859ce12..5d291c4f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java @@ -3,9 +3,12 @@ import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest; import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionItemUpsertRequest; import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionSummaryUpdateRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionUpdateRequest; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryResponse; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionCreateResponse; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemCreateResponse; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; import com.devkor.ifive.nadab.domain.admin.application.AdminVersionCommandService; import com.devkor.ifive.nadab.domain.admin.application.AdminVersionItemCommandService; import com.devkor.ifive.nadab.domain.admin.application.AdminVersionQueryService; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Hidden @@ -39,6 +43,13 @@ public ResponseEntity> getLatestVers return ApiResponseEntity.ok(adminVersionQueryService.getLatestVersions()); } + @GetMapping("/history") + public ResponseEntity> getVersionHistory( + @RequestParam(required = false) AppPlatform platform + ) { + return ApiResponseEntity.ok(adminVersionQueryService.getVersionHistory(platform)); + } + @PostMapping public ResponseEntity> createVersion( @RequestBody @Valid AdminVersionCreateRequest request @@ -56,6 +67,15 @@ public ResponseEntity> updateVersionSummary( return ApiResponseEntity.noContent(); } + @PutMapping("/{appVersionId}/version") + public ResponseEntity> updateVersion( + @PathVariable Long appVersionId, + @RequestBody @Valid AdminVersionUpdateRequest request + ) { + adminVersionCommandService.updateVersion(appVersionId, request.version()); + return ApiResponseEntity.noContent(); + } + @PostMapping("/{appVersionId}/items") public ResponseEntity> createVersionItem( @PathVariable Long appVersionId, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java index dad51c94..79934d5e 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record AdminVersionCreateRequest( @@ -11,6 +12,7 @@ public record AdminVersionCreateRequest( @NotBlank(message = "버전은 필수입니다.") @Size(max = 30, message = "버전은 30자 이하여야 합니다.") + @Pattern(regexp = "\\d+\\.\\d+\\.\\d+", message = "Version must use major.minor.patch format.") String version, @NotNull(message = "요약은 null일 수 없습니다.") diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionUpdateRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionUpdateRequest.java new file mode 100644 index 00000000..4f17faa3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionUpdateRequest.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record AdminVersionUpdateRequest( + @NotBlank(message = "Version is required.") + @Size(max = 30, message = "Version must be 30 characters or less.") + @Pattern(regexp = "\\d+\\.\\d+\\.\\d+", message = "Version must use major.minor.patch format.") + String version +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionHistoryItemResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionHistoryItemResponse.java new file mode 100644 index 00000000..a1a885ef --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionHistoryItemResponse.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; + +import java.time.OffsetDateTime; +import java.util.List; + +public record AdminVersionHistoryItemResponse( + Long id, + AppPlatform platform, + String version, + Boolean isLatest, + String summary, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + List items +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionHistoryResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionHistoryResponse.java new file mode 100644 index 00000000..64d4bc76 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionHistoryResponse.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import java.util.List; + +public record AdminVersionHistoryResponse( + List versions +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java index 6f760a7e..21f490d1 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.ConflictException; import com.devkor.ifive.nadab.global.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -11,11 +12,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigInteger; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; + @Service @RequiredArgsConstructor @Transactional public class AdminVersionCommandService { + private static final Pattern SEMANTIC_VERSION_PATTERN = Pattern.compile("\\d+\\.\\d+\\.\\d+"); + private final AppVersionRepository appVersionRepository; public Long createVersion(AdminVersionCreateRequest request) { @@ -23,6 +31,12 @@ public Long createVersion(AdminVersionCreateRequest request) { throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS); } + SemanticVersion newVersion = SemanticVersion.parse(request.version()); + validateVersionGreaterThanOtherVersions( + newVersion, + appVersionRepository.findByPlatform(request.platform()) + ); + appVersionRepository.findByPlatformAndIsLatestTrue(request.platform()) .ifPresent(AppVersion::markAsNotLatest); appVersionRepository.flush(); @@ -45,4 +59,73 @@ public void updateSummary(Long appVersionId, String summary) { .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); appVersion.updateSummary(summary); } + + public void updateVersion(Long appVersionId, String version) { + AppVersion appVersion = appVersionRepository.findById(appVersionId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); + + if (appVersionRepository.existsByPlatformAndVersionAndIdNot( + appVersion.getPlatform(), version, appVersionId + )) { + throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS); + } + + SemanticVersion newVersion = SemanticVersion.parse(version); + validateVersionGreaterThanOtherVersions( + newVersion, + appVersionRepository.findByPlatformAndIdNot(appVersion.getPlatform(), appVersionId) + ); + + try { + appVersion.updateVersion(version); + appVersionRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS); + } + } + + private void validateVersionGreaterThanOtherVersions( + SemanticVersion newVersion, + List otherAppVersions + ) { + otherAppVersions.stream() + .map(versionEntity -> SemanticVersion.parse(versionEntity.getVersion())) + .max(Comparator.naturalOrder()) + .filter(maxVersion -> newVersion.compareTo(maxVersion) <= 0) + .ifPresent(maxVersion -> { + throw new ConflictException(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING); + }); + } + + private record SemanticVersion(BigInteger major, BigInteger minor, BigInteger patch) + implements Comparable { + + private static SemanticVersion parse(String version) { + if (!SEMANTIC_VERSION_PATTERN.matcher(version).matches()) { + throw new BadRequestException(ErrorCode.APP_VERSION_INVALID_FORMAT); + } + + String[] parts = version.split("\\."); + return new SemanticVersion( + new BigInteger(parts[0]), + new BigInteger(parts[1]), + new BigInteger(parts[2]) + ); + } + + @Override + public int compareTo(SemanticVersion other) { + int majorComparison = major.compareTo(other.major); + if (majorComparison != 0) { + return majorComparison; + } + + int minorComparison = minor.compareTo(other.minor); + if (minorComparison != 0) { + return minorComparison; + } + + return patch.compareTo(other.patch); + } + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java index 6b470c35..b4011ffb 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java @@ -1,8 +1,11 @@ package com.devkor.ifive.nadab.domain.admin.application; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryItemResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryResponse; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemResponse; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionResponse; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; @@ -61,4 +64,46 @@ public AdminLatestVersionsResponse getLatestVersions() { return new AdminLatestVersionsResponse(versions); } + + public AdminVersionHistoryResponse getVersionHistory(AppPlatform platform) { + List appVersions = platform == null + ? appVersionRepository.findAllByOrderByCreatedAtDescIdDesc() + : appVersionRepository.findByPlatformOrderByCreatedAtDescIdDesc(platform); + List appVersionIds = appVersions.stream() + .map(AppVersion::getId) + .toList(); + + List items = appVersionIds.isEmpty() + ? List.of() + : appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(appVersionIds); + + Map> itemsByVersionId = items.stream() + .collect(Collectors.groupingBy( + item -> item.getAppVersion().getId(), + Collectors.mapping( + item -> new AdminVersionItemResponse( + item.getId(), + item.getTitle(), + item.getDescription(), + item.getDisplayOrder() + ), + Collectors.toList() + ) + )); + + List versions = appVersions.stream() + .map(version -> new AdminVersionHistoryItemResponse( + version.getId(), + version.getPlatform(), + version.getVersion(), + version.getIsLatest(), + version.getSummary(), + version.getCreatedAt(), + version.getUpdatedAt(), + itemsByVersionId.getOrDefault(version.getId(), List.of()) + )) + .toList(); + + return new AdminVersionHistoryResponse(versions); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java index 49a3d47b..dc36f0d6 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java @@ -56,4 +56,8 @@ public void markAsNotLatest() { public void updateSummary(String summary) { this.summary = summary; } + + public void updateVersion(String version) { + this.version = version; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java index dd437ffd..ce937160 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java @@ -13,4 +13,14 @@ public interface AppVersionRepository extends JpaRepository { Optional findByPlatformAndIsLatestTrue(AppPlatform platform); boolean existsByPlatformAndVersion(AppPlatform platform, String version); + + boolean existsByPlatformAndVersionAndIdNot(AppPlatform platform, String version, Long id); + + List findByPlatform(AppPlatform platform); + + List findByPlatformAndIdNot(AppPlatform platform, Long id); + + List findAllByOrderByCreatedAtDescIdDesc(); + + List findByPlatformOrderByCreatedAtDescIdDesc(AppPlatform platform); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java index 78d133a2..13f5d561 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java @@ -28,7 +28,9 @@ import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -104,7 +106,17 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq AiDailyReportResultDto dto; try { - dto = dailyReportLlmClient.generate(question.getQuestionText(), answerEntry); + LlmGenerationResult generationResult = + dailyReportLlmClient.generate(question.getQuestionText(), answerEntry); + dto = generationResult.content(); + LlmTokenUsage tokenUsage = generationResult.tokenUsage(); + reportGenerationLogRecorder.recordTokenUsage( + generationLogId, + tokenUsage.inputTokens(), + tokenUsage.outputTokens(), + tokenUsage.totalTokens(), + tokenUsage.thinkingTokens() + ); reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { reportGenerationLogRecorder.fail(generationLogId, e); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java index 5f3d06d3..9b72f962 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java @@ -10,12 +10,16 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.content.Media; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; @@ -36,7 +40,7 @@ public class DailyReportLlmClient { private final LlmProvider provider = LlmProvider.OPENAI; - public AiDailyReportResultDto generate(String question, AnswerEntry answerEntry) { + public LlmGenerationResult generate(String question, AnswerEntry answerEntry) { String answer = answerEntry.getContent(); @@ -59,12 +63,15 @@ public AiDailyReportResultDto generate(String question, AnswerEntry answerEntry) UserMessage userMessage = buildUserMessage(prompt, withImagePrompt,answerEntry); String content; + LlmTokenUsage tokenUsage; try { - content = chatClient.prompt() + ChatResponse response = chatClient.prompt() .messages(userMessage) .options(options) .call() - .content(); + .chatResponse(); + content = extractContent(response); + tokenUsage = LlmTokenUsageExtractor.extract(response); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } @@ -85,9 +92,12 @@ public AiDailyReportResultDto generate(String question, AnswerEntry answerEntry) throw new AiResponseParseException(ErrorCode.AI_RESPONSE_FORMAT_INVALID); } - return new AiDailyReportResultDto( - message, - emotion + return new LlmGenerationResult<>( + new AiDailyReportResultDto( + message, + emotion + ), + tokenUsage ); } catch (Exception e) { @@ -96,6 +106,13 @@ public AiDailyReportResultDto generate(String question, AnswerEntry answerEntry) } } + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } + private UserMessage buildUserMessage(String prompt, String withImagePrompt, AnswerEntry answerEntry) { String imageKey = answerEntry.getImageKey(); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java index 3c485cb4..a9fecdb4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java @@ -166,6 +166,11 @@ public ResponseEntity> getMyMonthlyRepor description = "월간 리포트 조회 성공", content = @Content(schema = @Schema(implementation = MonthlyReportResponse.class), mediaType = "application/json") ), + @ApiResponse( + responseCode = "403", + description = "- ErrorCode: MONTHLY_REPORT_ACCESS_FORBIDDEN - 본인 월간 리포트만 조회 가능", + content = @Content + ), @ApiResponse( responseCode = "404", description = "- ErrorCode: MONTHLY_REPORT_NOT_FOUND - 월간 리포트를 찾을 수 없음", @@ -174,9 +179,10 @@ public ResponseEntity> getMyMonthlyRepor } ) public ResponseEntity> getMonthlyReportById( + @AuthenticationPrincipal UserPrincipal principal, @PathVariable Long id ) { - MonthlyReportResponse response = monthlyReportQueryService.getMonthlyReportById(id); + MonthlyReportResponse response = monthlyReportQueryService.getMonthlyReportById(principal.getId(), id); return ApiResponseEntity.ok(response); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java index f229dc6f..022d9ed7 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java @@ -137,13 +137,18 @@ public ResponseEntity>> getAllRepor @Operation( summary = "나의 월간 리포트 조회 V2", description = """ - 사용자의 (지난 달에 대한) 월간 리포트를 조회합니다.
+ 사용자의 지난달·지지난달 월간 리포트 위치 정보를 조회합니다.
- report: 존재하지 않으면 null, 존재하면 id와 version을 반환합니다.
+ report: 지난달 리포트이며 존재하지 않으면 null입니다.
+ previousReport: 지지난달 완료 리포트이며 존재하지 않으면 null입니다.
+ 두 슬롯은 서로 독립적으로 null일 수 있습니다.
+ 각 항목은 reportId, version, month, status를 반환합니다.
+ report.status: PENDING | IN_PROGRESS | TEXT_COMPLETED | COMPLETED | FAILED
+ previousReport.status: 존재하는 경우 항상 COMPLETED
version 규칙: - - monthly_reports - 1인 경우 : GET /api/v1/monthly-report/{id}로 조회 (기존의 레거시 버전) - - monthly_reports - 2인 경우 : GET /api/v2/monthly-report/{id}로 조회 (새로운 V2 버전) + - version 1인 경우 : GET /api/v1/monthly-report/{reportId}로 조회 (기존의 레거시 버전) + - version 2인 경우 : GET /api/v2/monthly-report/{reportId}로 조회 (새로운 V2 버전) """, security = @SecurityRequirement(name = "bearerAuth"), responses = { @@ -194,7 +199,7 @@ public ResponseEntity> getMyMont 다음은 각 페이지에서 활용되는 필드의 값에 대한 설명입니다.
comparisonType: 최초 생성인지 이전 리포트가 존재하는지 여부입니다.
- 현재는 모두 최초 생성이기 때문에 "BASELINE"으로 고정되어 있고, 이전 리포트가 존재하는 경우에는 "COMPARISON"으로 반환될 예정입니다.
+ 이전에 완료된 V2 월간 리포트가 없으면 "BASELINE", 있으면 "COMPARISON"으로 반환됩니다.
**<페이지 1>**
summary : 월간 기록 요약
@@ -203,15 +208,31 @@ public ResponseEntity> getMyMont **<페이지 2>**
dominantKeyword : 이번 달 요약 단어
+ emotionTrend : BASELINE은 "NOT_SUPPORTED", COMPARISON은 주요 감정의 변동 양상
emotionStats.emotions : 감정에 대한 통계가 빈도 기준 내림차순으로 정렬되어 있습니다.
+ emotionComparison : 직전 월간 리포트 감정 비교 스냅샷이며 BASELINE인 경우 null입니다.
+ - emotionComparison.previousReportId : 비교 대상인 직전 완료 리포트 ID
+ - emotionComparison.previousMonth : 비교 대상 월
+ - emotionComparison.previousEmotionStats : 직전 리포트의 감정 통계
+ - emotionComparison.positivePercentPointChange : 현재와 직전 리포트의 긍정 감정 비율 차이(%p)
+ emotionSummaryContent.styledText.segments : 감정 분석 텍스트
- - emotionTrend : "NOT_SUPPORTED" (현재는 고정. 변동 양상은 최초 생성 월간 리포트에서는 지원되지 않음. 이후 업데이트 예정)
**<페이지 3>**
commentSummary : 나답의 한 마디 요약
comment.segments : 나답의 한 마디 텍스트
interestStats.interests : 카테고리(관심사)에 대한 통계가 빈도 기준 내림차순으로 정렬되어 있습니다.
+ + **<페이지 4>**
+ socialSummary.visible : 월간 소셜 페이지 노출 여부
+ socialSummary.month : 집계 대상 월
+ socialSummary.likeRanking : 내 DailyReport에 좋아요를 많이 누른 친구 최대 3명
+ socialSummary.commentRanking : 내 DailyReport에 댓글·대댓글을 많이 작성한 친구 최대 3명
+ - displayOrder : 화면 표시 순서(1~3)
+ - userId : 친구 사용자 ID
+ - nickname : 친구 닉네임
+ - profileImageUrl : 친구 프로필 이미지 URL
+ - topRank : 공동 1위를 포함한 1위 강조 여부
""", security = @SecurityRequirement(name = "bearerAuth"), responses = { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportLocatorResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportLocatorResponse.java new file mode 100644 index 00000000..fcef089a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportLocatorResponse.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "버전별 월간 리포트 상세 조회를 위한 위치 정보") +public record MonthlyReportLocatorResponse( + @Schema(description = "월간 리포트 ID") + Long reportId, + + @Schema(description = "월간 리포트 버전", allowableValues = {"1", "2"}, example = "2") + int version, + + @Schema(description = "월간 리포트 대상 월", example = "5") + int month, + + @Schema(description = "월간 리포트 상태", example = "COMPLETED") + MonthlyReportStatus status +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponseV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponseV2.java index 934a97d4..80a0c1b4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponseV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponseV2.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyEmotionComparisonContent; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; @@ -32,15 +33,18 @@ public record MonthlyReportResponseV2( @Schema(description = "핵심 키워드") String dominantKeyword, + @Schema(description = "감정 흐름 요약") + String emotionTrend, + @Schema(description = "감정 통계") TypeEmotionStatsContent emotionStats, + @Schema(description = "직전 월간 리포트 감정 비교 스냅샷. BASELINE인 경우 null", nullable = true) + MonthlyEmotionComparisonContent emotionComparison, + @Schema(description = "감정 요약(styled)") TypeTextContent emotionSummaryContent, - @Schema(description = "감정 흐름 요약") - String emotionTrend, - @Schema(description = "코멘트(styled)") StyledText comment, @@ -48,6 +52,9 @@ public record MonthlyReportResponseV2( String commentSummary, @Schema(description = "관심사 통계") - InterestStatsContent interestStats + InterestStatsContent interestStats, + + @Schema(description = "월간 소셜 반응 요약") + MonthlySocialSummaryResponse socialSummary ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlySocialRankingItemResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlySocialRankingItemResponse.java new file mode 100644 index 00000000..e322a602 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlySocialRankingItemResponse.java @@ -0,0 +1,21 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MonthlySocialRankingItemResponse( + @Schema(description = "화면 표시 순서", example = "1") + int displayOrder, + + @Schema(description = "친구 사용자 ID", example = "12") + Long userId, + + @Schema(description = "친구 닉네임", example = "가나다") + String nickname, + + @Schema(description = "친구 프로필 이미지 URL", nullable = true) + String profileImageUrl, + + @Schema(description = "공동 1위를 포함한 1위 강조 여부", example = "true") + boolean topRank +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlySocialSummaryResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlySocialSummaryResponse.java new file mode 100644 index 00000000..bc386ade --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlySocialSummaryResponse.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record MonthlySocialSummaryResponse( + @Schema(description = "월간 소셜 페이지 노출 여부", example = "true") + boolean visible, + + @Schema(description = "집계 대상 월", example = "5") + int month, + + @Schema(description = "내 DailyReport에 좋아요를 많이 누른 친구 랭킹") + List likeRanking, + + @Schema(description = "내 DailyReport에 댓글·대댓글을 많이 작성한 친구 랭킹") + List commentRanking +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupItemV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupItemV2.java deleted file mode 100644 index f5df4979..00000000 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupItemV2.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; - -import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "나의 월간 리포트 라우팅 정보") -public record MyMonthlyReportLookupItemV2( - @Schema(description = "리포트 ID") - Long id, - @Schema(description = "리포트 버전", example = "2") - int version, - @Schema(description = "리포트 대상 월") - int month, - @Schema(description = "리포트 상태", example = "COMPLETED") - MonthlyReportStatus status -) { -} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2.java index 00328b06..d0e67d49 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2.java @@ -2,9 +2,12 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "나의 월간 리포트 단건 조회 응답") +@Schema(description = "나의 현재·이전 월간 리포트 위치 조회 응답") public record MyMonthlyReportLookupResponseV2( - @Schema(description = "월간 리포트 라우팅 정보. 리포트가 없으면 null", nullable = true) - MyMonthlyReportLookupItemV2 report + @Schema(description = "지난달 월간 리포트 위치 정보. 리포트가 없으면 null", nullable = true) + MonthlyReportLocatorResponse report, + + @Schema(description = "지지난달 완료 월간 리포트 위치 정보. 리포트가 없으면 null", nullable = true) + MonthlyReportLocatorResponse previousReport ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentService.java new file mode 100644 index 00000000..8a7d4ad9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentService.java @@ -0,0 +1,88 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyImageColorPaletteSelector; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyImagePresetSelector; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImageVisualPreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class MonthlyImagePresetAssignmentService { + + private static final int RECENT_PRESET_LIMIT = 3; + + private final MonthlyReportV2Repository monthlyReportV2Repository; + + public MonthlyImageVisualPreset getOrAssignVisualPreset(Long userId, Long reportId) { + MonthlyReportV2 report = monthlyReportV2Repository.findById(reportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + if (report.getUser() == null || report.getUser().getId() == null + || !report.getUser().getId().equals(userId)) { + throw new ForbiddenException(ErrorCode.MONTHLY_REPORT_ACCESS_FORBIDDEN); + } + MonthlyImageStylePreset stylePreset = getOrAssignStylePreset(userId, reportId, report); + MonthlyImageColorPalette colorPalette = getOrAssignColorPalette(userId, reportId, report); + return new MonthlyImageVisualPreset(stylePreset, colorPalette); + } + + private MonthlyImageStylePreset getOrAssignStylePreset( + Long userId, + Long reportId, + MonthlyReportV2 report + ) { + if (report.getImagePromptVariant() == null) { + List recentPresets = + monthlyReportV2Repository.findRecentCompletedImagePromptVariants( + userId, + reportId, + PageRequest.of(0, RECENT_PRESET_LIMIT) + ); + MonthlyImageStylePreset selected = MonthlyImagePresetSelector.select( + userId, + reportId, + report.getMonthStartDate(), + recentPresets + ); + report.assignImagePromptVariant(selected); + return selected; + } + return report.getImagePromptVariant(); + } + + private MonthlyImageColorPalette getOrAssignColorPalette( + Long userId, + Long reportId, + MonthlyReportV2 report + ) { + if (report.getImageColorPalette() == null) { + List recentPalettes = + monthlyReportV2Repository.findRecentCompletedImageColorPalettes( + userId, + reportId, + PageRequest.of(0, RECENT_PRESET_LIMIT) + ); + MonthlyImageColorPalette selected = MonthlyImageColorPaletteSelector.select( + userId, + reportId, + report.getMonthStartDate(), + recentPalettes + ); + report.assignImageColorPalette(selected); + return selected; + } + return report.getImageColorPalette(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolver.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolver.java new file mode 100644 index 00000000..a76ae453 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolver.java @@ -0,0 +1,61 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportLocatorResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class MonthlyReportLocatorResolver { + + private final MonthlyReportRepository monthlyReportRepository; + private final MonthlyReportV2Repository monthlyReportV2Repository; + + public Optional findByMonth(Long userId, LocalDate monthStartDate) { + return monthlyReportV2Repository.findByUserIdAndMonthStartDate(userId, monthStartDate) + .map(this::toLocatorResponse) + .or(() -> monthlyReportRepository.findByUserIdAndMonthStartDate(userId, monthStartDate) + .map(this::toLocatorResponse)); + } + + public Optional findCompletedByMonth(Long userId, LocalDate monthStartDate) { + return monthlyReportV2Repository.findByUserIdAndMonthStartDateAndStatus( + userId, + monthStartDate, + MonthlyReportStatus.COMPLETED + ) + .map(this::toLocatorResponse) + .or(() -> monthlyReportRepository.findByUserIdAndMonthStartDateAndStatus( + userId, + monthStartDate, + MonthlyReportStatus.COMPLETED + ) + .map(this::toLocatorResponse)); + } + + private MonthlyReportLocatorResponse toLocatorResponse(MonthlyReportV2 report) { + return new MonthlyReportLocatorResponse( + report.getId(), + 2, + report.getMonthStartDate().getMonthValue(), + report.getStatus() == null ? MonthlyReportStatus.PENDING : report.getStatus() + ); + } + + private MonthlyReportLocatorResponse toLocatorResponse(MonthlyReport report) { + return new MonthlyReportLocatorResponse( + report.getId(), + 1, + report.getMonthStartDate().getMonthValue(), + report.getStatus() == null ? MonthlyReportStatus.PENDING : report.getStatus() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java index 27b3edfc..8042c6ec 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java @@ -9,6 +9,7 @@ import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; @@ -53,9 +54,13 @@ public MyMonthlyReportResponse getMyMonthlyReport(Long userId) { ); } - public MonthlyReportResponse getMonthlyReportById(Long id) { + public MonthlyReportResponse getMonthlyReportById(Long userId, Long id) { MonthlyReport report = monthlyReportRepository.findById(id) .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + if (report.getUser() == null || report.getUser().getId() == null + || !report.getUser().getId().equals(userId)) { + throw new ForbiddenException(ErrorCode.MONTHLY_REPORT_ACCESS_FORBIDDEN); + } MonthRangeDto range = MonthRangeCalculator.monthRangeOf(report.getMonthStartDate()); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2.java index c2037c8a..054bb046 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2.java @@ -1,11 +1,15 @@ package com.devkor.ifive.nadab.domain.monthlyreport.application; import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.AllReportItemResponseV2; -import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MyMonthlyReportLookupItemV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportLocatorResponse; import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MyMonthlyReportLookupResponseV2; import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlySocialRankingItemResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlySocialSummaryResponse; import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.ReportListTypeV2; import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; @@ -43,6 +47,7 @@ public class MonthlyReportQueryServiceV2 { private final WeeklyReportRepository weeklyReportRepository; private final UserRepository userRepository; private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final MonthlyReportLocatorResolver monthlyReportLocatorResolver; public List getAllReports(Long userId, ReportListTypeV2 type) { if (!userRepository.existsById(userId)) { @@ -120,11 +125,21 @@ public MyMonthlyReportLookupResponseV2 getMyMonthlyReport(Long userId) { } MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); - return monthlyReportV2Repository.findByUserIdAndMonthStartDate(userId, range.monthStartDate()) - .map(this::toLookupResponse) - .or(() -> monthlyReportRepository.findByUserIdAndMonthStartDate(userId, range.monthStartDate()) - .map(this::toLookupResponse)) - .orElseGet(() -> new MyMonthlyReportLookupResponseV2(null)); + return getMyMonthlyReport(userId, range); + } + + MyMonthlyReportLookupResponseV2 getMyMonthlyReport(Long userId, MonthRangeDto range) { + MonthlyReportLocatorResponse report = monthlyReportLocatorResolver + .findByMonth(userId, range.monthStartDate()) + .orElse(null); + MonthRangeDto previousRange = MonthRangeCalculator.monthRangeOf( + range.monthStartDate().minusMonths(1) + ); + MonthlyReportLocatorResponse previousReport = monthlyReportLocatorResolver + .findCompletedByMonth(userId, previousRange.monthStartDate()) + .orElse(null); + + return new MyMonthlyReportLookupResponseV2(report, previousReport); } public MonthlyReportResponseV2 getMonthlyReportById(Long userId, Long id) { @@ -151,34 +166,41 @@ private MonthlyReportResponseV2 toResponse(MonthlyReportV2 report) { imageUrl, content.discovered(), content.dominantKeyword(), + content.emotionTrend(), report.getEmotionStats() == null ? TypeContentFactory.emptyEmotionStats() : report.getEmotionStats().normalized(), + report.getEmotionComparison() == null ? null : report.getEmotionComparison().normalized(), report.getEmotionSummaryContent() == null ? TypeContentFactory.emptyText() : report.getEmotionSummaryContent().normalized(), - content.emotionTrend(), content.comment(), content.commentSummary(), - report.getInterestStats() == null ? MonthlyContentFactory.emptyInterestStats() : report.getInterestStats().normalized() + report.getInterestStats() == null ? MonthlyContentFactory.emptyInterestStats() : report.getInterestStats().normalized(), + toSocialSummaryResponse(report) ); } - private MyMonthlyReportLookupResponseV2 toLookupResponse(MonthlyReportV2 report) { - return new MyMonthlyReportLookupResponseV2( - new MyMonthlyReportLookupItemV2( - report.getId(), - 2, - report.getMonthStartDate().getMonthValue(), - report.getStatus() == null ? MonthlyReportStatus.PENDING : report.getStatus() - ) + private MonthlySocialSummaryResponse toSocialSummaryResponse(MonthlyReportV2 report) { + MonthlySocialSummaryContent socialSummary = report.getSocialSummary() == null + ? MonthlySocialSummaryContent.empty(report.getMonthStartDate().getMonthValue()) + : report.getSocialSummary().normalized(); + + return new MonthlySocialSummaryResponse( + socialSummary.visible(), + socialSummary.month(), + socialSummary.likeRanking().stream().map(this::toSocialRankingItemResponse).toList(), + socialSummary.commentRanking().stream().map(this::toSocialRankingItemResponse).toList() ); } - private MyMonthlyReportLookupResponseV2 toLookupResponse(MonthlyReport report) { - return new MyMonthlyReportLookupResponseV2( - new MyMonthlyReportLookupItemV2( - report.getId(), - 1, - report.getMonthStartDate().getMonthValue(), - report.getStatus() == null ? MonthlyReportStatus.PENDING : report.getStatus() - ) + private MonthlySocialRankingItemResponse toSocialRankingItemResponse(MonthlySocialRankingItem item) { + String profileImageUrl = item.profileImageKey() != null + ? profileImageUrlBuilder.buildUrl(item.profileImageKey()) + : profileImageUrlBuilder.buildDefaultUrl(item.defaultProfileType()); + + return new MonthlySocialRankingItemResponse( + item.displayOrder(), + item.userId(), + item.nickname(), + profileImageUrl, + item.topRank() ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java index be32ae0c..8d30fcaa 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java @@ -2,6 +2,8 @@ import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyEmotionComparisonContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.*; @@ -19,6 +21,8 @@ import com.devkor.ifive.nadab.global.exception.NotEnoughCrystalException; import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; @@ -79,9 +83,17 @@ public MonthlyReserveResultDto reserveMonthly(User user, boolean exists) { public MonthlyReserveResultDto reserveMonthlyAndPublish(User user) { - boolean exists = monthlyReportV2Repository.existsByUserIdAndStatus(user.getId(), MonthlyReportStatus.COMPLETED); + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + Long previousReportId = monthlyReportV2Repository + .findFirstByUserIdAndStatusAndMonthStartDateBeforeOrderByMonthStartDateDesc( + user.getId(), + MonthlyReportStatus.COMPLETED, + range.monthStartDate() + ) + .map(MonthlyReportV2::getId) + .orElse(null); - MonthlyReserveResultDto reserve = this.reserveMonthly(user, exists); + MonthlyReserveResultDto reserve = this.reserveMonthly(user, previousReportId != null); monthlyReportV2Repository.updateStatus(reserve.reportId(), MonthlyReportStatus.IN_PROGRESS); @@ -90,7 +102,7 @@ public MonthlyReserveResultDto reserveMonthlyAndPublish(User user) { reserve.reportId(), user.getId(), reserve.crystalLogId(), - exists + previousReportId )); return reserve; @@ -113,11 +125,14 @@ public void confirmMonthlyText( MonthlyReportV2Content content, TypeTextContent emotionSummaryContent, TypeEmotionStatsContent emotionStats, - InterestStatsContent interestStats + InterestStatsContent interestStats, + MonthlyEmotionComparisonContent emotionComparison, + MonthlySocialSummaryContent socialSummary ) { MonthlyReportV2Content contentNormalized = content.normalized(); TypeTextContent emotionSummaryContentNormalized = emotionSummaryContent.normalized(); InterestStatsContent interestStatsNormalized = interestStats.normalized(); + MonthlySocialSummaryContent socialSummaryNormalized = socialSummary.normalized(); String summary = contentNormalized.summary(); String commentSummary = contentNormalized.commentSummary(); @@ -127,12 +142,18 @@ public void confirmMonthlyText( String emotionSummaryContentJson; String emotionStatsJson; String interestStatsJson; + String emotionComparisonJson; + String socialSummaryJson; try { contentJson = objectMapper.writeValueAsString(contentNormalized); emotionSummaryContentJson = objectMapper.writeValueAsString(emotionSummaryContentNormalized); emotionStatsJson = objectMapper.writeValueAsString(emotionStats.normalized()); interestStatsJson = objectMapper.writeValueAsString(interestStatsNormalized); + emotionComparisonJson = emotionComparison == null + ? null + : objectMapper.writeValueAsString(emotionComparison.normalized()); + socialSummaryJson = objectMapper.writeValueAsString(socialSummaryNormalized); } catch (Exception e) { throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); } @@ -145,6 +166,8 @@ public void confirmMonthlyText( dominantKeyword, emotionStatsJson, interestStatsJson, + emotionComparisonJson, + socialSummaryJson, MonthlyReportStatus.TEXT_COMPLETED.name() ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelector.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelector.java new file mode 100644 index 00000000..32a59d6e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelector.java @@ -0,0 +1,41 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public final class MonthlyImageColorPaletteSelector { + + private static final String SELECTION_SALT = "monthly-image-color-palette-v1"; + + private MonthlyImageColorPaletteSelector() { + } + + public static MonthlyImageColorPalette select( + Long userId, + Long reportId, + LocalDate monthStartDate, + List recentPalettes + ) { + Set excluded = recentPalettes == null + ? Set.of() + : recentPalettes.stream() + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); + + List candidates = Arrays.stream(MonthlyImageColorPalette.values()) + .filter(palette -> !excluded.contains(palette)) + .toList(); + if (candidates.isEmpty()) { + candidates = List.of(MonthlyImageColorPalette.values()); + } + + int seed = Objects.hash(SELECTION_SALT, userId, reportId, monthStartDate); + return candidates.get(Math.floorMod(seed, candidates.size())); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelector.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelector.java new file mode 100644 index 00000000..8a28e7d6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelector.java @@ -0,0 +1,39 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public final class MonthlyImagePresetSelector { + + private MonthlyImagePresetSelector() { + } + + public static MonthlyImageStylePreset select( + Long userId, + Long reportId, + LocalDate monthStartDate, + List recentPresets + ) { + Set excluded = recentPresets == null + ? Set.of() + : recentPresets.stream() + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableSet()); + + List candidates = Arrays.stream(MonthlyImageStylePreset.values()) + .filter(preset -> !excluded.contains(preset)) + .toList(); + if (candidates.isEmpty()) { + candidates = List.of(MonthlyImageStylePreset.values()); + } + + int seed = Objects.hash(userId, reportId, monthStartDate); + return candidates.get(Math.floorMod(seed, candidates.size())); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposer.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposer.java new file mode 100644 index 00000000..6bf01c02 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposer.java @@ -0,0 +1,140 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +@Component +public class MonthlyImagePromptComposer { + + private static final Map STYLE_DIRECTIONS = styleDirections(); + private static final Map COLOR_DIRECTIONS = colorDirections(); + + public String compose(MonthlyImagePromptContext context) { + String styleDirection = STYLE_DIRECTIONS.get(context.stylePreset()); + if (styleDirection == null) { + throw new IllegalArgumentException("Unsupported monthly image style preset: " + context.stylePreset()); + } + String colorDirection = COLOR_DIRECTIONS.get(context.colorPalette()); + if (colorDirection == null) { + throw new IllegalArgumentException("Unsupported monthly image color palette: " + context.colorPalette()); + } + + return """ + Create a premium abstract monthly self-reflection cover image for a Korean journaling app. + + Report context: + - Monthly summary: %s + - Gentle comment summary: %s + - Dominant keyword: %s + - Month: %s to %s + + Visual direction: + %s + + Color direction: + %s + + The specified color direction is mandatory. Use it to control the background, dominant forms, lighting, and accents. Preserve clear hue separation and recognizable palette identity. Do not default to lavender, pink, beige, peach, or a generic pastel gradient unless those colors are explicitly included in the selected color direction. + + Interpret the report context symbolically through the selected visual direction. Vary the objects, color relationships, lighting, spatial rhythm, and composition according to the emotional meaning. Do not fall back to a generic fixed scene. + + The image should feel calm, reflective, comforting, refined, and suitable for a premium mobile app monthly report card. Keep a clear focal point and enough negative space. + + Strict constraints: + No people, no faces, no hands, no body parts. + No readable text, no letters, no Korean characters, no English characters, no numbers, no symbols, no handwriting, no typography. + No UI, no logos, no brand marks, no watermarks. + No medical, therapy, counseling, hospital, or diagnosis-related imagery. + No dark, horror, gloomy, childish, or overly dramatic mood. + """.formatted( + context.summary(), + context.commentSummary(), + context.dominantKeyword(), + context.monthStartDate(), + context.monthEndDate(), + styleDirection, + colorDirection + ); + } + + private static Map styleDirections() { + EnumMap directions = + new EnumMap<>(MonthlyImageStylePreset.class); + directions.put(MonthlyImageStylePreset.BOTANICAL_COLLAGE, """ + Botanical editorial collage using abstract leaves, petals, seeds, and organic silhouettes. + Layer tactile paper textures in the selected color direction with an asymmetrical, airy composition. + """); + directions.put(MonthlyImageStylePreset.PAPER_CUT_LAYERS, """ + Dimensional paper-cut illustration built from softly layered shapes and carefully controlled shadows. + Use depth, openings, and overlapping planes to express the emotional progression without depicting a literal scene. + """); + directions.put(MonthlyImageStylePreset.GLASS_AND_LIGHT, """ + Abstract translucent glass forms with gentle refraction, luminous gradients, and soft reflected light. + Keep the materials elegant and weightless, with subtle depth and no sharp or hazardous appearance. + """); + directions.put(MonthlyImageStylePreset.MINIMAL_GEOMETRY, """ + Minimal geometric editorial composition using circles, arcs, planes, and balanced spatial relationships. + Use the selected color direction with precise rhythm and soft dimensional lighting rather than flat iconography. + """); + directions.put(MonthlyImageStylePreset.INK_WASH, """ + Contemporary abstract ink-wash artwork with diluted pigment, quiet brush diffusion, and generous open space. + Keep the mood light and comforting, varying pigment intensity within the selected colors without calligraphy or written marks. + """); + directions.put(MonthlyImageStylePreset.DREAMLIKE_LANDSCAPE, """ + Dreamlike abstract landscape made of gentle horizons, atmospheric color fields, mist, and ambient light. + Suggest an inner emotional journey without recognizable locations, people, buildings, or dramatic weather. + """); + directions.put(MonthlyImageStylePreset.TEXTILE_PATTERN, """ + Artisanal textile-inspired abstraction using woven rhythm, soft fibers, stitched-like paths, and layered fabric forms. + Keep it sophisticated and editorial, avoiding literal lettering, symbols, decorative text, or busy repetition. + """); + directions.put(MonthlyImageStylePreset.CERAMIC_OBJECTS, """ + Sculptural ceramic still life using abstract vessels, rounded forms, matte glaze, and controlled studio light. + Arrange the objects as an emotional metaphor with an uncluttered composition and no human or household narrative. + """); + return Map.copyOf(directions); + } + + private static Map colorDirections() { + EnumMap directions = + new EnumMap<>(MonthlyImageColorPalette.class); + directions.put(MonthlyImageColorPalette.FOREST_MIST, """ + Forest mist palette: sage green and deep teal as dominant hues, moss green accents, and foggy cream highlights. + Keep the temperature cool and botanical, avoiding pink or purple casts. + """); + directions.put(MonthlyImageColorPalette.OCEAN_LIGHT, """ + Ocean light palette: cobalt blue and clear cyan as dominant hues, turquoise transitions, and crisp silver-white highlights. + Keep the result fresh and luminous, avoiding beige or dusty pink casts. + """); + directions.put(MonthlyImageColorPalette.SUNSET_CLAY, """ + Sunset clay palette: terracotta and burnt orange as dominant hues, apricot midtones, and restrained burgundy accents. + Keep the colors earthy and sunlit rather than pink, lavender, or candy-like. + """); + directions.put(MonthlyImageColorPalette.MOON_VIOLET, """ + Moon violet palette: indigo and violet as dominant hues, periwinkle transitions, and cool pearl highlights. + Keep sufficient luminosity for a comforting mood while preserving a distinctly nocturnal violet identity. + """); + directions.put(MonthlyImageColorPalette.CITRUS_BREEZE, """ + Citrus breeze palette: lemon yellow and fresh mint as dominant hues, lime-tinted transitions, and coral accents. + Keep the colors clear and energetic with a light neutral background, avoiding lavender or muted beige dominance. + """); + directions.put(MonthlyImageColorPalette.EARTH_NEUTRAL, """ + Earth neutral palette: ivory and stone gray as the foundation, taupe and soft charcoal as dominant forms, and subtle bronze accents. + Keep saturation intentionally low and avoid pink, purple, or blue color casts. + """); + directions.put(MonthlyImageColorPalette.JEWEL_GLOW, """ + Jewel glow palette: emerald and sapphire as dominant hues, amethyst accents, and bright crystal-like highlights. + Use rich but balanced saturation against a clean light ground, avoiding a generic pastel treatment. + """); + directions.put(MonthlyImageColorPalette.ROSE_DAWN, """ + Rose dawn palette: rose and peach as dominant hues, soft coral transitions, and pale sky-blue highlights. + Keep the combination airy and sunrise-like while maintaining clear contrast between warm and cool colors. + """); + return Map.copyOf(directions); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssembler.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssembler.java new file mode 100644 index 00000000..dfc9ac87 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssembler.java @@ -0,0 +1,105 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +public final class MonthlyReportComparisonInputAssembler { + + private MonthlyReportComparisonInputAssembler() { + } + + public static MonthlyReportComparisonInputDto assemble( + MonthlyReportV2 previousReport, + TypeEmotionStatsContent currentEmotionStats + ) { + MonthlyReportV2Content previousContent = previousReport.getContent() == null + ? MonthlyReportV2ContentFactory.empty() + : previousReport.getContent().normalized(); + TypeTextContent previousEmotionSummaryContent = previousReport.getEmotionSummaryContent() == null + ? TypeContentFactory.emptyText() + : previousReport.getEmotionSummaryContent().normalized(); + TypeEmotionStatsContent previousEmotionStats = previousReport.getEmotionStats() == null + ? TypeContentFactory.emptyEmotionStats() + : previousReport.getEmotionStats().normalized(); + TypeEmotionStatsContent normalizedCurrentEmotionStats = currentEmotionStats == null + ? TypeContentFactory.emptyEmotionStats() + : currentEmotionStats.normalized(); + + int currentPositivePercent = normalizedCurrentEmotionStats.positivePercent(); + int previousPositivePercent = previousEmotionStats.positivePercent(); + + return new MonthlyReportComparisonInputDto( + previousReport.getId(), + previousReport.getMonthStartDate(), + previousReport.getMonthEndDate(), + previousContent, + previousEmotionSummaryContent, + previousEmotionStats, + currentPositivePercent, + previousPositivePercent, + currentPositivePercent - previousPositivePercent, + assembleEmotionChanges(normalizedCurrentEmotionStats, previousEmotionStats) + ); + } + + private static List assembleEmotionChanges( + TypeEmotionStatsContent currentEmotionStats, + TypeEmotionStatsContent previousEmotionStats + ) { + Map currentByCode = byEmotionCode(currentEmotionStats); + Map previousByCode = byEmotionCode(previousEmotionStats); + + return Arrays.stream(EmotionCode.values()) + .map(code -> toEmotionChange(code, currentByCode.get(code.name()), previousByCode.get(code.name()))) + .filter(change -> change.changePercentPoint() != 0) + .sorted((left, right) -> Integer.compare( + Math.abs(right.changePercentPoint()), + Math.abs(left.changePercentPoint()) + )) + .limit(3) + .toList(); + } + + private static Map byEmotionCode( + TypeEmotionStatsContent emotionStats + ) { + return emotionStats.emotions().stream() + .filter(stat -> stat.emotionCode() != null) + .collect(Collectors.toMap( + TypeEmotionStatsContent.EmotionStat::emotionCode, + Function.identity(), + (first, ignored) -> first + )); + } + + private static MonthlyReportComparisonInputDto.EmotionChange toEmotionChange( + EmotionCode code, + TypeEmotionStatsContent.EmotionStat current, + TypeEmotionStatsContent.EmotionStat previous + ) { + int currentPercent = current == null ? 0 : current.percent(); + int previousPercent = previous == null ? 0 : previous.percent(); + String emotionName = current != null ? current.emotionName() + : previous == null ? null : previous.emotionName(); + + return new MonthlyReportComparisonInputDto.EmotionChange( + code.name(), + emotionName, + currentPercent, + previousPercent, + currentPercent - previousPercent + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculator.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculator.java new file mode 100644 index 00000000..5a52a7d7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculator.java @@ -0,0 +1,104 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto; + +import java.text.Collator; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +public final class MonthlySocialSummaryCalculator { + + private static final long MIN_INTERACTION_COUNT = 10; + private static final int MAX_RANKING_SIZE = 3; + + private MonthlySocialSummaryCalculator() { + } + + public static MonthlySocialSummaryContent calculate( + int month, + List likeCounts, + List commentCounts + ) { + List normalizedLikes = normalize(likeCounts); + List normalizedComments = normalize(commentCounts); + + boolean visible = !normalizedLikes.isEmpty() + && !normalizedComments.isEmpty() + && totalCount(normalizedLikes) + totalCount(normalizedComments) >= MIN_INTERACTION_COUNT; + if (!visible) { + return MonthlySocialSummaryContent.empty(month); + } + + return new MonthlySocialSummaryContent( + true, + month, + toRanking(normalizedLikes), + toRanking(normalizedComments) + ).normalized(); + } + + private static List normalize( + List counts + ) { + if (counts == null || counts.isEmpty()) { + return List.of(); + } + + return counts.stream() + .filter(item -> item != null && item.userId() != null && item.interactionCount() > 0) + .toList(); + } + + private static long totalCount(List counts) { + return counts.stream() + .mapToLong(MonthlySocialInteractionCountDto::interactionCount) + .sum(); + } + + private static List toRanking( + List counts + ) { + Collator koreanCollator = Collator.getInstance(Locale.KOREAN); + Comparator comparator = Comparator + .comparingLong(MonthlySocialInteractionCountDto::interactionCount) + .reversed() + .thenComparing( + item -> normalizeNickname(item.nickname()), + koreanCollator + ) + .thenComparing(item -> normalizeNickname(item.nickname())) + .thenComparing(MonthlySocialInteractionCountDto::userId); + + List sorted = counts.stream() + .sorted(comparator) + .limit(MAX_RANKING_SIZE) + .toList(); + long topCount = sorted.getFirst().interactionCount(); + + return java.util.stream.IntStream.range(0, sorted.size()) + .mapToObj(index -> toRankingItem(sorted.get(index), index + 1, topCount)) + .toList(); + } + + private static MonthlySocialRankingItem toRankingItem( + MonthlySocialInteractionCountDto item, + int displayOrder, + long topCount + ) { + return new MonthlySocialRankingItem( + displayOrder, + item.userId(), + normalizeNickname(item.nickname()), + item.profileImageKey(), + item.defaultProfileType(), + item.interactionCount() == topCount + ); + } + + private static String normalizeNickname(String nickname) { + return nickname == null ? "" : nickname.trim(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java index 1871b5f8..9618f381 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java @@ -12,7 +12,9 @@ import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto; import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; @@ -66,12 +68,21 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { ); try { // 트랜잭션 밖(백그라운드)에서 LLM 호출 - dto = monthlyReportLlmClient.generate( + LlmGenerationResult generationResult = monthlyReportLlmClient.generate( range.monthStartDate().toString(), range.monthEndDate().toString(), weeklySummaries, representativeEntries ); + dto = generationResult.content(); + LlmTokenUsage tokenUsage = generationResult.tokenUsage(); + reportGenerationLogRecorder.recordTokenUsage( + generationLogId, + tokenUsage.inputTokens(), + tokenUsage.outputTokens(), + tokenUsage.totalTokens(), + tokenUsage.thinkingTokens() + ); reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { reportGenerationLogRecorder.fail(generationLogId, e); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java index df0f33ad..ca1d92ea 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java @@ -1,15 +1,24 @@ package com.devkor.ifive.nadab.domain.monthlyreport.application.listener; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyImagePresetAssignmentService; import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxServiceV2; import com.devkor.ifive.nadab.domain.monthlyreport.application.event.MonthlyReportCompletedEvent; import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyInterestStatsCalculator; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyReportComparisonInputAssembler; import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyRepresentativePicker; import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyEmotionComparisonContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImageVisualPreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyQueryRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlyWeeklySummariesService; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlySocialSummaryService; import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportImageStorage; import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClientV2; import com.devkor.ifive.nadab.domain.monthlyreport.infra.OpenAiImageClient; @@ -20,7 +29,9 @@ import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import lombok.RequiredArgsConstructor; @@ -41,13 +52,16 @@ public class MonthlyReportGenerationListenerV2 { private static final String MONTHLY_REPORT_V2_LLM_MODEL = "GEMINI_2_5_FLASH"; private final MonthlyQueryRepository monthlyQueryRepository; + private final MonthlyReportV2Repository monthlyReportV2Repository; private final MonthlyReportLlmClientV2 monthlyReportLlmClientV2; private final OpenAiImageClient openAiImageClient; private final MonthlyReportImageStorage monthlyReportImageStorage; private final MonthlyReportTxServiceV2 monthlyReportTxServiceV2; + private final MonthlyImagePresetAssignmentService monthlyImagePresetAssignmentService; private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; + private final MonthlySocialSummaryService monthlySocialSummaryService; private final ReportGenerationLogRecorder reportGenerationLogRecorder; private final ApplicationEventPublisher eventPublisher; @@ -108,6 +122,43 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { return; } + MonthlySocialSummaryContent socialSummary; + try { + socialSummary = monthlySocialSummaryService.buildSocialSummary(event.userId(), range); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][SOCIAL_SUMMARY_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + MonthlyReportComparisonInputDto comparisonInput = null; + if (event.previousReportId() != null) { + try { + comparisonInput = monthlyReportV2Repository.findById(event.previousReportId()) + .filter(previousReport -> previousReport.getUser() != null + && event.userId().equals(previousReport.getUser().getId())) + .map(previousReport -> MonthlyReportComparisonInputAssembler.assemble( + previousReport, + emotionStats + )) + .orElseThrow(); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][COMPARISON_INPUT_FAILED] userId={}, reportId={}, previousReportId={}", + event.userId(), event.reportId(), event.previousReportId(), e); + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + } + AiMonthlyReportResultDto dto; Long generationLogId = reportGenerationLogRecorder.start( event.userId(), @@ -119,13 +170,22 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { ); try { // 트랜잭션 밖(백그라운드)에서 LLM 호출 - dto = monthlyReportLlmClientV2.generate( + LlmGenerationResult generationResult = monthlyReportLlmClientV2.generate( range.monthStartDate().toString(), range.monthEndDate().toString(), weeklySummaries, representativeEntries, emotionStats, - event.exists() + comparisonInput + ); + dto = generationResult.content(); + LlmTokenUsage tokenUsage = generationResult.tokenUsage(); + reportGenerationLogRecorder.recordTokenUsage( + generationLogId, + tokenUsage.inputTokens(), + tokenUsage.outputTokens(), + tokenUsage.totalTokens(), + tokenUsage.thinkingTokens() ); reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { @@ -156,7 +216,9 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { dto.content(), dto.emotionSummaryContent(), emotionStats, - interestStats + interestStats, + MonthlyEmotionComparisonContent.from(comparisonInput), + socialSummary ); reportGenerationLogRecorder.succeed(textConfirmLogId); @@ -184,7 +246,19 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { null ); try { - String base64Image = openAiImageClient.generateBase64Image(event.userId(), dto, range); + MonthlyImageVisualPreset imageVisualPreset = monthlyImagePresetAssignmentService.getOrAssignVisualPreset( + event.userId(), + event.reportId() + ); + MonthlyImagePromptContext imagePromptContext = MonthlyImagePromptContext.from( + dto, + range, + imageVisualPreset + ); + String base64Image = openAiImageClient.generateBase64Image( + event.userId(), + imagePromptContext + ); imageKey = monthlyReportImageStorage.uploadBase64Webp( event.userId(), event.reportId(), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContent.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContent.java new file mode 100644 index 00000000..70158811 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContent.java @@ -0,0 +1,45 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; + +public record MonthlyEmotionComparisonContent( + Long previousReportId, + Integer previousMonth, + TypeEmotionStatsContent previousEmotionStats, + Integer positivePercentPointChange +) { + public static MonthlyEmotionComparisonContent from(MonthlyReportComparisonInputDto input) { + if (input == null) { + return null; + } + + Integer previousMonth = input.previousMonthStartDate() == null + ? null + : input.previousMonthStartDate().getMonthValue(); + + return new MonthlyEmotionComparisonContent( + input.previousReportId(), + previousMonth, + input.previousEmotionStats(), + input.positivePercentPointChange() + ).normalized(); + } + + public MonthlyEmotionComparisonContent normalized() { + TypeEmotionStatsContent normalizedPreviousEmotionStats = previousEmotionStats == null + ? TypeContentFactory.emptyEmotionStats() + : previousEmotionStats.normalized(); + int normalizedPositivePercentPointChange = positivePercentPointChange == null + ? 0 + : Math.max(-100, Math.min(100, positivePercentPointChange)); + + return new MonthlyEmotionComparisonContent( + previousReportId, + previousMonth, + normalizedPreviousEmotionStats, + normalizedPositivePercentPointChange + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialRankingItem.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialRankingItem.java new file mode 100644 index 00000000..83fefa0e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialRankingItem.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import com.devkor.ifive.nadab.domain.user.core.entity.DefaultProfileType; + +public record MonthlySocialRankingItem( + int displayOrder, + Long userId, + String nickname, + String profileImageKey, + DefaultProfileType defaultProfileType, + boolean topRank +) { + public MonthlySocialRankingItem normalized() { + return new MonthlySocialRankingItem( + Math.max(1, displayOrder), + userId, + nickname == null ? "" : nickname.trim(), + profileImageKey, + defaultProfileType, + topRank + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContent.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContent.java new file mode 100644 index 00000000..a732e55f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContent.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import java.util.List; + +public record MonthlySocialSummaryContent( + boolean visible, + int month, + List likeRanking, + List commentRanking +) { + private static final int MAX_RANKING_SIZE = 3; + + public static MonthlySocialSummaryContent empty(int month) { + return new MonthlySocialSummaryContent( + false, + month, + List.of(), + List.of() + ); + } + + public MonthlySocialSummaryContent normalized() { + return new MonthlySocialSummaryContent( + visible, + Math.max(1, Math.min(12, month)), + normalizeRanking(likeRanking), + normalizeRanking(commentRanking) + ); + } + + private static List normalizeRanking(List ranking) { + if (ranking == null || ranking.isEmpty()) { + return List.of(); + } + + return ranking.stream() + .filter(item -> item != null) + .limit(MAX_RANKING_SIZE) + .map(MonthlySocialRankingItem::normalized) + .toList(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyImagePromptContext.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyImagePromptContext.java new file mode 100644 index 00000000..82cbe0d5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyImagePromptContext.java @@ -0,0 +1,55 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; + +import java.time.LocalDate; +import java.util.Objects; + +public record MonthlyImagePromptContext( + String summary, + String commentSummary, + String dominantKeyword, + LocalDate monthStartDate, + LocalDate monthEndDate, + MonthlyImageStylePreset stylePreset, + MonthlyImageColorPalette colorPalette +) { + + public MonthlyImagePromptContext { + summary = normalize(summary); + commentSummary = normalize(commentSummary); + dominantKeyword = normalize(dominantKeyword); + Objects.requireNonNull(monthStartDate, "monthStartDate must not be null"); + Objects.requireNonNull(monthEndDate, "monthEndDate must not be null"); + Objects.requireNonNull(stylePreset, "stylePreset must not be null"); + Objects.requireNonNull(colorPalette, "colorPalette must not be null"); + } + + public static MonthlyImagePromptContext from( + AiMonthlyReportResultDto result, + MonthRangeDto range, + MonthlyImageVisualPreset visualPreset + ) { + Objects.requireNonNull(result, "result must not be null"); + MonthlyReportV2Content content = Objects.requireNonNull(result.content(), "content must not be null"); + Objects.requireNonNull(range, "range must not be null"); + Objects.requireNonNull(visualPreset, "visualPreset must not be null"); + + return new MonthlyImagePromptContext( + content.summary(), + content.commentSummary(), + content.dominantKeyword(), + range.monthStartDate(), + range.monthEndDate(), + visualPreset.stylePreset(), + visualPreset.colorPalette() + ); + } + + private static String normalize(String value) { + return value == null || value.isBlank() ? "Not provided" : value.trim(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyImageVisualPreset.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyImageVisualPreset.java new file mode 100644 index 00000000..1ca71b42 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyImageVisualPreset.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; + +import java.util.Objects; + +public record MonthlyImageVisualPreset( + MonthlyImageStylePreset stylePreset, + MonthlyImageColorPalette colorPalette +) { + public MonthlyImageVisualPreset { + Objects.requireNonNull(stylePreset, "stylePreset must not be null"); + Objects.requireNonNull(colorPalette, "colorPalette must not be null"); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportComparisonInputDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportComparisonInputDto.java new file mode 100644 index 00000000..5d68b484 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportComparisonInputDto.java @@ -0,0 +1,30 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; + +import java.time.LocalDate; +import java.util.List; + +public record MonthlyReportComparisonInputDto( + Long previousReportId, + LocalDate previousMonthStartDate, + LocalDate previousMonthEndDate, + MonthlyReportV2Content previousContent, + TypeTextContent previousEmotionSummaryContent, + TypeEmotionStatsContent previousEmotionStats, + int currentPositivePercent, + int previousPositivePercent, + int positivePercentPointChange, + List emotionChanges +) { + public record EmotionChange( + String emotionCode, + String emotionName, + int currentPercent, + int previousPercent, + int changePercentPoint + ) { + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java index a93f3358..41e702e8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java @@ -4,6 +4,6 @@ public record MonthlyReportGenerationRequestedEventDtoV2( Long reportId, Long userId, Long crystalLogId, - boolean exists + Long previousReportId ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlySocialInteractionCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlySocialInteractionCountDto.java new file mode 100644 index 00000000..64e69342 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlySocialInteractionCountDto.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import com.devkor.ifive.nadab.domain.user.core.entity.DefaultProfileType; + +public record MonthlySocialInteractionCountDto( + Long userId, + String nickname, + String profileImageKey, + DefaultProfileType defaultProfileType, + long interactionCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyImageColorPalette.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyImageColorPalette.java new file mode 100644 index 00000000..d6e1c297 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyImageColorPalette.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +public enum MonthlyImageColorPalette { + FOREST_MIST, + OCEAN_LIGHT, + SUNSET_CLAY, + MOON_VIOLET, + CITRUS_BREEZE, + EARTH_NEUTRAL, + JEWEL_GLOW, + ROSE_DAWN +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyImageStylePreset.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyImageStylePreset.java new file mode 100644 index 00000000..1cca0d8c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyImageStylePreset.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +public enum MonthlyImageStylePreset { + BOTANICAL_COLLAGE, + PAPER_CUT_LAYERS, + GLASS_AND_LIGHT, + MINIMAL_GEOMETRY, + INK_WASH, + DREAMLIKE_LANDSCAPE, + TEXTILE_PATTERN, + CERAMIC_OBJECTS +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2.java index ae717af1..1cb31d47 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportV2.java @@ -1,8 +1,10 @@ package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyEmotionComparisonContent; import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyContentFactory; import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; @@ -52,6 +54,14 @@ public class MonthlyReportV2 extends CreatableEntity { @Column(name = "image_key", length = 255) private String imageKey; + @Enumerated(EnumType.STRING) + @Column(name = "image_prompt_variant", length = 40) + private MonthlyImageStylePreset imagePromptVariant; + + @Enumerated(EnumType.STRING) + @Column(name = "image_color_palette", length = 40) + private MonthlyImageColorPalette imageColorPalette; + @Enumerated(EnumType.STRING) @Column(name = "image_status", nullable = false, length = 16) private MonthlyReportImageStatus imageStatus; @@ -72,6 +82,14 @@ public class MonthlyReportV2 extends CreatableEntity { @Column(name = "interest_stats", columnDefinition = "jsonb", nullable = false) private InterestStatsContent interestStats; + @Type(JsonType.class) + @Column(name = "emotion_comparison", columnDefinition = "jsonb") + private MonthlyEmotionComparisonContent emotionComparison; + + @Type(JsonType.class) + @Column(name = "social_summary", columnDefinition = "jsonb", nullable = false) + private MonthlySocialSummaryContent socialSummary; + @Column(name = "summary", nullable = false, length = 80) private String summary; @@ -114,6 +132,7 @@ public static MonthlyReportV2 create( mr.emotionSummaryContent = TypeContentFactory.emptyText().normalized(); mr.emotionStats = TypeContentFactory.emptyEmotionStats(); mr.interestStats = MonthlyContentFactory.emptyInterestStats(); + mr.socialSummary = MonthlySocialSummaryContent.empty(monthStartDate.getMonthValue()); mr.comparisonType = comparisonType; mr.date = date; @@ -128,4 +147,22 @@ public static MonthlyReportV2 createPending( return create(user, monthStartDate, monthEndDate, MonthlyReportV2ContentFactory.empty(), date, MonthlyReportStatus.PENDING, MonthlyReportImageStatus.PENDING, comparisonType); } + + public void updateSocialSummary(MonthlySocialSummaryContent socialSummary) { + this.socialSummary = socialSummary == null + ? MonthlySocialSummaryContent.empty(monthStartDate.getMonthValue()) + : socialSummary.normalized(); + } + + public void assignImagePromptVariant(MonthlyImageStylePreset imagePromptVariant) { + if (this.imagePromptVariant == null) { + this.imagePromptVariant = imagePromptVariant; + } + } + + public void assignImageColorPalette(MonthlyImageColorPalette imageColorPalette) { + if (this.imageColorPalette == null) { + this.imageColorPalette = imageColorPalette; + } + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportV2Repository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportV2Repository.java index 9b7e6508..abfc86a5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportV2Repository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportV2Repository.java @@ -3,6 +3,9 @@ import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportImageStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -14,8 +17,6 @@ public interface MonthlyReportV2Repository extends JpaRepository { - boolean existsByUserIdAndStatus(Long userId, MonthlyReportStatus status); - Optional findByUserIdAndMonthStartDate(Long userId, LocalDate monthStartDate); Optional findByUserIdAndMonthStartDateAndStatus( @@ -24,8 +25,44 @@ Optional findByUserIdAndMonthStartDateAndStatus( MonthlyReportStatus status ); + Optional findFirstByUserIdAndStatusAndMonthStartDateBeforeOrderByMonthStartDateDesc( + Long userId, + MonthlyReportStatus status, + LocalDate monthStartDate + ); + List findAllByUserIdAndStatus(Long userId, MonthlyReportStatus status); + @Query(""" + select mr.imagePromptVariant + from MonthlyReportV2 mr + where mr.user.id = :userId + and mr.id <> :reportId + and mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.COMPLETED + and mr.imagePromptVariant is not null + order by mr.monthStartDate desc, mr.id desc + """) + List findRecentCompletedImagePromptVariants( + @Param("userId") Long userId, + @Param("reportId") Long reportId, + Pageable pageable + ); + + @Query(""" + select mr.imageColorPalette + from MonthlyReportV2 mr + where mr.user.id = :userId + and mr.id <> :reportId + and mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.COMPLETED + and mr.imageColorPalette is not null + order by mr.monthStartDate desc, mr.id desc + """) + List findRecentCompletedImageColorPalettes( + @Param("userId") Long userId, + @Param("reportId") Long reportId, + Pageable pageable + ); + /** * PENDING -> FAILED 확정 */ @@ -55,6 +92,8 @@ int updateStatus( emotion_summary_content = CAST(:emotionSummaryContentJson AS jsonb), emotion_stats = CAST(:emotionStatsJson AS jsonb), interest_stats = CAST(:interestStatsJson AS jsonb), + emotion_comparison = CAST(:emotionComparisonJson AS jsonb), + social_summary = CAST(:socialSummaryJson AS jsonb), summary = :summary, comment_summary = :commentSummary, dominant_keyword = :dominantKeyword, @@ -71,6 +110,8 @@ int updateContent( @Param("dominantKeyword") String dominantKeyword, @Param("emotionStatsJson") String emotionStatsJson, @Param("interestStatsJson") String interestStatsJson, + @Param("emotionComparisonJson") String emotionComparisonJson, + @Param("socialSummaryJson") String socialSummaryJson, @Param("status") String status ); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlySocialQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlySocialQueryRepository.java new file mode 100644 index 00000000..a8a23ffa --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlySocialQueryRepository.java @@ -0,0 +1,84 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.repository; + +import com.devkor.ifive.nadab.domain.like.core.entity.DailyReportLike; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; + +public interface MonthlySocialQueryRepository extends Repository { + + @Query(""" + select new com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto( + liker.id, + liker.nickname, + liker.profileImageKey, + liker.defaultProfileType, + count(l) + ) + from DailyReportLike l + join l.user liker + where l.dailyReport.answerEntry.user.id = :userId + and l.createdAt >= :startAt + and l.createdAt < :endAt + and liker.deletedAt is null + and exists ( + select 1 + from Friendship f + where f.status = com.devkor.ifive.nadab.domain.friend.core.entity.FriendshipStatus.ACCEPTED + and ((f.user1.id = :userId and f.user2.id = liker.id) + or (f.user2.id = :userId and f.user1.id = liker.id)) + ) + and not exists ( + select 1 + from UserBlock ub + where (ub.blocker.id = :userId and ub.blocked.id = liker.id) + or (ub.blocker.id = liker.id and ub.blocked.id = :userId) + ) + group by liker.id, liker.nickname, liker.profileImageKey, liker.defaultProfileType + """) + List countReceivedLikesByFriend( + @Param("userId") Long userId, + @Param("startAt") OffsetDateTime startAt, + @Param("endAt") OffsetDateTime endAt + ); + + @Query(""" + select new com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto( + author.id, + author.nickname, + author.profileImageKey, + author.defaultProfileType, + count(c) + ) + from Comment c + join c.author author + where c.dailyReport.answerEntry.user.id = :userId + and c.createdAt >= :startAt + and c.createdAt < :endAt + and c.deletedAt is null + and author.deletedAt is null + and exists ( + select 1 + from Friendship f + where f.status = com.devkor.ifive.nadab.domain.friend.core.entity.FriendshipStatus.ACCEPTED + and ((f.user1.id = :userId and f.user2.id = author.id) + or (f.user2.id = :userId and f.user1.id = author.id)) + ) + and not exists ( + select 1 + from UserBlock ub + where (ub.blocker.id = :userId and ub.blocked.id = author.id) + or (ub.blocker.id = author.id and ub.blocked.id = :userId) + ) + group by author.id, author.nickname, author.profileImageKey, author.defaultProfileType + """) + List countReceivedCommentsByFriend( + @Param("userId") Long userId, + @Param("startAt") OffsetDateTime startAt, + @Param("endAt") OffsetDateTime endAt + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryService.java new file mode 100644 index 00000000..97282665 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryService.java @@ -0,0 +1,40 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlySocialSummaryCalculator; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlySocialQueryRepository; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MonthlySocialSummaryService { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + private final MonthlySocialQueryRepository monthlySocialQueryRepository; + + public MonthlySocialSummaryContent buildSocialSummary(Long userId, MonthRangeDto range) { + OffsetDateTime startAt = range.monthStartDate().atStartOfDay(SEOUL).toOffsetDateTime(); + OffsetDateTime endAt = range.monthEndDate().plusDays(1).atStartOfDay(SEOUL).toOffsetDateTime(); + + List likeCounts = + monthlySocialQueryRepository.countReceivedLikesByFriend(userId, startAt, endAt); + List commentCounts = + monthlySocialQueryRepository.countReceivedCommentsByFriend(userId, startAt, endAt); + + return MonthlySocialSummaryCalculator.calculate( + range.monthStartDate().getMonthValue(), + likeCounts, + commentCounts + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java index f2c44c90..b7c20ae9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java @@ -5,8 +5,11 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.devkor.ifive.nadab.global.shared.reportcontent.*; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -14,6 +17,7 @@ import org.springframework.ai.anthropic.AnthropicChatOptions; import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.google.genai.GoogleGenAiChatModel; import org.springframework.ai.google.genai.GoogleGenAiChatOptions; import org.springframework.ai.openai.OpenAiChatOptions; @@ -47,7 +51,7 @@ public class MonthlyReportLlmClient { private static final int MIN_SUMMARY = 8; private static final int MAX_SUMMARY = 30; - public AiReportResultDto generate( + public LlmGenerationResult generate( String monthStartDate, String monthEndDate, String weeklySummaries, String representativeEntries) { String prompt = monthlyReportPromptLoader.loadV1Prompt() .replace("{monthStartDate}", monthStartDate) @@ -57,11 +61,13 @@ public AiReportResultDto generate( ChatClient client = llmRouter.route(provider); - String content = switch (provider) { + LlmGenerationResult generationResult = switch (provider) { case OPENAI -> callOpenAi(client, prompt); case CLAUDE -> callClaude(client, prompt); case GEMINI -> callGemini(client, prompt); }; + String content = generationResult.content(); + LlmTokenUsage tokenUsage = generationResult.tokenUsage(); if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); @@ -97,11 +103,14 @@ public AiReportResultDto generate( throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); } - AiReportResultDto dto = enforceLength(new AiReportResultDto(reportContent, discovered, improve)); + LlmGenerationResult lengthResult = + enforceLength(new AiReportResultDto(reportContent, discovered, improve)); + AiReportResultDto dto = lengthResult.content(); + tokenUsage = tokenUsage.plus(lengthResult.tokenUsage()); validateLength(dto); - return dto; + return new LlmGenerationResult<>(dto, tokenUsage); } catch (AiResponseParseException e) { throw e; @@ -110,7 +119,7 @@ public AiReportResultDto generate( } } - private String callOpenAi(ChatClient client, String prompt) { + private LlmGenerationResult callOpenAi(ChatClient client, String prompt) { OpenAiChatOptions options = OpenAiChatOptions.builder() .model(OpenAiApi.ChatModel.GPT_5_MINI) .reasoningEffort("medium") @@ -118,34 +127,36 @@ private String callOpenAi(ChatClient client, String prompt) { .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private String callClaude(ChatClient client, String prompt) { + private LlmGenerationResult callClaude(ChatClient client, String prompt) { AnthropicChatOptions options = AnthropicChatOptions.builder() .model(AnthropicApi.ChatModel.CLAUDE_3_HAIKU) .temperature(0.3) .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private String callGemini(ChatClient client, String prompt) { + private LlmGenerationResult callGemini(ChatClient client, String prompt) { GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder() .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_5_FLASH) .responseMimeType("application/json") @@ -153,17 +164,18 @@ private String callGemini(ChatClient client, String prompt) { .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private AiReportResultDto enforceLength(AiReportResultDto dto) { + private LlmGenerationResult enforceLength(AiReportResultDto dto) { ReportContent c = dto.content(); int dLen = dto.discovered().length(); @@ -172,15 +184,24 @@ private AiReportResultDto enforceLength(AiReportResultDto dto) { boolean badD = dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED; boolean badI = iLen < MIN_IMPROVE || iLen > MAX_IMPROVE; - if (!badD && !badI) return dto; + if (!badD && !badI) return new LlmGenerationResult<>(dto, LlmTokenUsage.empty()); ChatClient rewriteClient = llmRouter.route(REWRITE_PROVIDER); StyledText d = c.discovered(); StyledText i = c.improve(); + LlmTokenUsage tokenUsage = LlmTokenUsage.empty(); - if (badD) d = rewriteStyled(rewriteClient, d, true); - if (badI) i = rewriteStyled(rewriteClient, i, false); + if (badD) { + LlmGenerationResult rewriteResult = rewriteStyled(rewriteClient, d, true); + d = rewriteResult.content(); + tokenUsage = tokenUsage.plus(rewriteResult.tokenUsage()); + } + if (badI) { + LlmGenerationResult rewriteResult = rewriteStyled(rewriteClient, i, false); + i = rewriteResult.content(); + tokenUsage = tokenUsage.plus(rewriteResult.tokenUsage()); + } ReportContent newContent = new ReportContent(c.summary(), d, i); String newD = newContent.discovered().plainText(); @@ -193,10 +214,10 @@ private AiReportResultDto enforceLength(AiReportResultDto dto) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_REWRITE_FORMAT_INVALID); } - return new AiReportResultDto(newContent, newD, newI); + return new LlmGenerationResult<>(new AiReportResultDto(newContent, newD, newI), tokenUsage); } - private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDiscovered) { + private LlmGenerationResult rewriteStyled(ChatClient client, StyledText in, boolean isDiscovered) { int min = isDiscovered ? MIN_DISCOVERED : MIN_IMPROVE; int max = isDiscovered ? MAX_DISCOVERED : MAX_IMPROVE; int maxHl = isDiscovered ? MAX_HL_DISCOVERED : MAX_HL_IMPROVE; @@ -229,19 +250,21 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis .temperature(0.0) .build(); - String out; + ChatResponse response; try { - out = client.prompt().user(prompt).options(options).call().content(); + response = client.prompt().user(prompt).options(options).call().chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_REWRITE_NO_RESPONSE, e); } + String out = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.AI_REWRITE_NO_RESPONSE); } try { - return objectMapper.readValue(out, StyledText.class); + return new LlmGenerationResult<>(objectMapper.readValue(out, StyledText.class), tokenUsage); } catch (Exception e) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_REWRITE_JSON_MAPPING_FAILED); } @@ -253,6 +276,13 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis } } + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } + private boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java index 9b5623ca..76c3c7ea 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.monthlyreport.infra; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeReportInputAssembler; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; @@ -10,20 +11,26 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.devkor.ifive.nadab.global.shared.reportcontent.*; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.ai.anthropic.AnthropicChatOptions; import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.google.genai.GoogleGenAiChatModel; import org.springframework.ai.google.genai.GoogleGenAiChatOptions; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.EnumSet; import java.util.List; @@ -41,33 +48,46 @@ public class MonthlyReportLlmClientV2 { private static final int MAX_DISCOVERED = 400; private static final int MIN_DISCOVERED = 150; + private static final int MAX_COMMENT = 400; + private static final int MIN_COMMENT = 150; + private static final int MAX_EMOTION = 200; private static final int MIN_EMOTION = 50; private static final int MIN_SUMMARY = 8; private static final int MAX_SUMMARY = 30; - public AiMonthlyReportResultDto generate( + private static final int MIN_COMPARISON_EMOTION_TREND = 5; + private static final int MAX_COMPARISON_EMOTION_TREND = 80; + private static final int MIN_COMPARISON_EMOTION = 40; + private static final int MAX_COMPARISON_EMOTION = 150; + private static final int MAX_COMPARISON_BOLD_SEGMENTS = 4; + + public LlmGenerationResult generate( String monthStartDate, String monthEndDate, String weeklySummaries, String representativeEntries, TypeEmotionStatsContent emotionStats, - boolean exists) { - String prompt = monthlyReportPromptLoader.loadV2BaselinePrompt() - .replace("{monthStartDate}", monthStartDate) - .replace("{monthEndDate}", monthEndDate) - .replace("{weeklySummaries}", weeklySummaries) - .replace("{representativeEntries}", representativeEntries == null ? "" : representativeEntries) - .replace("{emotionStats}", TypeReportInputAssembler.assembleEmotionStats(emotionStats)); + MonthlyReportComparisonInputDto comparisonInput) { + String prompt = buildPrompt( + monthStartDate, + monthEndDate, + weeklySummaries, + representativeEntries, + emotionStats, + comparisonInput + ); ChatClient client = llmRouter.route(provider); - String content = switch (provider) { + LlmGenerationResult generationResult = switch (provider) { case OPENAI -> callOpenAi(client, prompt); case CLAUDE -> callClaude(client, prompt); case GEMINI -> callGemini(client, prompt); }; + String content = generationResult.content(); + LlmTokenUsage tokenUsage = generationResult.tokenUsage(); if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); @@ -83,15 +103,15 @@ public AiMonthlyReportResultDto generate( MonthlyReportV2Content monthlyReportContent = result.content(); - StyledText discoveredStyled = monthlyReportContent.discovered(); - StyledText commentStyled = monthlyReportContent.comment(); + StyledText discoveredStyled = normalizeStyledTextSegments(monthlyReportContent.discovered()); + StyledText commentStyled = normalizeStyledTextSegments(monthlyReportContent.comment()); String summary = monthlyReportContent.summary(); String commentSummary = monthlyReportContent.commentSummary(); String dominantKeyword = monthlyReportContent.dominantKeyword(); String emotionTrend = monthlyReportContent.emotionTrend(); TypeTextContent emotionStatsContent = result.emotionSummaryContent(); - StyledText styledText = emotionStatsContent.styledText(); + StyledText styledText = normalizeStyledTextSegments(emotionStatsContent.styledText()); if (discoveredStyled == null || commentStyled == null || styledText == null || isBlank(summary) || isBlank(commentSummary) || isBlank(dominantKeyword) || isBlank(emotionTrend)) { @@ -105,17 +125,36 @@ public AiMonthlyReportResultDto generate( validateStyledText(commentStyled, true); validateStyledText(styledText, false); - String discovered = monthlyReportContent.discovered().plainText(); - String comment = monthlyReportContent.comment().plainText(); - String emotion = emotionStatsContent.styledText().plainText(); + String discovered = discoveredStyled.plainText(); + String comment = commentStyled.plainText(); + String emotion = styledText.plainText(); if (isBlank(discovered) || isBlank(comment) || isBlank(emotion)) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); } - validateLength(discovered, comment, emotion); + validateLength(discovered, comment, emotion, comparisonInput != null); + + if (comparisonInput != null) { + validateComparisonEmotionTrend(emotionTrend); + validateComparisonEmotionSummary(styledText, dominantKeyword); + } - return result; + MonthlyReportV2Content normalizedContent = new MonthlyReportV2Content( + summary, + commentSummary, + dominantKeyword, + emotionTrend, + discoveredStyled, + commentStyled + ); + return new LlmGenerationResult<>( + new AiMonthlyReportResultDto( + normalizedContent, + new TypeTextContent(styledText) + ), + tokenUsage + ); } catch (AiResponseParseException e) { throw e; @@ -124,7 +163,41 @@ public AiMonthlyReportResultDto generate( } } - private String callOpenAi(ChatClient client, String prompt) { + String buildPrompt( + String monthStartDate, + String monthEndDate, + String weeklySummaries, + String representativeEntries, + TypeEmotionStatsContent emotionStats, + MonthlyReportComparisonInputDto comparisonInput + ) { + String template = comparisonInput == null + ? monthlyReportPromptLoader.loadV2BaselinePrompt() + : monthlyReportPromptLoader.loadV2ComparisonPrompt(); + + String prompt = template + .replace("{monthStartDate}", monthStartDate) + .replace("{monthEndDate}", monthEndDate) + .replace("{weeklySummaries}", weeklySummaries) + .replace("{representativeEntries}", representativeEntries == null ? "" : representativeEntries) + .replace("{emotionStats}", TypeReportInputAssembler.assembleEmotionStats(emotionStats)); + + if (comparisonInput != null) { + prompt = prompt.replace("{comparisonInput}", serializeComparisonInput(comparisonInput)); + } + + return prompt; + } + + private String serializeComparisonInput(MonthlyReportComparisonInputDto comparisonInput) { + try { + return objectMapper.writeValueAsString(comparisonInput); + } catch (JsonProcessingException e) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } + + private LlmGenerationResult callOpenAi(ChatClient client, String prompt) { OpenAiChatOptions options = OpenAiChatOptions.builder() .model(OpenAiApi.ChatModel.GPT_5_MINI) .reasoningEffort("medium") @@ -132,34 +205,36 @@ private String callOpenAi(ChatClient client, String prompt) { .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private String callClaude(ChatClient client, String prompt) { + private LlmGenerationResult callClaude(ChatClient client, String prompt) { AnthropicChatOptions options = AnthropicChatOptions.builder() .model(AnthropicApi.ChatModel.CLAUDE_3_HAIKU) .temperature(0.3) .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private String callGemini(ChatClient client, String prompt) { + private LlmGenerationResult callGemini(ChatClient client, String prompt) { GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder() .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_5_FLASH) .responseMimeType("application/json") @@ -167,11 +242,12 @@ private String callGemini(ChatClient client, String prompt) { .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } @@ -270,10 +346,57 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis */ + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } + private boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } + StyledText normalizeStyledTextSegments(StyledText styledText) { + if (styledText == null || styledText.segments() == null) { + return styledText; + } + + List normalized = new ArrayList<>(); + StringBuilder leadingWhitespace = new StringBuilder(); + + for (Segment segment : styledText.segments()) { + if (segment == null || segment.text() == null || segment.text().isEmpty()) { + continue; + } + + String text = segment.text(); + if (text.isBlank()) { + if (normalized.isEmpty()) { + leadingWhitespace.append(text); + } else { + int lastIndex = normalized.size() - 1; + Segment previous = normalized.get(lastIndex); + normalized.set(lastIndex, new Segment( + previous.text() + text, + previous.marks() + )); + } + continue; + } + + if (!leadingWhitespace.isEmpty()) { + text = leadingWhitespace + text; + leadingWhitespace.setLength(0); + } + + List marks = segment.marks() == null ? List.of() : segment.marks(); + normalized.add(new Segment(text, marks)); + } + + return new StyledText(List.copyOf(normalized)); + } + private void validateStyledText(StyledText st, boolean isDiscovered) { if (st.segments() == null || st.segments().isEmpty()) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); @@ -308,7 +431,7 @@ private void validateStyledText(StyledText st, boolean isDiscovered) { } } - private void validateLength(String discovered, String comment, String emotion) { + void validateLength(String discovered, String comment, String emotion, boolean comparison) { int dLen = discovered.length(); int cLen = comment.length(); int eLen = emotion.length(); @@ -316,14 +439,67 @@ private void validateLength(String discovered, String comment, String emotion) { if (dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); } - if (cLen < MIN_DISCOVERED || cLen > MAX_DISCOVERED) { - throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); + if (cLen < MIN_COMMENT || cLen > MAX_COMMENT) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_COMMENT_LENGTH_INVALID); } - if (eLen < MIN_EMOTION || eLen > MAX_EMOTION) { - throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_IMPROVE_LENGTH_INVALID); + int minEmotion = comparison ? MIN_COMPARISON_EMOTION : MIN_EMOTION; + int maxEmotion = comparison ? MAX_COMPARISON_EMOTION : MAX_EMOTION; + if (eLen < minEmotion || eLen > maxEmotion) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_EMOTION_SUMMARY_LENGTH_INVALID); } } + void validateComparisonEmotionTrend(String emotionTrend) { + String trend = emotionTrend.trim(); + if (trend.length() < MIN_COMPARISON_EMOTION_TREND + || trend.length() > MAX_COMPARISON_EMOTION_TREND + || trend.contains("\n") + || trend.contains("\r") + || containsDigit(trend)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + } + + void validateComparisonEmotionSummary(StyledText emotionSummary, String dominantKeyword) { + int boldSegments = 0; + + for (Segment segment : emotionSummary.segments()) { + List marks = segment.marks() == null ? List.of() : segment.marks(); + if (marks.contains(Mark.BOLD)) { + boldSegments++; + } + } + + if (boldSegments == 0 + || boldSegments > MAX_COMPARISON_BOLD_SEGMENTS + || countOccurrences(emotionSummary.plainText(), dominantKeyword) == 0) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + } + + private boolean containsDigit(String value) { + for (int i = 0; i < value.length(); i++) { + if (Character.isDigit(value.charAt(i))) { + return true; + } + } + return false; + } + + private int countOccurrences(String value, String target) { + if (isBlank(value) || isBlank(target)) { + return 0; + } + + int count = 0; + int fromIndex = 0; + while ((fromIndex = value.indexOf(target, fromIndex)) >= 0) { + count++; + fromIndex += target.length(); + } + return count; + } + private void validateSummary(String summary) { if (summary == null) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); @@ -341,10 +517,8 @@ private void validateSummary(String summary) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); } // 숫자 금지(시간/빈도 방지) - for (int k = 0; k < s.length(); k++) { - if (Character.isDigit(s.charAt(k))) { - throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); - } + if (containsDigit(s)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); } } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java index f17d7ed3..50057f4f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java @@ -1,15 +1,13 @@ package com.devkor.ifive.nadab.domain.monthlyreport.infra; -import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyImagePromptComposer; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.OpenAiImageGenerateRequestDto; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.OpenAiImageGenerateResponseDto; -import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; -import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; -import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -25,7 +23,7 @@ public class OpenAiImageClient { private final WebClient.Builder webClientBuilder; - private final MonthlyReportPromptLoader monthlyReportPromptLoader; + private final MonthlyImagePromptComposer monthlyImagePromptComposer; @Value("${openai.api-key}") private String apiKey; @@ -42,17 +40,11 @@ public class OpenAiImageClient { @Value("${openai.image.output-format}") private String outputFormat; - public String generateBase64Image(Long userId, AiMonthlyReportResultDto dto, MonthRangeDto range) { - MonthlyReportV2Content content = dto.content(); + public String generateBase64Image(Long userId, MonthlyImagePromptContext context) { + return generateBase64Image(userId, monthlyImagePromptComposer.compose(context)); + } - String prompt = monthlyReportPromptLoader.loadImagePrompt() - .formatted( - safe(content.summary()), - safe(content.commentSummary()), - safe(content.dominantKeyword()), - range.monthStartDate(), - range.monthEndDate() - ); + private String generateBase64Image(Long userId, String prompt) { OpenAiImageGenerateRequestDto request = new OpenAiImageGenerateRequestDto( model, @@ -110,10 +102,6 @@ public String generateBase64Image(Long userId, AiMonthlyReportResultDto dto, Mon } } - private String safe(String value) { - return value == null || value.isBlank() ? "Not provided" : value; - } - private String truncate(String value) { if (value == null) { return ""; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogRecorder.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogRecorder.java index f511328b..8c0286fe 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogRecorder.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogRecorder.java @@ -52,6 +52,24 @@ public void succeed(Long logId) { } } + public void recordTokenUsage( + Long logId, + Long inputTokens, + Long outputTokens, + Long totalTokens, + Long thinkingTokens + ) { + if (logId == null) { + return; + } + + try { + reportGenerationLogService.recordTokenUsage(logId, inputTokens, outputTokens, totalTokens, thinkingTokens); + } catch (Exception e) { + log.warn("[REPORT_GENERATION_LOG][TOKEN_USAGE_FAILED] logId={}", logId, e); + } + } + public void fail(Long logId, Exception exception) { if (logId == null) { return; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogService.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogService.java index b1a8eeda..0c1107c2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogService.java @@ -51,6 +51,18 @@ public void succeed(Long logId) { .ifPresent(ReportGenerationLog::succeed); } + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordTokenUsage( + Long logId, + Long inputTokens, + Long outputTokens, + Long totalTokens, + Long thinkingTokens + ) { + reportGenerationLogRepository.findById(logId) + .ifPresent(log -> log.recordTokenUsage(inputTokens, outputTokens, totalTokens, thinkingTokens)); + } + @Transactional(propagation = Propagation.REQUIRES_NEW) public void fail(Long logId, Exception exception) { Integer externalHttpStatus = extractExternalHttpStatus(exception); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLog.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLog.java index 36efa075..df5a3069 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLog.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLog.java @@ -76,6 +76,18 @@ public class ReportGenerationLog extends CreatableEntity { @Column(name = "elapsed_ms") private Long elapsedMs; + @Column(name = "input_tokens") + private Long inputTokens; + + @Column(name = "output_tokens") + private Long outputTokens; + + @Column(name = "total_tokens") + private Long totalTokens; + + @Column(name = "thinking_tokens") + private Long thinkingTokens; + @Column(name = "started_at", nullable = false) private OffsetDateTime startedAt; @@ -107,6 +119,18 @@ public void succeed() { finish(); } + public void recordTokenUsage( + Long inputTokens, + Long outputTokens, + Long totalTokens, + Long thinkingTokens + ) { + this.inputTokens = inputTokens; + this.outputTokens = outputTokens; + this.totalTokens = totalTokens; + this.thinkingTokens = thinkingTokens; + } + public void fail( String errorCode, String exceptionClass, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestUserService.java b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestUserService.java index 1a7e39b9..c1c3dcb1 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestUserService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestUserService.java @@ -3,9 +3,11 @@ import com.devkor.ifive.nadab.domain.terms.application.TermsCommandService; import com.devkor.ifive.nadab.domain.test.api.dto.request.CreateTestUserRequest; import com.devkor.ifive.nadab.domain.test.api.dto.response.CreateTestUserResponse; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.user.core.service.UserInterestService; import com.devkor.ifive.nadab.domain.user.core.service.UserProfileUpdateService; import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; @@ -25,9 +27,12 @@ @RequiredArgsConstructor public class TestUserService { + private static final long DEFAULT_CRYSTAL_BALANCE = 1_000L; + private final UserRepository userRepository; private final UserWalletRepository userWalletRepository; private final UserProfileUpdateService userProfileUpdateService; + private final UserInterestService userInterestService; private final TermsCommandService termsCommandService; private final PasswordEncoder passwordEncoder; @@ -47,9 +52,10 @@ public CreateTestUserResponse createTestUser(CreateTestUserRequest request) { userRepository.save(user); userProfileUpdateService.updateNickname(user, request.nickname()); + userInterestService.updateUserInterest(user, InterestCode.PREFERENCE); user.updateSignupStatus(SignupStatusType.COMPLETED); - userWalletRepository.save(UserWallet.create(user)); + userWalletRepository.save(UserWallet.create(user, DEFAULT_CRYSTAL_BALANCE)); termsCommandService.saveConsents(user.getId(), true, true, true, false); return new CreateTestUserResponse( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java index 75400229..3d278ae0 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java @@ -24,7 +24,9 @@ import com.devkor.ifive.nadab.domain.typereport.core.service.TypeSelectionService; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -125,7 +127,10 @@ public void handle(TypeReportGenerationRequestedEventDto event) { TYPE_REPORT_OPENAI_MODEL ); try { - cards = evidenceCardGenerationService.generate(recentEntries); + LlmGenerationResult> generationResult = + evidenceCardGenerationService.generate(recentEntries); + cards = generationResult.content(); + recordTokenUsage(evidenceLogId, generationResult.tokenUsage()); reportGenerationLogRecorder.succeed(evidenceLogId); } catch (Exception e) { reportGenerationLogRecorder.fail(evidenceLogId, e); @@ -146,7 +151,10 @@ public void handle(TypeReportGenerationRequestedEventDto event) { TYPE_REPORT_OPENAI_MODEL ); try { - patterns = patternExtractionService.extract(cards); + LlmGenerationResult generationResult = + patternExtractionService.extract(cards); + patterns = generationResult.content(); + recordTokenUsage(patternLogId, generationResult.tokenUsage()); reportGenerationLogRecorder.succeed(patternLogId); } catch (Exception e) { reportGenerationLogRecorder.fail(patternLogId, e); @@ -179,7 +187,10 @@ public void handle(TypeReportGenerationRequestedEventDto event) { TYPE_REPORT_OPENAI_MODEL ); try { - selection = typeSelectionService.select(candidates, patterns); + LlmGenerationResult generationResult = + typeSelectionService.select(candidates, patterns); + selection = generationResult.content(); + recordTokenUsage(selectionLogId, generationResult.tokenUsage()); reportGenerationLogRecorder.succeed(selectionLogId); } catch (Exception e) { reportGenerationLogRecorder.fail(selectionLogId, e); @@ -213,13 +224,15 @@ public void handle(TypeReportGenerationRequestedEventDto event) { TYPE_REPORT_GEMINI_MODEL ); try { - content = typeReportContentGenerationService.generate( + LlmGenerationResult generationResult = typeReportContentGenerationService.generate( selectedType, patterns, cards, emotionStats, selection.analysisTypeCode() ); + content = generationResult.content(); + recordTokenUsage(contentLogId, generationResult.tokenUsage()); reportGenerationLogRecorder.succeed(contentLogId); } catch (Exception e) { reportGenerationLogRecorder.fail(contentLogId, e); @@ -282,4 +295,14 @@ private String getCategoryNameKorean(InterestCode code) { case VALUES -> "가치관"; }; } + + private void recordTokenUsage(Long logId, LlmTokenUsage tokenUsage) { + reportGenerationLogRecorder.recordTokenUsage( + logId, + tokenUsage.inputTokens(), + tokenUsage.outputTokens(), + tokenUsage.totalTokens(), + tokenUsage.thinkingTokens() + ); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/EvidenceCardGenerationService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/EvidenceCardGenerationService.java index f3bcf4ca..acb4504e 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/EvidenceCardGenerationService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/EvidenceCardGenerationService.java @@ -7,6 +7,8 @@ import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,17 +23,17 @@ public class EvidenceCardGenerationService { private final TypeEvidenceCardLlmClient llmClient; - public List generate(List recentEntries) { + public LlmGenerationResult> generate(List recentEntries) { List withIds = EvidenceEntriesAssembler.attachIds(recentEntries); - if (withIds.isEmpty()) return List.of(); + if (withIds.isEmpty()) return new LlmGenerationResult<>(List.of(), LlmTokenUsage.empty()); String entriesText = EvidenceEntriesAssembler.assembleForPrompt(withIds); - List> raw = llmClient.generateRawCardsJsonArray(entriesText); + LlmGenerationResult>> rawResult = llmClient.generateRawCardsJsonArray(entriesText); - List cards = mapAndValidate(withIds, raw); + List cards = mapAndValidate(withIds, rawResult.content()); - return cards; + return new LlmGenerationResult<>(cards, rawResult.tokenUsage()); } private List mapAndValidate(List withIds, List> raw) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/PatternExtractionService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/PatternExtractionService.java index 919285d4..a00a42c1 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/PatternExtractionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/PatternExtractionService.java @@ -6,6 +6,7 @@ import com.devkor.ifive.nadab.domain.typereport.infra.TypePatternExtractLlmClient; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -26,7 +27,7 @@ public class PatternExtractionService { private final TypePatternExtractLlmClient llmClient; - public PatternExtractionResultDto extract(List cards) { + public LlmGenerationResult extract(List cards) { if (cards == null || cards.isEmpty()) { throw new AiResponseParseException(ErrorCode.PATTERN_RESULT_EMPTY); } @@ -37,12 +38,12 @@ public PatternExtractionResultDto extract(List cards) { .collect(Collectors.toSet()); String cardsText = EvidenceCardsAssembler.assembleForPrompt(cards); - JsonNode raw = llmClient.extractPatternsRawJson(cardsText); + LlmGenerationResult rawResult = llmClient.extractPatternsRawJson(cardsText); - PatternExtractionResultDto dto = toDto(raw); + PatternExtractionResultDto dto = toDto(rawResult.content()); validate(dto, validIds); - return dto; + return new LlmGenerationResult<>(dto, rawResult.tokenUsage()); } private PatternExtractionResultDto toDto(JsonNode raw) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java index 803b49ea..54089374 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java @@ -11,6 +11,8 @@ import com.devkor.ifive.nadab.domain.typereport.infra.TypeReportLlmClient; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; @@ -36,7 +38,7 @@ public class TypeReportContentGenerationService { private final TypeReportLlmClient llmClient; private final ObjectMapper objectMapper; - public TypeReportContentDto generate( + public LlmGenerationResult generate( AnalysisTypeCandidateDto selectedType, PatternExtractionResultDto patterns, List allCards, @@ -56,19 +58,23 @@ public TypeReportContentDto generate( String evidenceCardsText = TypeReportInputAssembler.assembleEvidenceCards(representative); String emotionStatsText = TypeReportInputAssembler.assembleEmotionStats(emotionStats); - JsonNode raw = llmClient.generateRaw(selectedTypeText, patternsText, evidenceCardsText, emotionStatsText); + LlmGenerationResult rawResult = + llmClient.generateRaw(selectedTypeText, patternsText, evidenceCardsText, emotionStatsText); + JsonNode raw = rawResult.content(); + LlmTokenUsage tokenUsage = rawResult.tokenUsage(); // 1차 파싱/검증 try { TypeReportContentDto dto = toDto(raw); validate(dto, expectedAnalysisTypeCode); - return dto; + return new LlmGenerationResult<>(dto, tokenUsage); } catch (AiResponseParseException e) { // 리페어 1회 - JsonNode repaired = llmClient.rewriteOnly(raw); + LlmGenerationResult rewriteResult = llmClient.rewriteOnly(raw); + JsonNode repaired = rewriteResult.content(); TypeReportContentDto dto2 = toDto(repaired); validate(dto2, expectedAnalysisTypeCode); - return dto2; + return new LlmGenerationResult<>(dto2, tokenUsage.plus(rewriteResult.tokenUsage())); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeSelectionService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeSelectionService.java index 6db4c50d..29c4c4d5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeSelectionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeSelectionService.java @@ -7,6 +7,7 @@ import com.devkor.ifive.nadab.domain.typereport.infra.TypeSelectLlmClient; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -26,7 +27,7 @@ public class TypeSelectionService { private final TypeSelectLlmClient llmClient; - public TypeSelectionResultDto select( + public LlmGenerationResult select( List candidates, PatternExtractionResultDto patternResult ) { @@ -47,12 +48,12 @@ public TypeSelectionResultDto select( String candidatesText = TypeSelectionInputAssembler.assembleCandidates(candidates); String patternsText = TypeSelectionInputAssembler.assemblePatterns(patternResult); - JsonNode raw = llmClient.selectTypeRawJson(candidatesText, patternsText); + LlmGenerationResult rawResult = llmClient.selectTypeRawJson(candidatesText, patternsText); - TypeSelectionResultDto dto = toDto(raw); + TypeSelectionResultDto dto = toDto(rawResult.content()); validate(dto, candidateCodes, validEvidenceIds); - return dto; + return new LlmGenerationResult<>(dto, rawResult.tokenUsage()); } private TypeSelectionResultDto toDto(JsonNode raw) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java index 3df6a0a9..ac70333f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java @@ -5,12 +5,16 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.stereotype.Component; @@ -28,25 +32,27 @@ public class TypeEvidenceCardLlmClient { private final LlmProvider provider = LlmProvider.OPENAI; - public List> generateRawCardsJsonArray(String entriesText) { + public LlmGenerationResult>> generateRawCardsJsonArray(String entriesText) { String prompt = promptLoader.loadPrompt() .replace("{entries}", entriesText); ChatClient client = llmRouter.route(provider); - String content; + ChatResponse response; try { - content = client.prompt() + response = client.prompt() .user(prompt) .options(OpenAiChatOptions.builder() .model(OpenAiApi.ChatModel.GPT_4_O_MINI) .temperature(0.0) .build()) .call() - .content(); + .chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_EVIDENCE_CARD_NO_RESPONSE, e); } + String content = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_EVIDENCE_CARD_NO_RESPONSE); @@ -54,7 +60,7 @@ public List> generateRawCardsJsonArray(String entriesText) { try { // JSON 배열 파싱: [{"id":"D1","card":"..."}...] - return objectMapper.readValue(content, new TypeReference<>() {}); + return new LlmGenerationResult<>(objectMapper.readValue(content, new TypeReference<>() {}), tokenUsage); } catch (AiResponseParseException e) { throw e; } catch (Exception e) { @@ -62,4 +68,11 @@ public List> generateRawCardsJsonArray(String entriesText) { throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); } } + + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java index 625b9dee..9d59c986 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java @@ -5,12 +5,16 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.stereotype.Component; @@ -25,7 +29,7 @@ public class TypePatternExtractLlmClient { private final LlmProvider provider = LlmProvider.OPENAI; - public JsonNode extractPatternsRawJson(String cardsText) { + public LlmGenerationResult extractPatternsRawJson(String cardsText) { String prompt = promptLoader.loadPrompt().replace("{cards}", cardsText); ChatClient client = llmRouter.route(provider); @@ -35,23 +39,25 @@ public JsonNode extractPatternsRawJson(String cardsText) { .temperature(0.0) .build(); - String content; + ChatResponse response; try { - content = client.prompt() + response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_PATTERN_EXTRACT_NO_RESPONSE, e); } + String content = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_PATTERN_EXTRACT_NO_RESPONSE); } try { - return objectMapper.readTree(content); + return new LlmGenerationResult<>(objectMapper.readTree(content), tokenUsage); } catch (AiResponseParseException e) { throw e; } catch (Exception e) { @@ -59,4 +65,11 @@ public JsonNode extractPatternsRawJson(String cardsText) { throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); } } + + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java index 2d3f37b9..8c8009a4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java @@ -5,8 +5,11 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.devkor.ifive.nadab.global.shared.reportcontent.Mark; import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; @@ -20,6 +23,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.google.genai.GoogleGenAiChatModel; import org.springframework.ai.google.genai.GoogleGenAiChatOptions; import org.springframework.stereotype.Component; @@ -56,7 +60,7 @@ public class TypeReportLlmClient { "패턴", "분석", "데이터", "기록", "습관", "장점", "단점", "모습", "결국", "보여집니다", "확인됩니다" }; - public JsonNode generateRaw(String selectedType, String patterns, String evidenceCards, String emotionStats) { + public LlmGenerationResult generateRaw(String selectedType, String patterns, String evidenceCards, String emotionStats) { if (blank(selectedType) || blank(patterns) || blank(evidenceCards) || blank(emotionStats)) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_GENERATE_INPUT_EMPTY); } @@ -75,16 +79,18 @@ public JsonNode generateRaw(String selectedType, String patterns, String evidenc .temperature(0.0) .build(); - String content; + ChatResponse response; try { - content = client.prompt() + response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } + String content = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); @@ -95,7 +101,7 @@ public JsonNode generateRaw(String selectedType, String patterns, String evidenc if (raw.isObject()) { hydrateTypeAnalysisIfMissing((ObjectNode) raw); } - return raw; + return new LlmGenerationResult<>(raw, tokenUsage); } catch (AiResponseParseException e) { throw e; } catch (Exception e) { @@ -103,11 +109,11 @@ public JsonNode generateRaw(String selectedType, String patterns, String evidenc } } - public JsonNode rewriteOnly(JsonNode raw) { + public LlmGenerationResult rewriteOnly(JsonNode raw) { return enforceLength(raw); } - private JsonNode enforceLength(JsonNode raw) { + private LlmGenerationResult enforceLength(JsonNode raw) { if (raw == null || raw.isNull() || !raw.isObject()) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_REWRITE_INPUT_SCHEMA_INVALID); } @@ -116,14 +122,14 @@ private JsonNode enforceLength(JsonNode raw) { JsonNode personasNode = root.get("personas"); if (personasNode == null || !personasNode.isArray()) { - return root; + return new LlmGenerationResult<>(root, LlmTokenUsage.empty()); } validateRichContentsIfPresent(root); TypeTextContent sourceTypeAnalysisContent = parseTypeTextContentOrFallback(root.get("typeAnalysisContent"), ""); String typeAnalysis = sourceTypeAnalysisContent.plainText().trim(); if (blank(typeAnalysis)) { - return root; + return new LlmGenerationResult<>(root, LlmTokenUsage.empty()); } root.put("typeAnalysis", typeAnalysis); ArrayNode personasArr = (ArrayNode) personasNode; @@ -140,13 +146,17 @@ private JsonNode enforceLength(JsonNode raw) { badP2 = isOutOfRange(getPersonaContent(personasArr.get(1)).length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT); } - if (!badTA && !badP1 && !badP2) return root; + if (!badTA && !badP1 && !badP2) return new LlmGenerationResult<>(root, LlmTokenUsage.empty()); ChatClient rewriteClient = llmRouter.route(REWRITE_PROVIDER); + LlmTokenUsage tokenUsage = LlmTokenUsage.empty(); if (badTA) { log.debug("[TypeReportRewrite] typeAnalysis invalid. len={}, twoParagraph={}", typeAnalysis.length(), validTwoParagraphs(typeAnalysis)); - StyledText rewritten = rewriteTypeAnalysis(rewriteClient, sourceTypeAnalysisContent.styledText()); + LlmGenerationResult rewriteResult = + rewriteTypeAnalysis(rewriteClient, sourceTypeAnalysisContent.styledText()); + StyledText rewritten = rewriteResult.content(); + tokenUsage = tokenUsage.plus(rewriteResult.tokenUsage()); validateStyledText(rewritten, true); TypeTextContent rewrittenContent = new TypeTextContent(rewritten).normalized(); root.set("typeAnalysisContent", objectMapper.valueToTree(rewrittenContent)); @@ -157,14 +167,18 @@ private JsonNode enforceLength(JsonNode raw) { ObjectNode p0 = (ObjectNode) personasArr.get(0); String in = safeText(p0.get("content")); log.debug("[TypeReportRewrite] personas[0].content invalid. len={}", in.length()); - p0.put("content", rewritePersonaContent(rewriteClient, in, 1)); + LlmGenerationResult rewriteResult = rewritePersonaContent(rewriteClient, in, 1); + p0.put("content", rewriteResult.content()); + tokenUsage = tokenUsage.plus(rewriteResult.tokenUsage()); } if (personasArr.size() >= 2 && badP2 && personasArr.get(1).isObject()) { ObjectNode p1 = (ObjectNode) personasArr.get(1); String in = safeText(p1.get("content")); log.debug("[TypeReportRewrite] personas[1].content invalid. len={}", in.length()); - p1.put("content", rewritePersonaContent(rewriteClient, in, 2)); + LlmGenerationResult rewriteResult = rewritePersonaContent(rewriteClient, in, 2); + p1.put("content", rewriteResult.content()); + tokenUsage = tokenUsage.plus(rewriteResult.tokenUsage()); } // ===== rewrite 후 검증(원인별 코드로) ===== @@ -195,7 +209,7 @@ private JsonNode enforceLength(JsonNode raw) { } } - return root; + return new LlmGenerationResult<>(root, tokenUsage); } private void validateRichContentsIfPresent(ObjectNode root) { @@ -273,7 +287,7 @@ private void validateStyledText(StyledText styledText, boolean isTypeAnalysis) { } } - private StyledText rewriteTypeAnalysis(ChatClient client, StyledText in) { + private LlmGenerationResult rewriteTypeAnalysis(ChatClient client, StyledText in) { StyledText inputStyled = in == null ? new StyledText(List.of(new Segment("", List.of()))) : in.normalized(); String jsonInput; try { @@ -312,12 +326,14 @@ private StyledText rewriteTypeAnalysis(ChatClient client, StyledText in) { .temperature(0.0) .build(); - String out; + ChatResponse response; try { - out = client.prompt().user(prompt).options(options).call().content(); + response = client.prompt().user(prompt).options(options).call().chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE, e); } + String out = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE); @@ -328,7 +344,7 @@ private StyledText rewriteTypeAnalysis(ChatClient client, StyledText in) { if (rewritten == null || rewritten.segments() == null || rewritten.segments().isEmpty()) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_REWRITE_OUTPUT_EMPTY); } - return rewritten; + return new LlmGenerationResult<>(rewritten, tokenUsage); } catch (AiResponseParseException e) { throw e; } catch (JsonParseException e) { @@ -369,7 +385,7 @@ private void hydrateTypeAnalysisIfMissing(ObjectNode root) { } } - private String rewritePersonaContent(ChatClient client, String in, int personaIndex) { + private LlmGenerationResult rewritePersonaContent(ChatClient client, String in, int personaIndex) { String prompt = """ 아래 'personas[%d].content' 본문은 의미는 유지하되 글자수 규칙을 맞춰야 해요. 의미는 유지하면서 글자수(공백 포함)를 최소 %d자 ~ 최대 %d자로 맞춰서 다시 써줘요. @@ -406,12 +422,14 @@ private String rewritePersonaContent(ChatClient client, String in, int personaIn .temperature(0.0) .build(); - String out; + ChatResponse response; try { - out = client.prompt().user(prompt).options(options).call().content(); + response = client.prompt().user(prompt).options(options).call().chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE, e); } + String out = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE); @@ -421,7 +439,7 @@ private String rewritePersonaContent(ChatClient client, String in, int personaIn JsonNode node = objectMapper.readTree(out); String text = safeText(node.get("text")).trim(); if (blank(text)) throw new AiResponseParseException(ErrorCode.TYPE_REPORT_REWRITE_OUTPUT_EMPTY); - return text; + return new LlmGenerationResult<>(text, tokenUsage); } catch (AiResponseParseException e) { throw e; } catch (JsonParseException e) { @@ -459,6 +477,13 @@ private String safeText(JsonNode node) { return node.asText(); } + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } + private boolean blank(String s) { return s == null || s.isBlank(); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java index a1fe2265..1c7911f1 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java @@ -5,12 +5,16 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.stereotype.Component; @@ -25,7 +29,7 @@ public class TypeSelectLlmClient { private final LlmProvider provider = LlmProvider.OPENAI; - public JsonNode selectTypeRawJson(String candidatesText, String patternsText) { + public LlmGenerationResult selectTypeRawJson(String candidatesText, String patternsText) { if (candidatesText == null || candidatesText.isBlank() || patternsText == null || patternsText.isBlank()) { throw new AiResponseParseException(ErrorCode.TYPE_SELECT_INPUT_EMPTY); } @@ -41,27 +45,36 @@ public JsonNode selectTypeRawJson(String candidatesText, String patternsText) { .temperature(0.0) .build(); - String content; + ChatResponse response; try { - content = client.prompt() + response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_TYPE_SELECT_NO_RESPONSE, e); } + String content = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_TYPE_SELECT_NO_RESPONSE); } try { - return objectMapper.readTree(content); + return new LlmGenerationResult<>(objectMapper.readTree(content), tokenUsage); } catch (AiResponseParseException e) { throw e; } catch (Exception e) { throw new AiResponseParseException(ErrorCode.TYPE_SELECT_JSON_MISSING_FIELDS); } } + + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/UserWallet.java b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/UserWallet.java index 50c370ea..8eff3889 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/UserWallet.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/UserWallet.java @@ -34,9 +34,13 @@ public class UserWallet extends AuditableEntity { private long version; public static UserWallet create(User user) { + return create(user, 0L); + } + + public static UserWallet create(User user, long initialCrystalBalance) { UserWallet wallet = new UserWallet(); wallet.user = user; - wallet.crystalBalance = 0L; + wallet.crystalBalance = initialCrystalBalance; return wallet; } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java index 1e0a11f5..3f045da5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java @@ -10,7 +10,9 @@ import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportGenerationRequestedEventDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyQueryRepository; import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; @@ -58,7 +60,17 @@ public void handle(WeeklyReportGenerationRequestedEventDto event) { ); try { // 트랜잭션 밖(백그라운드)에서 LLM 호출 - dto = weeklyReportLlmClient.generate(range.weekStartDate().toString(), range.weekEndDate().toString(), entries); + LlmGenerationResult generationResult = + weeklyReportLlmClient.generate(range.weekStartDate().toString(), range.weekEndDate().toString(), entries); + dto = generationResult.content(); + LlmTokenUsage tokenUsage = generationResult.tokenUsage(); + reportGenerationLogRecorder.recordTokenUsage( + generationLogId, + tokenUsage.inputTokens(), + tokenUsage.outputTokens(), + tokenUsage.totalTokens(), + tokenUsage.thinkingTokens() + ); reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { reportGenerationLogRecorder.fail(generationLogId, e); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java index e3dd40f2..ba016275 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java @@ -5,8 +5,11 @@ import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsageExtractor; import com.devkor.ifive.nadab.global.shared.reportcontent.*; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -14,6 +17,7 @@ import org.springframework.ai.anthropic.AnthropicChatOptions; import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.google.genai.GoogleGenAiChatModel; import org.springframework.ai.google.genai.GoogleGenAiChatOptions; import org.springframework.ai.openai.OpenAiChatOptions; @@ -52,7 +56,7 @@ public class WeeklyReportLlmClient { * @param weekEndDate 예: 2026-01-07 * @param entries WeeklyEntriesAssembler 결과 문자열 */ - public AiReportResultDto generate(String weekStartDate, String weekEndDate, String entries) { + public LlmGenerationResult generate(String weekStartDate, String weekEndDate, String entries) { if (isBlank(entries)) { throw new AiResponseParseException(ErrorCode.WEEKLY_REPORT_INPUT_ENTRIES_EMPTY); @@ -65,11 +69,13 @@ public AiReportResultDto generate(String weekStartDate, String weekEndDate, Stri ChatClient client = llmRouter.route(provider); - String content = switch (provider) { + LlmGenerationResult generationResult = switch (provider) { case OPENAI -> callOpenAi(client, prompt); case CLAUDE -> callClaude(client, prompt); case GEMINI -> callGemini(client, prompt); }; + String content = generationResult.content(); + LlmTokenUsage tokenUsage = generationResult.tokenUsage(); if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); @@ -108,12 +114,15 @@ public AiReportResultDto generate(String weekStartDate, String weekEndDate, Stri } // 길이 맞추기(StyledText 자체 리라이트) - AiReportResultDto dto = enforceLength(new AiReportResultDto(reportContent, discovered, improve)); + LlmGenerationResult lengthResult = + enforceLength(new AiReportResultDto(reportContent, discovered, improve)); + AiReportResultDto dto = lengthResult.content(); + tokenUsage = tokenUsage.plus(lengthResult.tokenUsage()); // 최종 길이 검증 validateLength(dto); - return dto; + return new LlmGenerationResult<>(dto, tokenUsage); } catch (AiResponseParseException e) { throw e; @@ -123,7 +132,7 @@ public AiReportResultDto generate(String weekStartDate, String weekEndDate, Stri } } - private String callOpenAi(ChatClient client, String prompt) { + private LlmGenerationResult callOpenAi(ChatClient client, String prompt) { OpenAiChatOptions options = OpenAiChatOptions.builder() .model(OpenAiApi.ChatModel.GPT_5_MINI) .reasoningEffort("medium") @@ -131,34 +140,36 @@ private String callOpenAi(ChatClient client, String prompt) { .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private String callClaude(ChatClient client, String prompt) { + private LlmGenerationResult callClaude(ChatClient client, String prompt) { AnthropicChatOptions options = AnthropicChatOptions.builder() .model(AnthropicApi.ChatModel.CLAUDE_3_HAIKU) .temperature(0.3) .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private String callGemini(ChatClient client, String prompt) { + private LlmGenerationResult callGemini(ChatClient client, String prompt) { GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder() .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_5_FLASH) .responseMimeType("application/json") @@ -166,17 +177,18 @@ private String callGemini(ChatClient client, String prompt) { .build(); try { - return client.prompt() + ChatResponse response = client.prompt() .user(prompt) .options(options) .call() - .content(); + .chatResponse(); + return new LlmGenerationResult<>(extractContent(response), LlmTokenUsageExtractor.extract(response)); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } } - private AiReportResultDto enforceLength(AiReportResultDto dto) { + private LlmGenerationResult enforceLength(AiReportResultDto dto) { ReportContent c = dto.content(); int dLen = dto.discovered().length(); @@ -185,15 +197,24 @@ private AiReportResultDto enforceLength(AiReportResultDto dto) { boolean badD = dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED; boolean badI = iLen < MIN_IMPROVE || iLen > MAX_IMPROVE; - if (!badD && !badI) return dto; + if (!badD && !badI) return new LlmGenerationResult<>(dto, LlmTokenUsage.empty()); ChatClient rewriteClient = llmRouter.route(REWRITE_PROVIDER); StyledText d = c.discovered(); StyledText i = c.improve(); + LlmTokenUsage tokenUsage = LlmTokenUsage.empty(); - if (badD) d = rewriteStyled(rewriteClient, d, true); - if (badI) i = rewriteStyled(rewriteClient, i, false); + if (badD) { + LlmGenerationResult rewriteResult = rewriteStyled(rewriteClient, d, true); + d = rewriteResult.content(); + tokenUsage = tokenUsage.plus(rewriteResult.tokenUsage()); + } + if (badI) { + LlmGenerationResult rewriteResult = rewriteStyled(rewriteClient, i, false); + i = rewriteResult.content(); + tokenUsage = tokenUsage.plus(rewriteResult.tokenUsage()); + } ReportContent newContent = new ReportContent(c.summary(), d, i); String newD = newContent.discovered().plainText(); @@ -206,10 +227,10 @@ private AiReportResultDto enforceLength(AiReportResultDto dto) { throw new AiResponseParseException(ErrorCode.WEEKLY_REPORT_REWRITE_FORMAT_INVALID); } - return new AiReportResultDto(newContent, newD, newI); + return new LlmGenerationResult<>(new AiReportResultDto(newContent, newD, newI), tokenUsage); } - private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDiscovered) { + private LlmGenerationResult rewriteStyled(ChatClient client, StyledText in, boolean isDiscovered) { int min = isDiscovered ? MIN_DISCOVERED : MIN_IMPROVE; int max = isDiscovered ? MAX_DISCOVERED : MAX_IMPROVE; int maxHl = isDiscovered ? MAX_HL_DISCOVERED : MAX_HL_IMPROVE; @@ -242,19 +263,21 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis .temperature(0.0) .build(); - String out; + ChatResponse response; try { - out = client.prompt().user(prompt).options(options).call().content(); + response = client.prompt().user(prompt).options(options).call().chatResponse(); } catch (Exception e) { throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_REWRITE_NO_RESPONSE, e); } + String out = extractContent(response); + LlmTokenUsage tokenUsage = LlmTokenUsageExtractor.extract(response); if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.AI_REWRITE_NO_RESPONSE); } try { - return objectMapper.readValue(out, StyledText.class); + return new LlmGenerationResult<>(objectMapper.readValue(out, StyledText.class), tokenUsage); } catch (Exception e) { throw new AiResponseParseException(ErrorCode.WEEKLY_REPORT_REWRITE_JSON_MAPPING_FAILED); } @@ -267,6 +290,13 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis } } + private String extractContent(ChatResponse response) { + if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { + return null; + } + return response.getResult().getOutput().getText(); + } + private boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java index 12a48576..5b5abeb7 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java @@ -17,6 +17,7 @@ public class LocalMonthlyReportPromptLoader implements MonthlyReportPromptLoader private static final String V1_PROMPT_PATH = "secret/monthly-prompt-v1-local.txt"; private static final String V2_BASELINE_PROMPT_PATH = "secret/monthly-prompt-v2-baseline-local.txt"; + private static final String V2_COMPARISON_PROMPT_PATH = "secret/monthly-prompt-v2-comparison-local.txt"; @Override public String loadV1Prompt() { @@ -57,30 +58,22 @@ public String loadV2BaselinePrompt() { } @Override - public String loadImagePrompt() { - return """ - Create a premium abstract monthly self-reflection cover image for a Korean journaling app. - - Report context: - - Monthly summary: %s - - Gentle comment summary: %s - - Dominant keyword: %s - - Month: %s to %s - - Task: - Create a symbolic, abstract cover image that represents the emotional meaning of this monthly report. Do not use a fixed scene. Choose objects, colors, light, space, and composition that naturally match the report's theme. - - The image should feel calm, warm, reflective, comforting, refined, and suitable for a premium mobile app monthly report card. - - Style: - Premium editorial illustration, modern app-friendly aesthetic, soft lighting, subtle texture, refined color palette, clean composition, enough negative space. - - Strict constraints: - No people, no faces, no hands, no body parts. - No readable text, no letters, no Korean characters, no English characters, no numbers, no symbols, no handwriting, no typography. - No UI, no logos, no brand marks, no watermarks. - No medical, therapy, counseling, hospital, or diagnosis-related imagery. - No dark, horror, gloomy, childish, or overly dramatic mood. - """; + public String loadV2ComparisonPrompt() { + try { + ClassPathResource resource = new ClassPathResource(V2_COMPARISON_PROMPT_PATH); + + if (!resource.exists()) { + log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", V2_COMPARISON_PROMPT_PATH); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_NOT_FOUND); + } + + byte[] bytes = resource.getContentAsByteArray(); + return new String(bytes, StandardCharsets.UTF_8); + + } catch (IOException e) { + log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", V2_COMPARISON_PROMPT_PATH, e); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_READ_FAILED); + } } + } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java index 68898a41..f2050b4b 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java @@ -5,5 +5,5 @@ public interface MonthlyReportPromptLoader { String loadV2BaselinePrompt(); - String loadImagePrompt(); + String loadV2ComparisonPrompt(); } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java index 728368a2..e1ee5575 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java @@ -20,6 +20,9 @@ public class SecretMonthlyReportPromptLoader implements MonthlyReportPromptLoade @Value("${MONTHLY_V2_BASELINE_PROMPT}") private String monthlyV2BaselinePrompt; + @Value("${MONTHLY_V2_COMPARISON_PROMPT}") + private String monthlyV2ComparisonPrompt; + @Override public String loadV1Prompt() { if (monthlyV1Prompt == null || monthlyV1Prompt.isBlank()) { @@ -41,30 +44,13 @@ public String loadV2BaselinePrompt() { } @Override - public String loadImagePrompt() { - return """ - Create a premium abstract monthly self-reflection cover image for a Korean journaling app. - - Report context: - - Monthly summary: %s - - Gentle comment summary: %s - - Dominant keyword: %s - - Month: %s to %s - - Task: - Create a symbolic, abstract cover image that represents the emotional meaning of this monthly report. Do not use a fixed scene. Choose objects, colors, light, space, and composition that naturally match the report's theme. - - The image should feel calm, warm, reflective, comforting, refined, and suitable for a premium mobile app monthly report card. - - Style: - Premium editorial illustration, modern app-friendly aesthetic, soft lighting, subtle texture, refined color palette, clean composition, enough negative space. - - Strict constraints: - No people, no faces, no hands, no body parts. - No readable text, no letters, no Korean characters, no English characters, no numbers, no symbols, no handwriting, no typography. - No UI, no logos, no brand marks, no watermarks. - No medical, therapy, counseling, hospital, or diagnosis-related imagery. - No dark, horror, gloomy, childish, or overly dramatic mood. - """; + public String loadV2ComparisonPrompt() { + if (monthlyV2ComparisonPrompt == null || monthlyV2ComparisonPrompt.isBlank()) { + log.error("환경 변수 MONTHLY_V2_COMPARISON_PROMPT가 비어있습니다."); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_ENV_VAR_NOT_SET); + } + + return monthlyV2ComparisonPrompt; } + } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index 398ee556..f2a0d08e 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -189,6 +189,8 @@ public enum ErrorCode { MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID(HttpStatus.BAD_GATEWAY, "월간 리포트 discovered 길이가 제한을 만족하지 않습니다"), MONTHLY_REPORT_IMPROVE_LENGTH_INVALID(HttpStatus.BAD_GATEWAY, "월간 리포트 improve 길이가 제한을 만족하지 않습니다"), + MONTHLY_REPORT_COMMENT_LENGTH_INVALID(HttpStatus.BAD_GATEWAY, "월간 리포트 comment 길이가 제한을 만족하지 않습니다"), + MONTHLY_REPORT_EMOTION_SUMMARY_LENGTH_INVALID(HttpStatus.BAD_GATEWAY, "월간 리포트 emotionSummaryContent 길이가 제한을 만족하지 않습니다"), MONTHLY_REPORT_TOTAL_LENGTH_INVALID(HttpStatus.BAD_GATEWAY, "월간 리포트 총 길이가 제한을 만족하지 않습니다"), MONTHLY_REPORT_REWRITE_JSON_MAPPING_FAILED(HttpStatus.BAD_GATEWAY, "월간 리포트 리라이트 응답 JSON을 매핑할 수 없습니다"), @@ -430,8 +432,12 @@ public enum ErrorCode { APP_VERSION_NOT_FOUND(HttpStatus.NOT_FOUND, "앱 버전을 찾을 수 없습니다"), APP_VERSION_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "앱 버전 항목을 찾을 수 없습니다"), + // 400 Bad Request + APP_VERSION_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "앱 버전은 major.minor.patch 형식이어야 합니다"), + // 409 Conflict APP_VERSION_ALREADY_EXISTS(HttpStatus.CONFLICT, "해당 플랫폼의 같은 버전이 이미 존재합니다"), + APP_VERSION_MUST_BE_GREATER_THAN_EXISTING(HttpStatus.CONFLICT, "앱 버전은 같은 플랫폼의 다른 모든 버전보다 높아야 합니다"), APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED(HttpStatus.CONFLICT, "같은 버전 내 displayOrder가 중복됩니다"), // ==================== ADMIN (어드민) ==================== diff --git a/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmGenerationResult.java b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmGenerationResult.java new file mode 100644 index 00000000..54f11697 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmGenerationResult.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.global.infra.llm; + +public record LlmGenerationResult( + T content, + LlmTokenUsage tokenUsage +) { + + public LlmGenerationResult { + if (tokenUsage == null) { + tokenUsage = LlmTokenUsage.empty(); + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsage.java b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsage.java new file mode 100644 index 00000000..afc2c162 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsage.java @@ -0,0 +1,39 @@ +package com.devkor.ifive.nadab.global.infra.llm; + +public record LlmTokenUsage( + Long inputTokens, + Long outputTokens, + Long totalTokens, + Long thinkingTokens +) { + + public LlmTokenUsage(Long inputTokens, Long outputTokens, Long totalTokens) { + this(inputTokens, outputTokens, totalTokens, null); + } + + public static LlmTokenUsage empty() { + return new LlmTokenUsage(null, null, null, null); + } + + public LlmTokenUsage plus(LlmTokenUsage other) { + if (other == null) { + return this; + } + return new LlmTokenUsage( + add(inputTokens, other.inputTokens), + add(outputTokens, other.outputTokens), + add(totalTokens, other.totalTokens), + add(thinkingTokens, other.thinkingTokens) + ); + } + + private Long add(Long left, Long right) { + if (left == null) { + return right; + } + if (right == null) { + return left; + } + return left + right; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractor.java b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractor.java new file mode 100644 index 00000000..bc27f716 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractor.java @@ -0,0 +1,43 @@ +package com.devkor.ifive.nadab.global.infra.llm; + +import org.springframework.ai.chat.metadata.EmptyUsage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; + +public final class LlmTokenUsageExtractor { + + private LlmTokenUsageExtractor() { + } + + public static LlmTokenUsage extract(ChatResponse response) { + if (response == null || response.getMetadata() == null) { + return LlmTokenUsage.empty(); + } + return extract(response.getMetadata().getUsage()); + } + + public static LlmTokenUsage extract(Usage usage) { + if (usage == null || usage instanceof EmptyUsage) { + return LlmTokenUsage.empty(); + } + + return new LlmTokenUsage( + toLong(usage.getPromptTokens()), + toLong(usage.getCompletionTokens()), + toLong(usage.getTotalTokens()), + extractThinkingTokens(usage) + ); + } + + private static Long extractThinkingTokens(Usage usage) { + if (usage instanceof GoogleGenAiUsage googleUsage) { + return toLong(googleUsage.getThoughtsTokenCount()); + } + return null; + } + + private static Long toLong(Integer value) { + return value == null ? null : value.longValue(); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3a62b47a..69cd4012 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -63,5 +63,6 @@ cors: - http://localhost:5173 - ${FRONTEND_DEV_URL} - ${FRONTEND_DEV_URL_2} + - ${FRONTEND_DEV_URL_3} - ${SWAGGER_ORIGIN_PUBLIC_IP} - ${SWAGGER_ORIGIN_DOMAIN} diff --git a/src/main/resources/db/migration/V20260618_1200__IS_add_emotion_comparison_to_monthly_reports_v2.sql b/src/main/resources/db/migration/V20260618_1200__IS_add_emotion_comparison_to_monthly_reports_v2.sql new file mode 100644 index 00000000..e880562b --- /dev/null +++ b/src/main/resources/db/migration/V20260618_1200__IS_add_emotion_comparison_to_monthly_reports_v2.sql @@ -0,0 +1,2 @@ +ALTER TABLE monthly_reports_v2 + ADD COLUMN emotion_comparison JSONB; diff --git a/src/main/resources/db/migration/V20260628_1200__IS_add_social_summary_to_monthly_reports_v2.sql b/src/main/resources/db/migration/V20260628_1200__IS_add_social_summary_to_monthly_reports_v2.sql new file mode 100644 index 00000000..a98bdba2 --- /dev/null +++ b/src/main/resources/db/migration/V20260628_1200__IS_add_social_summary_to_monthly_reports_v2.sql @@ -0,0 +1,17 @@ +ALTER TABLE monthly_reports_v2 + ADD COLUMN social_summary JSONB NOT NULL DEFAULT '{ + "visible": false, + "month": 1, + "likeRanking": [], + "commentRanking": [] + }'::jsonb; + +UPDATE monthly_reports_v2 +SET social_summary = jsonb_set( + social_summary, + '{month}', + to_jsonb(EXTRACT(MONTH FROM month_start_date)::int) + ); + +ALTER TABLE monthly_reports_v2 + ALTER COLUMN social_summary DROP DEFAULT; diff --git a/src/main/resources/db/migration/V20260629_1200__IS_add_image_prompt_variant_to_monthly_reports_v2.sql b/src/main/resources/db/migration/V20260629_1200__IS_add_image_prompt_variant_to_monthly_reports_v2.sql new file mode 100644 index 00000000..b63e7fc6 --- /dev/null +++ b/src/main/resources/db/migration/V20260629_1200__IS_add_image_prompt_variant_to_monthly_reports_v2.sql @@ -0,0 +1,2 @@ +ALTER TABLE monthly_reports_v2 + ADD COLUMN image_prompt_variant VARCHAR(40); diff --git a/src/main/resources/db/migration/V20260629_1700__IS_add_image_color_palette_to_monthly_reports_v2.sql b/src/main/resources/db/migration/V20260629_1700__IS_add_image_color_palette_to_monthly_reports_v2.sql new file mode 100644 index 00000000..ad0923c3 --- /dev/null +++ b/src/main/resources/db/migration/V20260629_1700__IS_add_image_color_palette_to_monthly_reports_v2.sql @@ -0,0 +1,2 @@ +ALTER TABLE monthly_reports_v2 + ADD COLUMN image_color_palette VARCHAR(40); diff --git a/src/main/resources/db/migration/V20260708_1200__IS_add_token_usage_to_report_generation_logs.sql b/src/main/resources/db/migration/V20260708_1200__IS_add_token_usage_to_report_generation_logs.sql new file mode 100644 index 00000000..b03e59e7 --- /dev/null +++ b/src/main/resources/db/migration/V20260708_1200__IS_add_token_usage_to_report_generation_logs.sql @@ -0,0 +1,4 @@ +ALTER TABLE report_generation_logs + ADD COLUMN input_tokens BIGINT, + ADD COLUMN output_tokens BIGINT, + ADD COLUMN total_tokens BIGINT; diff --git a/src/main/resources/db/migration/V20260708_1500__IS_add_thinking_tokens_to_report_generation_logs.sql b/src/main/resources/db/migration/V20260708_1500__IS_add_thinking_tokens_to_report_generation_logs.sql new file mode 100644 index 00000000..bb0421fa --- /dev/null +++ b/src/main/resources/db/migration/V20260708_1500__IS_add_thinking_tokens_to_report_generation_logs.sql @@ -0,0 +1,2 @@ +ALTER TABLE report_generation_logs + ADD COLUMN thinking_tokens BIGINT; diff --git a/src/main/resources/templates/admin/version-history.html b/src/main/resources/templates/admin/version-history.html new file mode 100644 index 00000000..b6060c30 --- /dev/null +++ b/src/main/resources/templates/admin/version-history.html @@ -0,0 +1,488 @@ + + + + + + NADAB Admin Version History + + + +
+
+

Admin Console

+ +
+ + + +
+
+

Version History

+ Back to App Versions +
+
+
+ + +
+
+ +
+
+
+
+
+ + + + + + diff --git a/src/main/resources/templates/admin/version.html b/src/main/resources/templates/admin/version.html index db82efe4..2460a529 100644 --- a/src/main/resources/templates/admin/version.html +++ b/src/main/resources/templates/admin/version.html @@ -78,6 +78,19 @@ color: var(--muted); } + .panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; + } + + .panel-head .panel-title { + margin: 0; + } + .grid { display: grid; gap: 10px; @@ -177,6 +190,24 @@ font-size: 15px; } + .version-editor { + display: grid; + grid-template-columns: minmax(140px, 220px) auto; + gap: 8px; + align-items: end; + } + + .version-editor.confirmed-ui input { + opacity: 0.58; + } + + @media (max-width: 640px) { + .version-editor { + grid-template-columns: 1fr; + width: 100%; + } + } + .items-table { width: 100%; border-collapse: collapse; @@ -287,7 +318,7 @@

Create Version

- +
@@ -300,7 +331,10 @@

Create Version

-

Current Latest Versions

+
+

Current Latest Versions

+ View History +
@@ -449,7 +483,18 @@ versionListEl.innerHTML = versions.map((version) => `
- ${escapeHtml(version.platform)} / ${escapeHtml(version.version)} + ${escapeHtml(version.platform)} +
+
+ + +
+
+ + + +
+
@@ -509,6 +554,27 @@ } } + function toggleVersionEditMode(versionEditor, editing) { + const versionInput = versionEditor.querySelector('.version-value'); + const editBtn = versionEditor.querySelector('[data-action="edit-version"]'); + const saveBtn = versionEditor.querySelector('[data-action="save-version"]'); + const cancelBtn = versionEditor.querySelector('[data-action="cancel-version"]'); + + versionInput.disabled = !editing; + editBtn.classList.toggle('is-hidden', editing); + saveBtn.classList.toggle('is-hidden', !editing); + cancelBtn.classList.toggle('is-hidden', !editing); + + if (editing) { + versionEditor.classList.remove('confirmed-ui'); + versionEditor.classList.add('draft-ui'); + versionInput.focus(); + } else { + versionEditor.classList.remove('draft-ui'); + versionEditor.classList.add('confirmed-ui'); + } + } + function toggleItemEditMode(row, editing) { const titleInput = row.querySelector('.item-title'); const descriptionInput = row.querySelector('.item-description'); @@ -561,6 +627,12 @@ summaryEditor.classList.add('draft-ui'); } + const versionEditor = target.closest('.version-editor'); + if (versionEditor) { + versionEditor.classList.remove('confirmed-ui'); + versionEditor.classList.add('draft-ui'); + } + const row = target.closest('tr[data-item-id], tr[data-new-item]'); if (row) { row.classList.remove('confirmed-ui'); @@ -610,6 +682,32 @@ const appVersionId = Number(versionCard.dataset.versionId); try { + if (action === 'edit-version') { + const versionEditor = versionCard.querySelector('.version-editor'); + toggleVersionEditMode(versionEditor, true); + return; + } + + if (action === 'cancel-version') { + const versionEditor = versionCard.querySelector('.version-editor'); + const versionInput = versionEditor.querySelector('.version-value'); + versionInput.value = versionInput.dataset.original ?? ''; + toggleVersionEditMode(versionEditor, false); + return; + } + + if (action === 'save-version') { + const confirmed = await showConfirmPopup('Save version?'); + if (!confirmed) { + return; + } + const version = versionCard.querySelector('.version-value').value.trim(); + await request('PUT', `/admin/api/versions/${appVersionId}/version`, {version}); + await loadVersions(); + await showResultPopup('Version saved.'); + return; + } + if (action === 'edit-summary') { const summaryEditor = versionCard.querySelector('.summary-editor'); toggleSummaryEditMode(summaryEditor, true); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandServiceTest.java new file mode 100644 index 00000000..a2d95b76 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandServiceTest.java @@ -0,0 +1,144 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminVersionCommandServiceTest { + + @Mock + AppVersionRepository appVersionRepository; + + AdminVersionCommandService adminVersionCommandService; + + @BeforeEach + void setUp() { + adminVersionCommandService = new AdminVersionCommandService(appVersionRepository); + } + + @Test + void updateVersion_updates_when_new_version_is_greater_than_other_versions() { + AppVersion target = AppVersion.create(AppPlatform.ANDROID, "1.3.0", "summary"); + when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target)); + when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.ANDROID, "1.10.0", 1L)) + .thenReturn(false); + when(appVersionRepository.findByPlatformAndIdNot(AppPlatform.ANDROID, 1L)) + .thenReturn(List.of( + AppVersion.create(AppPlatform.ANDROID, "1.2.0", "old"), + AppVersion.create(AppPlatform.ANDROID, "1.9.0", "old") + )); + + adminVersionCommandService.updateVersion(1L, "1.10.0"); + + assertThat(target.getVersion()).isEqualTo("1.10.0"); + verify(appVersionRepository).flush(); + } + + @Test + void updateVersion_rejects_version_equal_to_highest_other_version() { + AppVersion target = AppVersion.create(AppPlatform.IOS, "1.3.0", "summary"); + when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target)); + when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.IOS, "1.2.0", 1L)) + .thenReturn(false); + when(appVersionRepository.findByPlatformAndIdNot(AppPlatform.IOS, 1L)) + .thenReturn(List.of(AppVersion.create(AppPlatform.IOS, "1.2.0", "old"))); + + assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.2.0")) + .isInstanceOfSatisfying(ConflictException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING) + ); + + assertThat(target.getVersion()).isEqualTo("1.3.0"); + verify(appVersionRepository, never()).flush(); + } + + @Test + void updateVersion_rejects_version_lower_than_highest_other_version() { + AppVersion target = AppVersion.create(AppPlatform.IOS, "1.3.0", "summary"); + when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target)); + when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.IOS, "1.1.2", 1L)) + .thenReturn(false); + when(appVersionRepository.findByPlatformAndIdNot(AppPlatform.IOS, 1L)) + .thenReturn(List.of(AppVersion.create(AppPlatform.IOS, "1.2.0", "old"))); + + assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.1.2")) + .isInstanceOfSatisfying(ConflictException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING) + ); + + assertThat(target.getVersion()).isEqualTo("1.3.0"); + verify(appVersionRepository, never()).flush(); + } + + @Test + void updateVersion_rejects_duplicate_version_before_comparing_order() { + AppVersion target = AppVersion.create(AppPlatform.ANDROID, "1.3.0", "summary"); + when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target)); + when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.ANDROID, "1.4.0", 1L)) + .thenReturn(true); + + assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.4.0")) + .isInstanceOfSatisfying(ConflictException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_ALREADY_EXISTS) + ); + + verify(appVersionRepository, never()).findByPlatformAndIdNot(AppPlatform.ANDROID, 1L); + verify(appVersionRepository, never()).flush(); + } + + @Test + void updateVersion_rejects_invalid_version_format() { + AppVersion target = AppVersion.create(AppPlatform.ANDROID, "1.3.0", "summary"); + when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target)); + when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.ANDROID, "1.4", 1L)) + .thenReturn(false); + + assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.4")) + .isInstanceOfSatisfying(BadRequestException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_INVALID_FORMAT) + ); + + verify(appVersionRepository, never()).findByPlatformAndIdNot(AppPlatform.ANDROID, 1L); + verify(appVersionRepository, never()).flush(); + } + + @Test + void createVersion_rejects_version_lower_than_existing_platform_version() { + AdminVersionCreateRequest request = new AdminVersionCreateRequest( + AppPlatform.ANDROID, + "1.1.2", + "summary" + ); + when(appVersionRepository.existsByPlatformAndVersion(AppPlatform.ANDROID, "1.1.2")) + .thenReturn(false); + when(appVersionRepository.findByPlatform(AppPlatform.ANDROID)) + .thenReturn(List.of(AppVersion.create(AppPlatform.ANDROID, "1.2.0", "old"))); + + assertThatThrownBy(() -> adminVersionCommandService.createVersion(request)) + .isInstanceOfSatisfying(ConflictException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING) + ); + + verify(appVersionRepository, never()).findByPlatformAndIsLatestTrue(AppPlatform.ANDROID); + verify(appVersionRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryServiceTest.java new file mode 100644 index 00000000..5ecdc65d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryServiceTest.java @@ -0,0 +1,84 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryResponse; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminVersionQueryServiceTest { + + @Mock + AppVersionRepository appVersionRepository; + + @Mock + AppVersionItemRepository appVersionItemRepository; + + AdminVersionQueryService adminVersionQueryService; + + @BeforeEach + void setUp() { + adminVersionQueryService = new AdminVersionQueryService( + appVersionRepository, + appVersionItemRepository + ); + } + + @Test + void getVersionHistory_returns_all_versions_with_items() { + AppVersion android = appVersion(1L, AppPlatform.ANDROID, "1.2.0", "android summary"); + AppVersion ios = appVersion(2L, AppPlatform.IOS, "1.1.0", "ios summary"); + AppVersionItem item = AppVersionItem.create(android, "Title", "Description", 1); + ReflectionTestUtils.setField(item, "id", 10L); + + when(appVersionRepository.findAllByOrderByCreatedAtDescIdDesc()) + .thenReturn(List.of(android, ios)); + when(appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(List.of(1L, 2L))) + .thenReturn(List.of(item)); + + AdminVersionHistoryResponse response = adminVersionQueryService.getVersionHistory(null); + + assertThat(response.versions()).hasSize(2); + assertThat(response.versions().get(0).id()).isEqualTo(1L); + assertThat(response.versions().get(0).items()).hasSize(1); + assertThat(response.versions().get(0).items().get(0).title()).isEqualTo("Title"); + assertThat(response.versions().get(1).id()).isEqualTo(2L); + assertThat(response.versions().get(1).items()).isEmpty(); + } + + @Test + void getVersionHistory_uses_platform_filter_when_platform_exists() { + AppVersion ios = appVersion(2L, AppPlatform.IOS, "1.1.0", "ios summary"); + when(appVersionRepository.findByPlatformOrderByCreatedAtDescIdDesc(AppPlatform.IOS)) + .thenReturn(List.of(ios)); + when(appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(List.of(2L))) + .thenReturn(List.of()); + + AdminVersionHistoryResponse response = adminVersionQueryService.getVersionHistory(AppPlatform.IOS); + + assertThat(response.versions()).hasSize(1); + assertThat(response.versions().get(0).platform()).isEqualTo(AppPlatform.IOS); + verify(appVersionRepository, never()).findAllByOrderByCreatedAtDescIdDesc(); + } + + private AppVersion appVersion(Long id, AppPlatform platform, String version, String summary) { + AppVersion appVersion = AppVersion.create(platform, version, summary); + ReflectionTestUtils.setField(appVersion, "id", id); + return appVersion; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java new file mode 100644 index 00000000..b72ed0d7 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java @@ -0,0 +1,194 @@ +package com.devkor.ifive.nadab.domain.dailyreport.application; + +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.DailyReportRequest; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse; +import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto; +import com.devkor.ifive.nadab.domain.dailyreport.core.dto.ConfirmDailyAndRewardDto; +import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.Emotion; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode; +import com.devkor.ifive.nadab.domain.dailyreport.infra.DailyReportLlmClient; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRepository; +import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository; +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.user.core.service.ProfileImageService; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DailyReportServiceTest { + + @Mock + UserRepository userRepository; + + @Mock + DailyQuestionRepository dailyQuestionRepository; + + @Mock + UserDailyQuestionRepository userDailyQuestionRepository; + + @Mock + DailyReportTxService dailyReportTxService; + + @Mock + ProfileImageService profileImageService; + + @Mock + DailyReportLlmClient dailyReportLlmClient; + + @Mock + ReportGenerationLogRecorder reportGenerationLogRecorder; + + @Mock + ApplicationEventPublisher eventPublisher; + + @Mock + ProfileImageUrlBuilder profileImageUrlBuilder; + + DailyReportService dailyReportService; + + @BeforeEach + void setUp() { + dailyReportService = new DailyReportService( + userRepository, + dailyQuestionRepository, + userDailyQuestionRepository, + dailyReportTxService, + profileImageService, + dailyReportLlmClient, + reportGenerationLogRecorder, + eventPublisher, + profileImageUrlBuilder + ); + ReflectionTestUtils.setField(dailyReportService, "env", "test"); + } + + @Test + void generate_daily_report_records_token_usage_before_succeeding_generation_log() { + // given + Long userId = 1L; + Long reportId = 100L; + Long generationLogId = 10L; + User user = user(userId); + DailyQuestion question = dailyQuestion(20L); + AnswerEntry answerEntry = AnswerEntry.create(user, question, "answer", LocalDate.now(), null); + AiDailyReportResultDto aiResult = new AiDailyReportResultDto("message", "ACHIEVEMENT"); + Emotion emotion = emotion(EmotionCode.ACHIEVEMENT); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question)); + when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class))) + .thenReturn(Optional.of(UserDailyQuestion.create(user, LocalDate.now(), question))); + when(dailyReportTxService.prepareDaily(user, question, "answer", false, null)) + .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId)); + when(reportGenerationLogRecorder.start( + userId, + ReportGenerationType.DAILY, + reportId, + ReportGenerationStep.DAILY_GENERATE, + LlmProvider.OPENAI, + "GPT_4_O_MINI" + )).thenReturn(generationLogId); + when(dailyReportLlmClient.generate("question", answerEntry)) + .thenReturn(new LlmGenerationResult<>(aiResult, new LlmTokenUsage(100L, 50L, 150L))); + when(dailyReportTxService.confirmDailyAndReward( + any(PrepareDailyResultDto.class), + eq(aiResult), + eq(null) + )).thenReturn(new ConfirmDailyAndRewardDto(emotion, 110L)); + + // when + CreateDailyReportResponse response = dailyReportService.generateDailyReport( + userId, + new DailyReportRequest(20L, "answer", null, null) + ); + + // then + assertThat(response.reportId()).isEqualTo(reportId); + assertThat(response.content()).isEqualTo("message"); + assertThat(response.balanceAfter()).isEqualTo(110L); + + InOrder inOrder = inOrder(reportGenerationLogRecorder); + inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(generationLogId, 100L, 50L, 150L, null); + inOrder.verify(reportGenerationLogRecorder).succeed(generationLogId); + } + + @Test + void generate_daily_report_records_null_token_usage_when_usage_is_empty() { + // given + Long userId = 1L; + Long reportId = 100L; + Long generationLogId = 10L; + User user = user(userId); + DailyQuestion question = dailyQuestion(20L); + AnswerEntry answerEntry = AnswerEntry.create(user, question, "answer", LocalDate.now(), null); + AiDailyReportResultDto aiResult = new AiDailyReportResultDto("message", "ACHIEVEMENT"); + Emotion emotion = emotion(EmotionCode.ACHIEVEMENT); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question)); + when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class))) + .thenReturn(Optional.of(UserDailyQuestion.create(user, LocalDate.now(), question))); + when(dailyReportTxService.prepareDaily(user, question, "answer", false, null)) + .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId)); + when(reportGenerationLogRecorder.start(any(), any(), any(), any(), any(), any())) + .thenReturn(generationLogId); + when(dailyReportLlmClient.generate("question", answerEntry)) + .thenReturn(new LlmGenerationResult<>(aiResult, LlmTokenUsage.empty())); + when(dailyReportTxService.confirmDailyAndReward(any(), eq(aiResult), eq(null))) + .thenReturn(new ConfirmDailyAndRewardDto(emotion, 110L)); + + // when + dailyReportService.generateDailyReport(userId, new DailyReportRequest(20L, "answer", null, null)); + + // then + verify(reportGenerationLogRecorder).recordTokenUsage(generationLogId, null, null, null, null); + } + + private User user(Long id) { + User user = User.createUser("test@test.com", "hashed_password"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private DailyQuestion dailyQuestion(Long id) { + DailyQuestion question = mock(DailyQuestion.class); + when(question.getId()).thenReturn(id); + when(question.getQuestionText()).thenReturn("question"); + return question; + } + + private Emotion emotion(EmotionCode code) { + Emotion emotion = mock(Emotion.class); + when(emotion.getCode()).thenReturn(code); + return emotion; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java new file mode 100644 index 00000000..8723f771 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java @@ -0,0 +1,107 @@ +package com.devkor.ifive.nadab.domain.dailyreport.infra; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +import com.devkor.ifive.nadab.global.core.prompt.daily.DailyReportPromptLoader; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DailyReportLlmClientTest { + + @Mock + DailyReportPromptLoader dailyReportPromptLoader; + + @Mock + LlmRouter llmRouter; + + @Mock + ProfileImageUrlBuilder profileImageUrlBuilder; + + @Mock + ChatClient chatClient; + + @Mock + ChatClient.ChatClientRequestSpec requestSpec; + + @Mock + ChatClient.CallResponseSpec callResponseSpec; + + @Test + void generate_returns_content_with_token_usage() { + // given + DailyReportLlmClient client = new DailyReportLlmClient( + dailyReportPromptLoader, + new ObjectMapper(), + llmRouter, + profileImageUrlBuilder + ); + AnswerEntry answerEntry = AnswerEntry.create( + User.createUser("test@test.com", "hashed_password"), + dailyQuestion(1L), + "answer", + LocalDate.now(), + null + ); + + when(dailyReportPromptLoader.loadPrompt()).thenReturn("question={question}, answer={answer}"); + when(dailyReportPromptLoader.loadWithImagePrompt()).thenReturn("image question={question}, answer={answer}"); + when(llmRouter.route(LlmProvider.OPENAI)).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.messages(any(Message[].class))).thenReturn(requestSpec); + when(requestSpec.options(any())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(callResponseSpec); + when(callResponseSpec.chatResponse()).thenReturn(chatResponse( + "{\"message\":\"good\",\"emotion\":\"ACHIEVEMENT\"}", + new DefaultUsage(100, 50, 150) + )); + + // when + LlmGenerationResult result = client.generate("question", answerEntry); + + // then + assertThat(result.content()).isEqualTo(new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto( + "good", + "ACHIEVEMENT" + )); + assertThat(result.tokenUsage().inputTokens()).isEqualTo(100L); + assertThat(result.tokenUsage().outputTokens()).isEqualTo(50L); + assertThat(result.tokenUsage().totalTokens()).isEqualTo(150L); + } + + private ChatResponse chatResponse(String content, DefaultUsage usage) { + return new ChatResponse( + List.of(new Generation(new AssistantMessage(content))), + ChatResponseMetadata.builder() + .usage(usage) + .build() + ); + } + + private DailyQuestion dailyQuestion(Long id) { + return mock(DailyQuestion.class); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlyReportV2RepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlyReportV2RepositoryTest.java new file mode 100644 index 00000000..a30746e9 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlyReportV2RepositoryTest.java @@ -0,0 +1,235 @@ +package com.devkor.ifive.nadab.domain.monthlyreport; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportImageStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class MonthlyReportV2RepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + MonthlyReportV2Repository monthlyReportV2Repository; + + @Autowired + TestEntityManager em; + + @Test + void create_pending_initializes_empty_social_summary_for_report_month() { + User user = new UserBuilder(em).build(); + MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending( + user, + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + LocalDate.of(2026, 6, 1), + MonthlyReportComparisonType.BASELINE + )); + em.flush(); + em.clear(); + + MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow(); + + assertThat(reloaded.getSocialSummary().visible()).isFalse(); + assertThat(reloaded.getSocialSummary().month()).isEqualTo(5); + assertThat(reloaded.getSocialSummary().likeRanking()).isEmpty(); + assertThat(reloaded.getSocialSummary().commentRanking()).isEmpty(); + } + + @Test + void update_social_summary_saves_display_order_and_joint_top_rank() { + User user = new UserBuilder(em).build(); + MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending( + user, + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + LocalDate.of(2026, 6, 1), + MonthlyReportComparisonType.BASELINE + )); + report.updateSocialSummary(new MonthlySocialSummaryContent( + true, + 5, + List.of( + new MonthlySocialRankingItem(1, 10L, "가", null, null, true), + new MonthlySocialRankingItem(2, 11L, "나", null, null, true) + ), + List.of(new MonthlySocialRankingItem(1, 12L, "다", null, null, true)) + )); + em.flush(); + em.clear(); + + MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow(); + + assertThat(reloaded.getSocialSummary().visible()).isTrue(); + assertThat(reloaded.getSocialSummary().likeRanking()) + .extracting(MonthlySocialRankingItem::displayOrder) + .containsExactly(1, 2); + assertThat(reloaded.getSocialSummary().likeRanking()) + .allMatch(MonthlySocialRankingItem::topRank); + } + + @Test + void saves_visual_preset_and_finds_three_most_recent_completed_values() { + User user = new UserBuilder(em).build(); + completedReport(user, LocalDate.of(2026, 2, 1), MonthlyImageStylePreset.INK_WASH, + MonthlyImageColorPalette.FOREST_MIST); + completedReport(user, LocalDate.of(2026, 3, 1), MonthlyImageStylePreset.GLASS_AND_LIGHT, + MonthlyImageColorPalette.OCEAN_LIGHT); + completedReport(user, LocalDate.of(2026, 4, 1), MonthlyImageStylePreset.PAPER_CUT_LAYERS, + MonthlyImageColorPalette.SUNSET_CLAY); + MonthlyReportV2 may = completedReport(user, LocalDate.of(2026, 5, 1), + MonthlyImageStylePreset.BOTANICAL_COLLAGE, MonthlyImageColorPalette.MOON_VIOLET); + MonthlyReportV2 current = monthlyReportV2Repository.save(MonthlyReportV2.createPending( + user, + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 6, 30), + LocalDate.of(2026, 7, 1), + MonthlyReportComparisonType.COMPARISON + )); + em.flush(); + em.clear(); + + List recent = monthlyReportV2Repository.findRecentCompletedImagePromptVariants( + user.getId(), + current.getId(), + PageRequest.of(0, 3) + ); + List recentPalettes = + monthlyReportV2Repository.findRecentCompletedImageColorPalettes( + user.getId(), + current.getId(), + PageRequest.of(0, 3) + ); + MonthlyReportV2 reloadedMay = monthlyReportV2Repository.findById(may.getId()).orElseThrow(); + + assertThat(recent).containsExactly( + MonthlyImageStylePreset.BOTANICAL_COLLAGE, + MonthlyImageStylePreset.PAPER_CUT_LAYERS, + MonthlyImageStylePreset.GLASS_AND_LIGHT + ); + assertThat(recentPalettes).containsExactly( + MonthlyImageColorPalette.MOON_VIOLET, + MonthlyImageColorPalette.SUNSET_CLAY, + MonthlyImageColorPalette.OCEAN_LIGHT + ); + assertThat(reloadedMay.getImagePromptVariant()).isEqualTo(MonthlyImageStylePreset.BOTANICAL_COLLAGE); + assertThat(reloadedMay.getImageColorPalette()).isEqualTo(MonthlyImageColorPalette.MOON_VIOLET); + } + + @Test + void update_content_saves_emotion_comparison_snapshot() { + User user = new UserBuilder(em).build(); + MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending( + user, + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + LocalDate.of(2026, 6, 1), + MonthlyReportComparisonType.COMPARISON + )); + em.flush(); + em.clear(); + + int updated = updateContent( + report.getId(), + "{\"previousReportId\":10,\"previousMonth\":4,\"previousEmotionStats\":{\"totalCount\":10,\"dominantEmotionCode\":\"ACHIEVEMENT\",\"positivePercent\":57,\"emotions\":[]},\"positivePercentPointChange\":14}" + ); + em.flush(); + em.clear(); + + MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow(); + + assertThat(updated).isEqualTo(1); + assertThat(reloaded.getEmotionComparison()).isNotNull(); + assertThat(reloaded.getEmotionComparison().previousReportId()).isEqualTo(10L); + assertThat(reloaded.getEmotionComparison().previousMonth()).isEqualTo(4); + assertThat(reloaded.getEmotionComparison().previousEmotionStats().positivePercent()).isEqualTo(57); + assertThat(reloaded.getEmotionComparison().positivePercentPointChange()).isEqualTo(14); + assertThat(reloaded.getSocialSummary().visible()).isTrue(); + assertThat(reloaded.getSocialSummary().likeRanking()).singleElement() + .satisfies(item -> { + assertThat(item.displayOrder()).isEqualTo(1); + assertThat(item.nickname()).isEqualTo("가"); + assertThat(item.topRank()).isTrue(); + }); + } + + @Test + void update_content_keeps_emotion_comparison_null_for_baseline() { + User user = new UserBuilder(em).build(); + MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending( + user, + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + LocalDate.of(2026, 6, 1), + MonthlyReportComparisonType.BASELINE + )); + em.flush(); + em.clear(); + + int updated = updateContent(report.getId(), null); + em.flush(); + em.clear(); + + MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow(); + + assertThat(updated).isEqualTo(1); + assertThat(reloaded.getEmotionComparison()).isNull(); + } + + private int updateContent(Long reportId, String emotionComparisonJson) { + return monthlyReportV2Repository.updateContent( + reportId, + "{\"summary\":\"월간 요약\",\"commentSummary\":\"한마디 요약\",\"dominantKeyword\":\"도전\",\"emotionTrend\":\"도전을 중심으로 감정이 변했어요\",\"discovered\":{\"segments\":[]},\"comment\":{\"segments\":[]}}", + "{\"styledText\":{\"segments\":[]}}", + "월간 요약", + "한마디 요약", + "도전", + "{\"totalCount\":10,\"dominantEmotionCode\":\"ACHIEVEMENT\",\"positivePercent\":71,\"emotions\":[]}", + "{\"interests\":[]}", + emotionComparisonJson, + "{\"visible\":true,\"month\":5,\"likeRanking\":[{\"displayOrder\":1,\"userId\":10,\"nickname\":\"가\",\"profileImageKey\":null,\"defaultProfileType\":null,\"topRank\":true}],\"commentRanking\":[{\"displayOrder\":1,\"userId\":11,\"nickname\":\"나\",\"profileImageKey\":null,\"defaultProfileType\":null,\"topRank\":true}]}", + MonthlyReportStatus.TEXT_COMPLETED.name() + ); + } + + private MonthlyReportV2 completedReport( + User user, + LocalDate monthStartDate, + MonthlyImageStylePreset preset, + MonthlyImageColorPalette colorPalette + ) { + MonthlyReportV2 report = MonthlyReportV2.create( + user, + monthStartDate, + monthStartDate.withDayOfMonth(monthStartDate.lengthOfMonth()), + MonthlyReportV2ContentFactory.empty(), + monthStartDate.plusMonths(1), + MonthlyReportStatus.COMPLETED, + MonthlyReportImageStatus.COMPLETED, + MonthlyReportComparisonType.COMPARISON + ); + report.assignImagePromptVariant(preset); + report.assignImageColorPalette(colorPalette); + return monthlyReportV2Repository.save(report); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlySocialQueryRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlySocialQueryRepositoryTest.java new file mode 100644 index 00000000..b130409c --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlySocialQueryRepositoryTest.java @@ -0,0 +1,165 @@ +package com.devkor.ifive.nadab.domain.monthlyreport; + +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.friend.core.entity.Friendship; +import com.devkor.ifive.nadab.domain.like.core.entity.DailyReportLike; +import com.devkor.ifive.nadab.domain.moderation.core.entity.UserBlock; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlySocialQueryRepository; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class MonthlySocialQueryRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + MonthlySocialQueryRepository monthlySocialQueryRepository; + + @Autowired + TestEntityManager em; + + DailyQuestion question; + OffsetDateTime startAt; + OffsetDateTime endAt; + + @BeforeEach + void setUp() { + question = em.getEntityManager() + .createQuery("select q from DailyQuestion q where q.deletedAt is null", DailyQuestion.class) + .setMaxResults(1) + .getSingleResult(); + startAt = OffsetDateTime.now().minusDays(1); + endAt = OffsetDateTime.now().plusDays(1); + } + + @Test + void counts_only_likes_received_from_current_available_friends() { + User owner = user("owner"); + User friend = user("가나다"); + User blockedFriend = user("blocked"); + User deletedFriend = user("deleted"); + User nonFriend = user("stranger"); + User otherOwner = user("otherOwner"); + acceptFriendship(owner, friend); + acceptFriendship(owner, blockedFriend); + acceptFriendship(owner, deletedFriend); + em.persist(UserBlock.create(blockedFriend, owner)); + + DailyReport firstReport = report(owner, LocalDate.of(2026, 6, 1)); + DailyReport secondReport = report(owner, LocalDate.of(2026, 6, 2)); + DailyReport outsideRangeReport = report(owner, LocalDate.of(2026, 6, 3)); + DailyReport otherReport = report(otherOwner, LocalDate.of(2026, 6, 1)); + + em.persist(DailyReportLike.create(friend, firstReport)); + em.persist(DailyReportLike.create(friend, secondReport)); + DailyReportLike outsideRangeLike = DailyReportLike.create(friend, outsideRangeReport); + em.persist(outsideRangeLike); + em.persist(DailyReportLike.create(friend, otherReport)); + em.persist(DailyReportLike.create(blockedFriend, firstReport)); + em.persist(DailyReportLike.create(deletedFriend, firstReport)); + em.persist(DailyReportLike.create(nonFriend, firstReport)); + deletedFriend.softDelete(); + + em.flush(); + em.getEntityManager().createNativeQuery("UPDATE daily_report_likes SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", startAt.minusHours(1)) + .setParameter("id", outsideRangeLike.getId()) + .executeUpdate(); + em.clear(); + + List result = monthlySocialQueryRepository.countReceivedLikesByFriend( + owner.getId(), startAt, endAt + ); + + assertThat(result).singleElement().satisfies(row -> { + assertThat(row.userId()).isEqualTo(friend.getId()); + assertThat(row.nickname()).isEqualTo("가나다"); + assertThat(row.interactionCount()).isEqualTo(2); + }); + } + + @Test + void counts_active_comments_and_subcomments_received_from_current_available_friends() { + User owner = user("owner"); + User friend = user("가나다"); + User blockedFriend = user("blocked"); + User deletedFriend = user("deleted"); + User nonFriend = user("stranger"); + User otherOwner = user("otherOwner"); + acceptFriendship(owner, friend); + acceptFriendship(owner, blockedFriend); + acceptFriendship(owner, deletedFriend); + em.persist(UserBlock.create(owner, blockedFriend)); + + DailyReport report = report(owner, LocalDate.of(2026, 6, 1)); + DailyReport otherReport = report(otherOwner, LocalDate.of(2026, 6, 1)); + Comment topLevel = Comment.createTopLevel(report, friend, "comment", false); + em.persist(topLevel); + em.persist(Comment.createSubComment(friend, topLevel, "subcomment", false)); + Comment deletedComment = Comment.createTopLevel(report, friend, "deleted", false); + em.persist(deletedComment); + deletedComment.softDelete(); + Comment outsideRangeComment = Comment.createTopLevel(report, friend, "old", false); + em.persist(outsideRangeComment); + em.persist(Comment.createTopLevel(otherReport, friend, "other report", false)); + em.persist(Comment.createTopLevel(report, blockedFriend, "blocked", false)); + em.persist(Comment.createTopLevel(report, deletedFriend, "withdrawn", false)); + em.persist(Comment.createTopLevel(report, nonFriend, "stranger", false)); + deletedFriend.softDelete(); + + em.flush(); + em.getEntityManager().createNativeQuery("UPDATE comments SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", startAt.minusHours(1)) + .setParameter("id", outsideRangeComment.getId()) + .executeUpdate(); + em.clear(); + + List result = monthlySocialQueryRepository.countReceivedCommentsByFriend( + owner.getId(), startAt, endAt + ); + + assertThat(result).singleElement().satisfies(row -> { + assertThat(row.userId()).isEqualTo(friend.getId()); + assertThat(row.nickname()).isEqualTo("가나다"); + assertThat(row.interactionCount()).isEqualTo(2); + }); + } + + private User user(String nickname) { + User user = new UserBuilder(em).build(); + user.updateNickname(nickname); + return user; + } + + private void acceptFriendship(User first, User second) { + Friendship friendship = Friendship.createPending(first, second); + friendship.accept(); + em.persist(friendship); + } + + private DailyReport report(User owner, LocalDate date) { + AnswerEntry answerEntry = AnswerEntry.create(owner, question, "answer", date, null); + em.persist(answerEntry); + DailyReport report = DailyReport.create(answerEntry, null, "report", date, DailyReportStatus.COMPLETED); + em.persist(report); + return report; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2Test.java new file mode 100644 index 00000000..d78a1098 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2Test.java @@ -0,0 +1,28 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api; + +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlyReportControllerV2Test { + + @Test + void locator_swagger_documents_null_status_and_version_routes() throws Exception { + Method method = MonthlyReportControllerV2.class.getMethod( + "getMyMonthlyReport", + UserPrincipal.class + ); + + String description = method.getAnnotation(Operation.class).description(); + assertThat(description) + .contains("두 슬롯은 서로 독립적으로 null일 수 있습니다") + .contains("report.status: PENDING | IN_PROGRESS | TEXT_COMPLETED | COMPLETED | FAILED") + .contains("previousReport.status: 존재하는 경우 항상 COMPLETED") + .contains("/api/v1/monthly-report/{reportId}") + .contains("/api/v2/monthly-report/{reportId}"); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2Test.java new file mode 100644 index 00000000..c982f711 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2Test.java @@ -0,0 +1,27 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MyMonthlyReportLookupResponseV2Test { + + @Test + void serializes_report_and_previous_report_with_report_id() throws Exception { + MyMonthlyReportLookupResponseV2 response = new MyMonthlyReportLookupResponseV2( + new MonthlyReportLocatorResponse(20L, 2, 5, MonthlyReportStatus.IN_PROGRESS), + new MonthlyReportLocatorResponse(10L, 1, 4, MonthlyReportStatus.COMPLETED) + ); + + JsonNode json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsString(response)); + + assertThat(json.path("report").path("reportId").asLong()).isEqualTo(20L); + assertThat(json.path("report").path("version").asInt()).isEqualTo(2); + assertThat(json.path("previousReport").path("reportId").asLong()).isEqualTo(10L); + assertThat(json.path("previousReport").path("status").asText()).isEqualTo("COMPLETED"); + assertThat(json.path("report").has("id")).isFalse(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentServiceTest.java new file mode 100644 index 00000000..51abb9b3 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentServiceTest.java @@ -0,0 +1,132 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImageVisualPreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlyImagePresetAssignmentServiceTest { + + @Mock + MonthlyReportV2Repository monthlyReportV2Repository; + + MonthlyImagePresetAssignmentService service; + + @BeforeEach + void setUp() { + service = new MonthlyImagePresetAssignmentService(monthlyReportV2Repository); + } + + @Test + void reuses_already_assigned_visual_preset() { + MonthlyReportV2 report = report(1L); + when(report.getImagePromptVariant()).thenReturn(MonthlyImageStylePreset.INK_WASH); + when(report.getImageColorPalette()).thenReturn(MonthlyImageColorPalette.OCEAN_LIGHT); + when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report)); + + MonthlyImageVisualPreset result = service.getOrAssignVisualPreset(1L, 10L); + + assertThat(result.stylePreset()).isEqualTo(MonthlyImageStylePreset.INK_WASH); + assertThat(result.colorPalette()).isEqualTo(MonthlyImageColorPalette.OCEAN_LIGHT); + verify(monthlyReportV2Repository, never()).findRecentCompletedImagePromptVariants( + 1L, 10L, PageRequest.of(0, 3) + ); + verify(monthlyReportV2Repository, never()).findRecentCompletedImageColorPalettes( + 1L, 10L, PageRequest.of(0, 3) + ); + verify(report, never()).assignImagePromptVariant(org.mockito.ArgumentMatchers.any()); + verify(report, never()).assignImageColorPalette(org.mockito.ArgumentMatchers.any()); + } + + @Test + void assigns_visual_preset_excluding_recent_completed_values() { + MonthlyReportV2 report = report(1L); + when(report.getImagePromptVariant()).thenReturn(null); + when(report.getImageColorPalette()).thenReturn(null); + when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1)); + when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report)); + List recent = List.of( + MonthlyImageStylePreset.BOTANICAL_COLLAGE, + MonthlyImageStylePreset.GLASS_AND_LIGHT, + MonthlyImageStylePreset.INK_WASH + ); + when(monthlyReportV2Repository.findRecentCompletedImagePromptVariants( + 1L, 10L, PageRequest.of(0, 3) + )).thenReturn(recent); + List recentPalettes = List.of( + MonthlyImageColorPalette.FOREST_MIST, + MonthlyImageColorPalette.OCEAN_LIGHT, + MonthlyImageColorPalette.SUNSET_CLAY + ); + when(monthlyReportV2Repository.findRecentCompletedImageColorPalettes( + 1L, 10L, PageRequest.of(0, 3) + )).thenReturn(recentPalettes); + + MonthlyImageVisualPreset result = service.getOrAssignVisualPreset(1L, 10L); + + assertThat(recent).doesNotContain(result.stylePreset()); + assertThat(recentPalettes).doesNotContain(result.colorPalette()); + verify(report).assignImagePromptVariant(result.stylePreset()); + verify(report).assignImageColorPalette(result.colorPalette()); + } + + @Test + void assigns_only_missing_palette_when_style_is_already_assigned() { + MonthlyReportV2 report = report(1L); + when(report.getImagePromptVariant()).thenReturn(MonthlyImageStylePreset.INK_WASH); + when(report.getImageColorPalette()).thenReturn(null); + when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1)); + when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report)); + when(monthlyReportV2Repository.findRecentCompletedImageColorPalettes( + 1L, 10L, PageRequest.of(0, 3) + )).thenReturn(List.of(MonthlyImageColorPalette.OCEAN_LIGHT)); + + MonthlyImageVisualPreset result = service.getOrAssignVisualPreset(1L, 10L); + + assertThat(result.stylePreset()).isEqualTo(MonthlyImageStylePreset.INK_WASH); + verify(monthlyReportV2Repository, never()).findRecentCompletedImagePromptVariants( + 1L, 10L, PageRequest.of(0, 3) + ); + verify(report).assignImageColorPalette(org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejects_other_users_report() { + MonthlyReportV2 report = report(2L); + when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report)); + + assertThatThrownBy(() -> service.getOrAssignVisualPreset(1L, 10L)) + .isInstanceOfSatisfying(ForbiddenException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MONTHLY_REPORT_ACCESS_FORBIDDEN)); + } + + private MonthlyReportV2 report(Long ownerId) { + User user = mock(User.class); + when(user.getId()).thenReturn(ownerId); + MonthlyReportV2 report = mock(MonthlyReportV2.class); + when(report.getUser()).thenReturn(user); + return report; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolverTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolverTest.java new file mode 100644 index 00000000..f3f12ba0 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolverTest.java @@ -0,0 +1,109 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportLocatorResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlyReportLocatorResolverTest { + + @Mock + MonthlyReportRepository monthlyReportRepository; + @Mock + MonthlyReportV2Repository monthlyReportV2Repository; + + MonthlyReportLocatorResolver resolver; + + @BeforeEach + void setUp() { + resolver = new MonthlyReportLocatorResolver(monthlyReportRepository, monthlyReportV2Repository); + } + + @Test + void resolves_v2_report_without_v1_fallback() { + LocalDate monthStartDate = LocalDate.of(2026, 5, 1); + MonthlyReportV2 report = v2Report(20L, monthStartDate, MonthlyReportStatus.IN_PROGRESS); + when(monthlyReportV2Repository.findByUserIdAndMonthStartDate(1L, monthStartDate)) + .thenReturn(Optional.of(report)); + + Optional result = resolver.findByMonth(1L, monthStartDate); + + assertThat(result).contains(new MonthlyReportLocatorResponse( + 20L, 2, 5, MonthlyReportStatus.IN_PROGRESS + )); + verifyNoInteractions(monthlyReportRepository); + } + + @Test + void falls_back_to_v1_when_v2_report_does_not_exist() { + LocalDate monthStartDate = LocalDate.of(2026, 5, 1); + MonthlyReport report = v1Report(10L, monthStartDate, MonthlyReportStatus.COMPLETED); + when(monthlyReportV2Repository.findByUserIdAndMonthStartDate(1L, monthStartDate)) + .thenReturn(Optional.empty()); + when(monthlyReportRepository.findByUserIdAndMonthStartDate(1L, monthStartDate)) + .thenReturn(Optional.of(report)); + + Optional result = resolver.findByMonth(1L, monthStartDate); + + assertThat(result).contains(new MonthlyReportLocatorResponse( + 10L, 1, 5, MonthlyReportStatus.COMPLETED + )); + } + + @Test + void resolves_previous_report_with_completed_status_only() { + LocalDate monthStartDate = LocalDate.of(2026, 4, 1); + MonthlyReport report = v1Report(10L, monthStartDate, MonthlyReportStatus.COMPLETED); + when(monthlyReportV2Repository.findByUserIdAndMonthStartDateAndStatus( + 1L, monthStartDate, MonthlyReportStatus.COMPLETED + )).thenReturn(Optional.empty()); + when(monthlyReportRepository.findByUserIdAndMonthStartDateAndStatus( + 1L, monthStartDate, MonthlyReportStatus.COMPLETED + )).thenReturn(Optional.of(report)); + + Optional result = resolver.findCompletedByMonth(1L, monthStartDate); + + assertThat(result).contains(new MonthlyReportLocatorResponse( + 10L, 1, 4, MonthlyReportStatus.COMPLETED + )); + verify(monthlyReportV2Repository).findByUserIdAndMonthStartDateAndStatus( + 1L, monthStartDate, MonthlyReportStatus.COMPLETED + ); + verify(monthlyReportRepository).findByUserIdAndMonthStartDateAndStatus( + 1L, monthStartDate, MonthlyReportStatus.COMPLETED + ); + } + + private MonthlyReportV2 v2Report(Long id, LocalDate monthStartDate, MonthlyReportStatus status) { + MonthlyReportV2 report = mock(MonthlyReportV2.class); + when(report.getId()).thenReturn(id); + when(report.getMonthStartDate()).thenReturn(monthStartDate); + when(report.getStatus()).thenReturn(status); + return report; + } + + private MonthlyReport v1Report(Long id, LocalDate monthStartDate, MonthlyReportStatus status) { + MonthlyReport report = mock(MonthlyReport.class); + when(report.getId()).thenReturn(id); + when(report.getMonthStartDate()).thenReturn(monthStartDate); + when(report.getStatus()).thenReturn(status); + return report; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceTest.java new file mode 100644 index 00000000..b03ec7b9 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceTest.java @@ -0,0 +1,79 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ForbiddenException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlyReportQueryServiceTest { + + @Mock + MonthlyReportRepository monthlyReportRepository; + @Mock + UserRepository userRepository; + + MonthlyReportQueryService service; + + @BeforeEach + void setUp() { + service = new MonthlyReportQueryService(monthlyReportRepository, userRepository); + } + + @Test + void returns_owned_monthly_report() { + MonthlyReport report = report(1L); + when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1)); + when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED); + when(monthlyReportRepository.findById(10L)).thenReturn(Optional.of(report)); + + var response = service.getMonthlyReportById(1L, 10L); + + assertThat(response.month()).isEqualTo(5); + assertThat(response.status()).isEqualTo("COMPLETED"); + } + + @Test + void rejects_other_users_monthly_report() { + MonthlyReport report = report(2L); + when(monthlyReportRepository.findById(10L)).thenReturn(Optional.of(report)); + + assertThatThrownBy(() -> service.getMonthlyReportById(1L, 10L)) + .isInstanceOfSatisfying(ForbiddenException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MONTHLY_REPORT_ACCESS_FORBIDDEN)); + } + + @Test + void rejects_missing_monthly_report() { + when(monthlyReportRepository.findById(10L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getMonthlyReportById(1L, 10L)) + .isInstanceOfSatisfying(NotFoundException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + } + + private MonthlyReport report(Long ownerId) { + User owner = mock(User.class); + when(owner.getId()).thenReturn(ownerId); + MonthlyReport report = mock(MonthlyReport.class); + when(report.getUser()).thenReturn(owner); + return report; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2Test.java new file mode 100644 index 00000000..3de766ec --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2Test.java @@ -0,0 +1,193 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponseV2; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportLocatorResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyEmotionComparisonContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.entity.DefaultProfileType; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyReportRepository; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlyReportQueryServiceV2Test { + + @Mock + private MonthlyReportRepository monthlyReportRepository; + @Mock + private MonthlyReportV2Repository monthlyReportV2Repository; + @Mock + private WeeklyReportRepository weeklyReportRepository; + @Mock + private UserRepository userRepository; + @Mock + private ProfileImageUrlBuilder profileImageUrlBuilder; + @Mock + private MonthlyReportLocatorResolver monthlyReportLocatorResolver; + + private MonthlyReportQueryServiceV2 service; + + @BeforeEach + void setUp() { + service = new MonthlyReportQueryServiceV2( + monthlyReportRepository, + monthlyReportV2Repository, + weeklyReportRepository, + userRepository, + profileImageUrlBuilder, + monthlyReportLocatorResolver + ); + } + + @Test + void 현재와_지지난달_월간_리포트_위치_정보를_반환한다() { + when(userRepository.existsById(1L)).thenReturn(true); + LocalDate currentMonth = MonthRangeCalculator.getLastMonthRange().monthStartDate(); + LocalDate previousMonth = MonthRangeCalculator.getTwoMonthsAgoRange().monthStartDate(); + MonthlyReportLocatorResponse current = new MonthlyReportLocatorResponse( + 20L, 2, currentMonth.getMonthValue(), MonthlyReportStatus.IN_PROGRESS + ); + MonthlyReportLocatorResponse previous = new MonthlyReportLocatorResponse( + 10L, 1, previousMonth.getMonthValue(), MonthlyReportStatus.COMPLETED + ); + when(monthlyReportLocatorResolver.findByMonth(1L, currentMonth)).thenReturn(Optional.of(current)); + when(monthlyReportLocatorResolver.findCompletedByMonth(1L, previousMonth)) + .thenReturn(Optional.of(previous)); + + var response = service.getMyMonthlyReport(1L); + + assertThat(response.report()).isEqualTo(current); + assertThat(response.previousReport()).isEqualTo(previous); + } + + @Test + void 연도_경계에서_1월의_이전_리포트로_전년도_12월을_조회한다() { + MonthRangeDto january = new MonthRangeDto( + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 1, 31) + ); + MonthlyReportLocatorResponse current = new MonthlyReportLocatorResponse( + 20L, 2, 1, MonthlyReportStatus.COMPLETED + ); + MonthlyReportLocatorResponse previous = new MonthlyReportLocatorResponse( + 10L, 1, 12, MonthlyReportStatus.COMPLETED + ); + when(monthlyReportLocatorResolver.findByMonth(1L, LocalDate.of(2026, 1, 1))) + .thenReturn(Optional.of(current)); + when(monthlyReportLocatorResolver.findCompletedByMonth(1L, LocalDate.of(2025, 12, 1))) + .thenReturn(Optional.of(previous)); + + var response = service.getMyMonthlyReport(1L, january); + + assertThat(response.report()).isEqualTo(current); + assertThat(response.previousReport()).isEqualTo(previous); + } + + @Test + void COMPARISON_리포트는_감정_비교_스냅샷을_반환한다() { + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + + MonthlyEmotionComparisonContent emotionComparison = new MonthlyEmotionComparisonContent( + 10L, + 4, + new TypeEmotionStatsContent(10, "ACHIEVEMENT", 57, List.of()), + 14 + ); + MonthlyReportV2 report = mock(MonthlyReportV2.class); + when(report.getUser()).thenReturn(user); + when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1)); + when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED); + when(report.getComparisonType()).thenReturn(MonthlyReportComparisonType.COMPARISON); + when(report.getEmotionComparison()).thenReturn(emotionComparison); + when(monthlyReportV2Repository.findById(7L)).thenReturn(Optional.of(report)); + + MonthlyReportResponseV2 response = service.getMonthlyReportById(1L, 7L); + + assertThat(response.emotionComparison()).isEqualTo(emotionComparison.normalized()); + } + + @Test + void 소셜_요약의_프로필_식별자를_URL로_변환한다() throws Exception { + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + + MonthlySocialSummaryContent socialSummary = new MonthlySocialSummaryContent( + true, + 5, + List.of(new MonthlySocialRankingItem(1, 2L, "가", "custom-key", null, true)), + List.of(new MonthlySocialRankingItem(1, 3L, "나", null, DefaultProfileType.DEFAULT, true)) + ); + MonthlyReportV2 report = mock(MonthlyReportV2.class); + when(report.getUser()).thenReturn(user); + when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1)); + when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED); + when(report.getComparisonType()).thenReturn(MonthlyReportComparisonType.BASELINE); + when(report.getSocialSummary()).thenReturn(socialSummary); + when(monthlyReportV2Repository.findById(7L)).thenReturn(Optional.of(report)); + when(profileImageUrlBuilder.buildUrl("custom-key")).thenReturn("https://cdn/custom-key"); + when(profileImageUrlBuilder.buildDefaultUrl(DefaultProfileType.DEFAULT)) + .thenReturn("https://cdn/default/DEFAULT.png"); + + MonthlyReportResponseV2 response = service.getMonthlyReportById(1L, 7L); + + assertThat(response.socialSummary().visible()).isTrue(); + assertThat(response.socialSummary().likeRanking()).singleElement().satisfies(item -> { + assertThat(item.displayOrder()).isEqualTo(1); + assertThat(item.userId()).isEqualTo(2L); + assertThat(item.profileImageUrl()).isEqualTo("https://cdn/custom-key"); + assertThat(item.topRank()).isTrue(); + }); + assertThat(response.socialSummary().commentRanking()).singleElement() + .satisfies(item -> assertThat(item.profileImageUrl()) + .isEqualTo("https://cdn/default/DEFAULT.png")); + String responseJson = new ObjectMapper().writeValueAsString(response.socialSummary()); + assertThat(responseJson).contains("profileImageUrl"); + assertThat(responseJson).doesNotContain("profileImageKey", "defaultProfileType"); + } + + @Test + void BASELINE_리포트는_감정_비교_스냅샷을_null로_반환한다() { + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + + MonthlyReportV2 report = mock(MonthlyReportV2.class); + when(report.getUser()).thenReturn(user); + when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1)); + when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED); + when(report.getComparisonType()).thenReturn(MonthlyReportComparisonType.BASELINE); + when(monthlyReportV2Repository.findById(7L)).thenReturn(Optional.of(report)); + + MonthlyReportResponseV2 response = service.getMonthlyReportById(1L, 7L); + + assertThat(response.emotionComparison()).isNull(); + assertThat(response.socialSummary().visible()).isFalse(); + assertThat(response.socialSummary().month()).isEqualTo(5); + assertThat(response.socialSummary().likeRanking()).isEmpty(); + assertThat(response.socialSummary().commentRanking()).isEmpty(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2Test.java new file mode 100644 index 00000000..b551c3dd --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2Test.java @@ -0,0 +1,169 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.PendingMonthlyReportServiceV2; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlyReportTxServiceV2Test { + + @Mock + private PendingMonthlyReportServiceV2 pendingMonthlyReportServiceV2; + @Mock + private MonthlyReportV2Repository monthlyReportV2Repository; + @Mock + private UserWalletRepository userWalletRepository; + @Mock + private CrystalLogRepository crystalLogRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private ObjectMapper objectMapper; + + private MonthlyReportTxServiceV2 service; + + @BeforeEach + void setUp() { + service = new MonthlyReportTxServiceV2( + pendingMonthlyReportServiceV2, + monthlyReportV2Repository, + userWalletRepository, + crystalLogRepository, + eventPublisher, + objectMapper + ); + } + + @Test + void 가장_최근_완료된_V2_리포트를_비교_대상으로_확정한다() { + User user = user(1L); + MonthlyReportV2 previousReport = report(10L); + prepareReservation(user, true); + when(monthlyReportV2Repository + .findFirstByUserIdAndStatusAndMonthStartDateBeforeOrderByMonthStartDateDesc( + 1L, + MonthlyReportStatus.COMPLETED, + MonthRangeCalculator.getLastMonthRange().monthStartDate() + )) + .thenReturn(Optional.of(previousReport)); + + service.reserveMonthlyAndPublish(user); + + verify(pendingMonthlyReportServiceV2).getOrCreatePendingMonthlyReport(user, true); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MonthlyReportGenerationRequestedEventDtoV2.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().previousReportId()).isEqualTo(10L); + } + + @Test + void 이전에_완료된_V2_리포트가_없으면_BASELINE으로_확정한다() { + User user = user(1L); + prepareReservation(user, false); + when(monthlyReportV2Repository + .findFirstByUserIdAndStatusAndMonthStartDateBeforeOrderByMonthStartDateDesc( + 1L, + MonthlyReportStatus.COMPLETED, + MonthRangeCalculator.getLastMonthRange().monthStartDate() + )) + .thenReturn(Optional.empty()); + + service.reserveMonthlyAndPublish(user); + + verify(pendingMonthlyReportServiceV2).getOrCreatePendingMonthlyReport(user, false); + ArgumentCaptor captor = + ArgumentCaptor.forClass(MonthlyReportGenerationRequestedEventDtoV2.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().previousReportId()).isNull(); + } + + @Test + void confirm_monthly_text_serializes_social_summary_in_same_update() throws Exception { + when(objectMapper.writeValueAsString(any())) + .thenReturn( + "contentJson", + "emotionSummaryJson", + "emotionStatsJson", + "interestStatsJson", + "socialSummaryJson" + ); + + service.confirmMonthlyText( + 20L, + MonthlyReportV2ContentFactory.empty(), + TypeContentFactory.emptyText(), + TypeContentFactory.emptyEmotionStats(), + MonthlyContentFactory.emptyInterestStats(), + null, + MonthlySocialSummaryContent.empty(5) + ); + + verify(monthlyReportV2Repository).updateContent( + eq(20L), + eq("contentJson"), + eq("emotionSummaryJson"), + eq(""), + eq(""), + eq(""), + eq("emotionStatsJson"), + eq("interestStatsJson"), + eq(null), + eq("socialSummaryJson"), + eq(MonthlyReportStatus.TEXT_COMPLETED.name()) + ); + } + + private void prepareReservation(User user, boolean hasPreviousReport) { + MonthlyReportV2 pendingReport = report(20L); + UserWallet wallet = org.mockito.Mockito.mock(UserWallet.class); + CrystalLog crystalLog = org.mockito.Mockito.mock(CrystalLog.class); + + when(pendingMonthlyReportServiceV2.getOrCreatePendingMonthlyReport(user, hasPreviousReport)) + .thenReturn(pendingReport); + when(userWalletRepository.tryConsume(1L, 40L)).thenReturn(1); + when(userWalletRepository.findByUserId(1L)).thenReturn(Optional.of(wallet)); + when(wallet.getCrystalBalance()).thenReturn(60L); + when(crystalLogRepository.save(any(CrystalLog.class))).thenReturn(crystalLog); + when(crystalLog.getId()).thenReturn(30L); + } + + private User user(Long id) { + User user = org.mockito.Mockito.mock(User.class); + when(user.getId()).thenReturn(id); + return user; + } + + private MonthlyReportV2 report(Long id) { + MonthlyReportV2 report = org.mockito.Mockito.mock(MonthlyReportV2.class); + when(report.getId()).thenReturn(id); + return report; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelectorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelectorTest.java new file mode 100644 index 00000000..db4edee4 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelectorTest.java @@ -0,0 +1,52 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlyImageColorPaletteSelectorTest { + + @Test + void selects_deterministically_for_same_report() { + MonthlyImageColorPalette first = MonthlyImageColorPaletteSelector.select( + 1L, 10L, LocalDate.of(2026, 5, 1), List.of() + ); + MonthlyImageColorPalette second = MonthlyImageColorPaletteSelector.select( + 1L, 10L, LocalDate.of(2026, 5, 1), List.of() + ); + + assertThat(second).isEqualTo(first); + } + + @Test + void excludes_recent_palettes() { + List recent = List.of( + MonthlyImageColorPalette.FOREST_MIST, + MonthlyImageColorPalette.OCEAN_LIGHT, + MonthlyImageColorPalette.SUNSET_CLAY + ); + + MonthlyImageColorPalette selected = MonthlyImageColorPaletteSelector.select( + 1L, 10L, LocalDate.of(2026, 5, 1), recent + ); + + assertThat(recent).doesNotContain(selected); + } + + @Test + void reuses_all_palettes_when_every_candidate_is_excluded() { + MonthlyImageColorPalette selected = MonthlyImageColorPaletteSelector.select( + 1L, + 10L, + LocalDate.of(2026, 5, 1), + List.of(MonthlyImageColorPalette.values()) + ); + + assertThat(Arrays.asList(MonthlyImageColorPalette.values())).contains(selected); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelectorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelectorTest.java new file mode 100644 index 00000000..4965f049 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelectorTest.java @@ -0,0 +1,52 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlyImagePresetSelectorTest { + + @Test + void selects_deterministically_for_same_report() { + MonthlyImageStylePreset first = MonthlyImagePresetSelector.select( + 1L, 10L, LocalDate.of(2026, 5, 1), List.of() + ); + MonthlyImageStylePreset second = MonthlyImagePresetSelector.select( + 1L, 10L, LocalDate.of(2026, 5, 1), List.of() + ); + + assertThat(second).isEqualTo(first); + } + + @Test + void excludes_recent_presets() { + List recent = List.of( + MonthlyImageStylePreset.BOTANICAL_COLLAGE, + MonthlyImageStylePreset.GLASS_AND_LIGHT, + MonthlyImageStylePreset.INK_WASH + ); + + MonthlyImageStylePreset selected = MonthlyImagePresetSelector.select( + 1L, 10L, LocalDate.of(2026, 5, 1), recent + ); + + assertThat(recent).doesNotContain(selected); + } + + @Test + void reuses_all_presets_when_every_candidate_is_excluded() { + MonthlyImageStylePreset selected = MonthlyImagePresetSelector.select( + 1L, + 10L, + LocalDate.of(2026, 5, 1), + List.of(MonthlyImageStylePreset.values()) + ); + + assertThat(Arrays.asList(MonthlyImageStylePreset.values())).contains(selected); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposerTest.java new file mode 100644 index 00000000..ac4bd0f8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposerTest.java @@ -0,0 +1,152 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlyImagePromptComposerTest { + + private final MonthlyImagePromptComposer composer = new MonthlyImagePromptComposer(); + + @ParameterizedTest + @EnumSource(MonthlyImageStylePreset.class) + void 모든_프리셋에_대해_공통_문맥과_스타일_지침을_조립한다(MonthlyImageStylePreset preset) { + MonthlyImagePromptContext context = context(preset, "꾸준한 성장", "잠시 쉬어가도 괜찮아요", "성장"); + + String prompt = composer.compose(context); + + assertThat(prompt) + .contains("Monthly summary: 꾸준한 성장") + .contains("Gentle comment summary: 잠시 쉬어가도 괜찮아요") + .contains("Dominant keyword: 성장") + .contains("Month: 2026-05-01 to 2026-05-31") + .contains("Visual direction:") + .contains("No people, no faces, no hands, no body parts.") + .doesNotContain(preset.name()); + } + + @Test + void 프리셋마다_서로_다른_시각_지침을_사용한다() { + String botanical = composer.compose(context( + MonthlyImageStylePreset.BOTANICAL_COLLAGE, "요약", "코멘트", "키워드")); + String glass = composer.compose(context( + MonthlyImageStylePreset.GLASS_AND_LIGHT, "요약", "코멘트", "키워드")); + + assertThat(botanical).contains("Botanical editorial collage"); + assertThat(glass).contains("Abstract translucent glass forms"); + assertThat(botanical).isNotEqualTo(glass); + } + + @Test + void 빈_리포트_문맥은_안전한_대체값으로_정규화한다() { + MonthlyImagePromptContext context = context( + MonthlyImageStylePreset.INK_WASH, null, " ", "\t"); + + String prompt = composer.compose(context); + + assertThat(prompt).contains("Monthly summary: Not provided") + .contains("Gentle comment summary: Not provided") + .contains("Dominant keyword: Not provided"); + } + + @ParameterizedTest + @EnumSource(MonthlyImageColorPalette.class) + void composes_a_distinct_direction_for_every_color_palette(MonthlyImageColorPalette palette) { + MonthlyImagePromptContext context = new MonthlyImagePromptContext( + "summary", + "comment", + "keyword", + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + MonthlyImageStylePreset.MINIMAL_GEOMETRY, + palette + ); + + String prompt = composer.compose(context); + + assertThat(prompt) + .contains("Color direction:") + .contains("The specified color direction is mandatory.") + .contains("Do not default to lavender, pink, beige, peach") + .doesNotContain(palette.name()) + .doesNotContain("feel calm, warm"); + } + + @Test + void all_style_and_palette_combinations_produce_distinct_prompts() { + Set prompts = new HashSet<>(); + + for (MonthlyImageStylePreset style : MonthlyImageStylePreset.values()) { + for (MonthlyImageColorPalette palette : MonthlyImageColorPalette.values()) { + prompts.add(composer.compose(new MonthlyImagePromptContext( + "summary", + "comment", + "keyword", + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + style, + palette + ))); + } + } + + assertThat(prompts).hasSize( + MonthlyImageStylePreset.values().length * MonthlyImageColorPalette.values().length + ); + } + + @Test + void every_palette_contains_recognizable_core_color_tokens() { + Map expectedColors = Map.of( + MonthlyImageColorPalette.FOREST_MIST, new String[]{"sage green", "deep teal"}, + MonthlyImageColorPalette.OCEAN_LIGHT, new String[]{"cobalt blue", "clear cyan"}, + MonthlyImageColorPalette.SUNSET_CLAY, new String[]{"terracotta", "burnt orange"}, + MonthlyImageColorPalette.MOON_VIOLET, new String[]{"indigo", "violet"}, + MonthlyImageColorPalette.CITRUS_BREEZE, new String[]{"lemon yellow", "fresh mint"}, + MonthlyImageColorPalette.EARTH_NEUTRAL, new String[]{"ivory", "stone gray"}, + MonthlyImageColorPalette.JEWEL_GLOW, new String[]{"emerald", "sapphire"}, + MonthlyImageColorPalette.ROSE_DAWN, new String[]{"rose", "peach"} + ); + + expectedColors.forEach((palette, colors) -> { + String prompt = composer.compose(new MonthlyImagePromptContext( + "summary", + "comment", + "keyword", + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + MonthlyImageStylePreset.MINIMAL_GEOMETRY, + palette + )); + + assertThat(prompt).contains(colors); + }); + } + + private MonthlyImagePromptContext context( + MonthlyImageStylePreset preset, + String summary, + String commentSummary, + String dominantKeyword + ) { + return new MonthlyImagePromptContext( + summary, + commentSummary, + dominantKeyword, + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31), + preset, + MonthlyImageColorPalette.OCEAN_LIGHT + ); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssemblerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssemblerTest.java new file mode 100644 index 00000000..526a0342 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssemblerTest.java @@ -0,0 +1,70 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MonthlyReportComparisonInputAssemblerTest { + + @Test + void 직전_리포트와_현재_감정_통계로_비교_입력을_조립한다() { + MonthlyReportV2 previousReport = mock(MonthlyReportV2.class); + TypeEmotionStatsContent previousEmotionStats = emotionStats(57, List.of( + emotion("ACHIEVEMENT", "성취", 20), + emotion("INTEREST", "흥미", 30), + emotion("DEPRESSION", "우울", 25) + )); + TypeEmotionStatsContent currentEmotionStats = emotionStats(71, List.of( + emotion("ACHIEVEMENT", "성취", 35), + emotion("INTEREST", "흥미", 22), + emotion("DEPRESSION", "우울", 15) + )); + when(previousReport.getId()).thenReturn(10L); + when(previousReport.getMonthStartDate()).thenReturn(LocalDate.of(2026, 4, 1)); + when(previousReport.getMonthEndDate()).thenReturn(LocalDate.of(2026, 4, 30)); + when(previousReport.getEmotionStats()).thenReturn(previousEmotionStats); + + MonthlyReportComparisonInputDto result = MonthlyReportComparisonInputAssembler.assemble( + previousReport, + currentEmotionStats + ); + + assertThat(result.previousReportId()).isEqualTo(10L); + assertThat(result.previousMonthStartDate()).isEqualTo(LocalDate.of(2026, 4, 1)); + assertThat(result.previousMonthEndDate()).isEqualTo(LocalDate.of(2026, 4, 30)); + assertThat(result.currentPositivePercent()).isEqualTo(71); + assertThat(result.previousPositivePercent()).isEqualTo(57); + assertThat(result.positivePercentPointChange()).isEqualTo(14); + assertThat(result.previousContent()).isNotNull(); + assertThat(result.previousEmotionSummaryContent()).isNotNull(); + assertThat(result.emotionChanges()) + .extracting( + MonthlyReportComparisonInputDto.EmotionChange::emotionCode, + MonthlyReportComparisonInputDto.EmotionChange::changePercentPoint + ) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("ACHIEVEMENT", 15), + org.assertj.core.groups.Tuple.tuple("DEPRESSION", -10), + org.assertj.core.groups.Tuple.tuple("INTEREST", -8) + ); + } + + private TypeEmotionStatsContent emotionStats( + int positivePercent, + List emotions + ) { + return new TypeEmotionStatsContent(10, "ACHIEVEMENT", positivePercent, emotions); + } + + private TypeEmotionStatsContent.EmotionStat emotion(String code, String name, int percent) { + return new TypeEmotionStatsContent.EmotionStat(code, name, 1, percent); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculatorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculatorTest.java new file mode 100644 index 00000000..d716ac54 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculatorTest.java @@ -0,0 +1,93 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlySocialSummaryCalculatorTest { + + @Test + void hides_summary_when_total_interactions_are_less_than_ten() { + MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate( + 5, + List.of(count(1L, "가", 5)), + List.of(count(2L, "나", 4)) + ); + + assertThat(result.visible()).isFalse(); + assertThat(result.month()).isEqualTo(5); + assertThat(result.likeRanking()).isEmpty(); + assertThat(result.commentRanking()).isEmpty(); + } + + @Test + void hides_summary_when_either_interaction_has_no_friend() { + MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate( + 5, + List.of(count(1L, "가", 10)), + List.of() + ); + + assertThat(result.visible()).isFalse(); + assertThat(result.likeRanking()).isEmpty(); + assertThat(result.commentRanking()).isEmpty(); + } + + @Test + void exposes_summary_at_ten_interactions_and_keeps_available_friends() { + MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate( + 5, + List.of(count(1L, "가", 6)), + List.of(count(2L, "나", 3), count(3L, "다", 1)) + ); + + assertThat(result.visible()).isTrue(); + assertThat(result.likeRanking()).extracting(MonthlySocialRankingItem::nickname) + .containsExactly("가"); + assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::nickname) + .containsExactly("나", "다"); + assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::displayOrder) + .containsExactly(1, 2); + assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::topRank) + .containsExactly(true, false); + } + + @Test + void sorts_by_count_then_korean_name_and_highlights_all_joint_top_friends() { + MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate( + 5, + List.of( + count(4L, "라", 4), + count(2L, "나", 4), + count(1L, "가", 4), + count(3L, "다", 3) + ), + List.of( + count(6L, "다", 5), + count(5L, "나", 5), + count(7L, "가", 4), + count(8L, "라", 4) + ) + ); + + assertThat(result.likeRanking()).extracting(MonthlySocialRankingItem::nickname) + .containsExactly("가", "나", "라"); + assertThat(result.likeRanking()).extracting(MonthlySocialRankingItem::displayOrder) + .containsExactly(1, 2, 3); + assertThat(result.likeRanking()).allMatch(MonthlySocialRankingItem::topRank); + + assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::nickname) + .containsExactly("나", "다", "가"); + assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::topRank) + .containsExactly(true, true, false); + } + + private MonthlySocialInteractionCountDto count(Long userId, String nickname, long count) { + return new MonthlySocialInteractionCountDto(userId, nickname, null, null, count); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2Test.java new file mode 100644 index 00000000..f5ac586c --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2Test.java @@ -0,0 +1,147 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.listener; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyImagePresetAssignmentService; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxServiceV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImageVisualPreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyQueryRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlySocialSummaryService; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlyWeeklySummariesService; +import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportImageStorage; +import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClientV2; +import com.devkor.ifive.nadab.domain.monthlyreport.infra.OpenAiImageClient; +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlyReportGenerationListenerV2Test { + + @Mock MonthlyQueryRepository monthlyQueryRepository; + @Mock MonthlyReportV2Repository monthlyReportV2Repository; + @Mock MonthlyReportLlmClientV2 monthlyReportLlmClientV2; + @Mock OpenAiImageClient openAiImageClient; + @Mock MonthlyReportImageStorage monthlyReportImageStorage; + @Mock MonthlyReportTxServiceV2 monthlyReportTxServiceV2; + @Mock MonthlyImagePresetAssignmentService monthlyImagePresetAssignmentService; + @Mock MonthlyWeeklySummariesService monthlyWeeklySummariesService; + @Mock MonthlySocialSummaryService monthlySocialSummaryService; + @Mock ReportGenerationLogRecorder reportGenerationLogRecorder; + @Mock ApplicationEventPublisher eventPublisher; + + @InjectMocks + MonthlyReportGenerationListenerV2 listener; + + @Test + void 할당된_프리셋으로_이미지_prompt_context를_생성한다() { + MonthlyReportGenerationRequestedEventDtoV2 event = + new MonthlyReportGenerationRequestedEventDtoV2(10L, 1L, 100L, null); + MonthlyReportV2Content content = new MonthlyReportV2Content( + "월간 요약", + "코멘트 요약", + "성장", + "", + new StyledText(List.of()), + new StyledText(List.of()) + ); + AiMonthlyReportResultDto result = new AiMonthlyReportResultDto(content, null); + + when(monthlyQueryRepository.findMonthlyInputs(anyLong(), any(), any())).thenReturn(List.of()); + when(monthlyQueryRepository.countCompletedEmotionStatsByRange(anyLong(), any(), any(), any())) + .thenReturn(List.of()); + when(monthlyQueryRepository.countCompletedInterestStatsByRange(anyLong(), any(), any(), any())) + .thenReturn(List.of()); + when(monthlyWeeklySummariesService.buildWeeklySummaries(anyLong(), any())).thenReturn(""); + when(monthlySocialSummaryService.buildSocialSummary(anyLong(), any())) + .thenReturn(MonthlySocialSummaryContent.empty(1)); + when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any())) + .thenReturn(200L, 201L, 202L, 203L); + when(monthlyReportLlmClientV2.generate(any(), any(), any(), any(), any(), any())) + .thenReturn(new LlmGenerationResult<>(result, new LlmTokenUsage(100L, 50L, 150L, 30L))); + when(monthlyImagePresetAssignmentService.getOrAssignVisualPreset(1L, 10L)) + .thenReturn(new MonthlyImageVisualPreset( + MonthlyImageStylePreset.INK_WASH, + MonthlyImageColorPalette.OCEAN_LIGHT + )); + when(openAiImageClient.generateBase64Image(anyLong(), any(MonthlyImagePromptContext.class))) + .thenReturn("base64-image"); + when(monthlyReportImageStorage.uploadBase64Webp(1L, 10L, "base64-image")) + .thenReturn("monthly/1/10.webp"); + + listener.handle(event); + + ArgumentCaptor contextCaptor = + ArgumentCaptor.forClass(MonthlyImagePromptContext.class); + verify(openAiImageClient).generateBase64Image(anyLong(), contextCaptor.capture()); + MonthlyImagePromptContext context = contextCaptor.getValue(); + assertThat(context.summary()).isEqualTo("월간 요약"); + assertThat(context.commentSummary()).isEqualTo("코멘트 요약"); + assertThat(context.dominantKeyword()).isEqualTo("성장"); + assertThat(context.stylePreset()).isEqualTo(MonthlyImageStylePreset.INK_WASH); + assertThat(context.colorPalette()).isEqualTo(MonthlyImageColorPalette.OCEAN_LIGHT); + InOrder inOrder = inOrder(reportGenerationLogRecorder); + inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(200L, 100L, 50L, 150L, 30L); + inOrder.verify(reportGenerationLogRecorder).succeed(200L); + verify(monthlyReportTxServiceV2).confirmMonthly(10L, 100L, "monthly/1/10.webp"); + } + + @Test + void 프리셋_할당에_실패하면_이미지_생성을_호출하지_않고_환불한다() { + MonthlyReportGenerationRequestedEventDtoV2 event = + new MonthlyReportGenerationRequestedEventDtoV2(10L, 1L, 100L, null); + MonthlyReportV2Content content = new MonthlyReportV2Content( + "월간 요약", + "코멘트 요약", + "성장", + "", + new StyledText(List.of()), + new StyledText(List.of()) + ); + + when(monthlyQueryRepository.findMonthlyInputs(anyLong(), any(), any())).thenReturn(List.of()); + when(monthlyQueryRepository.countCompletedEmotionStatsByRange(anyLong(), any(), any(), any())) + .thenReturn(List.of()); + when(monthlyQueryRepository.countCompletedInterestStatsByRange(anyLong(), any(), any(), any())) + .thenReturn(List.of()); + when(monthlyWeeklySummariesService.buildWeeklySummaries(anyLong(), any())).thenReturn(""); + when(monthlySocialSummaryService.buildSocialSummary(anyLong(), any())) + .thenReturn(MonthlySocialSummaryContent.empty(1)); + when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any())) + .thenReturn(200L, 201L); + when(monthlyReportLlmClientV2.generate(any(), any(), any(), any(), any(), any())) + .thenReturn(new LlmGenerationResult<>(new AiMonthlyReportResultDto(content, null), LlmTokenUsage.empty())); + when(monthlyImagePresetAssignmentService.getOrAssignVisualPreset(1L, 10L)) + .thenThrow(new IllegalStateException("preset assignment failed")); + + listener.handle(event); + + verify(monthlyReportTxServiceV2).failAndRefundMonthlyWithImage(1L, 10L, 100L); + verifyNoInteractions(openAiImageClient); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContentTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContentTest.java new file mode 100644 index 00000000..e262fe6f --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContentTest.java @@ -0,0 +1,52 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlyEmotionComparisonContentTest { + + @Test + void 비교_입력으로부터_저장용_스냅샷을_생성한다() { + TypeEmotionStatsContent previousEmotionStats = new TypeEmotionStatsContent( + 10, + "ACHIEVEMENT", + 57, + List.of(new TypeEmotionStatsContent.EmotionStat( + "ACHIEVEMENT", + "성취", + 5, + 50 + )) + ); + MonthlyReportComparisonInputDto input = new MonthlyReportComparisonInputDto( + 10L, + LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 30), + null, + null, + previousEmotionStats, + 71, + 57, + 14, + List.of() + ); + + MonthlyEmotionComparisonContent result = MonthlyEmotionComparisonContent.from(input); + + assertThat(result.previousReportId()).isEqualTo(10L); + assertThat(result.previousMonth()).isEqualTo(4); + assertThat(result.previousEmotionStats().positivePercent()).isEqualTo(57); + assertThat(result.positivePercentPointChange()).isEqualTo(14); + } + + @Test + void BASELINE_입력은_비교_스냅샷을_생성하지_않는다() { + assertThat(MonthlyEmotionComparisonContent.from(null)).isNull(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContentTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContentTest.java new file mode 100644 index 00000000..cd11720f --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContentTest.java @@ -0,0 +1,31 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.content; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlySocialSummaryContentTest { + + @Test + void normalized_limits_rankings_and_sanitizes_month() { + MonthlySocialSummaryContent content = new MonthlySocialSummaryContent( + true, + 13, + List.of(item(1), item(2), item(3), item(4)), + null + ); + + MonthlySocialSummaryContent normalized = content.normalized(); + + assertThat(normalized.month()).isEqualTo(12); + assertThat(normalized.likeRanking()).extracting(MonthlySocialRankingItem::displayOrder) + .containsExactly(1, 2, 3); + assertThat(normalized.commentRanking()).isEmpty(); + } + + private MonthlySocialRankingItem item(int rank) { + return new MonthlySocialRankingItem(rank, (long) rank, "friend" + rank, null, null, rank == 1); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryServiceTest.java new file mode 100644 index 00000000..eb4d92d3 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryServiceTest.java @@ -0,0 +1,58 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlySocialQueryRepository; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlySocialSummaryServiceTest { + + @Mock + MonthlySocialQueryRepository monthlySocialQueryRepository; + + @Test + void builds_summary_with_seoul_month_boundaries() { + MonthlySocialSummaryService service = new MonthlySocialSummaryService(monthlySocialQueryRepository); + MonthRangeDto range = new MonthRangeDto( + LocalDate.of(2026, 5, 1), + LocalDate.of(2026, 5, 31) + ); + OffsetDateTime startAt = OffsetDateTime.of(2026, 5, 1, 0, 0, 0, 0, ZoneOffset.ofHours(9)); + OffsetDateTime endAt = OffsetDateTime.of(2026, 6, 1, 0, 0, 0, 0, ZoneOffset.ofHours(9)); + List likes = List.of(count(1L, "가", 6)); + List comments = List.of(count(2L, "나", 4)); + when(monthlySocialQueryRepository.countReceivedLikesByFriend(10L, startAt, endAt)) + .thenReturn(likes); + when(monthlySocialQueryRepository.countReceivedCommentsByFriend(10L, startAt, endAt)) + .thenReturn(comments); + + MonthlySocialSummaryContent result = service.buildSocialSummary(10L, range); + + assertThat(result.visible()).isTrue(); + assertThat(result.month()).isEqualTo(5); + assertThat(result.likeRanking()).singleElement() + .satisfies(item -> assertThat(item.nickname()).isEqualTo("가")); + assertThat(result.commentRanking()).singleElement() + .satisfies(item -> assertThat(item.nickname()).isEqualTo("나")); + verify(monthlySocialQueryRepository).countReceivedLikesByFriend(10L, startAt, endAt); + verify(monthlySocialQueryRepository).countReceivedCommentsByFriend(10L, startAt, endAt); + } + + private MonthlySocialInteractionCountDto count(Long userId, String nickname, long count) { + return new MonthlySocialInteractionCountDto(userId, nickname, null, null, count); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2Test.java new file mode 100644 index 00000000..ac66f8b5 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2Test.java @@ -0,0 +1,184 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.infra; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.shared.reportcontent.Mark; +import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; +import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MonthlyReportLlmClientV2Test { + + @Mock + private MonthlyReportPromptLoader promptLoader; + @Mock + private LlmRouter llmRouter; + + private MonthlyReportLlmClientV2 client; + + @BeforeEach + void setUp() { + client = new MonthlyReportLlmClientV2(promptLoader, new ObjectMapper(), llmRouter); + } + + @Test + void 비교_입력이_없으면_BASELINE_프롬프트를_사용한다() { + when(promptLoader.loadV2BaselinePrompt()).thenReturn( + "baseline {monthStartDate} {monthEndDate} {weeklySummaries} {representativeEntries} {emotionStats}" + ); + + String prompt = client.buildPrompt( + "2026-05-01", + "2026-05-31", + "weekly", + "entries", + emptyEmotionStats(), + null + ); + + assertThat(prompt).startsWith("baseline 2026-05-01 2026-05-31 weekly entries"); + verify(promptLoader, never()).loadV2ComparisonPrompt(); + } + + @Test + void 비교_입력이_있으면_COMPARISON_프롬프트에_JSON을_주입한다() { + when(promptLoader.loadV2ComparisonPrompt()).thenReturn("comparison {comparisonInput}"); + MonthlyReportComparisonInputDto comparisonInput = new MonthlyReportComparisonInputDto( + 10L, + null, + null, + null, + null, + null, + 71, + 57, + 14, + List.of() + ); + + String prompt = client.buildPrompt( + "2026-05-01", + "2026-05-31", + "weekly", + "entries", + emptyEmotionStats(), + comparisonInput + ); + + assertThat(prompt) + .startsWith("comparison {") + .contains("\"previousReportId\":10") + .contains("\"positivePercentPointChange\":14") + .doesNotContain("{comparisonInput}"); + verify(promptLoader, never()).loadV2BaselinePrompt(); + } + + @Test + void COMPARISON_emotionTrend는_감정_양상_문장의_길이를_검증한다() { + client.validateComparisonEmotionTrend("성취와 의지가 증가했어요"); + + assertThatThrownBy(() -> client.validateComparisonEmotionTrend("변했어요")) + .isInstanceOf(AiResponseParseException.class); + } + + @Test + void 공백_전용_segment는_직전_segment에_합쳐서_정규화한다() { + StyledText input = new StyledText(List.of( + new Segment("높은 기준과 기대를 가지고", List.of(Mark.BOLD, Mark.HIGHLIGHT)), + new Segment(" ", List.of()), + new Segment("의미 있는 결과를 만들고 싶어 해요.", List.of(Mark.BOLD, Mark.HIGHLIGHT)) + )); + + StyledText result = client.normalizeStyledTextSegments(input); + + assertThat(result.segments()).hasSize(2); + assertThat(result.segments().get(0).text()).isEqualTo("높은 기준과 기대를 가지고 "); + assertThat(result.plainText()).isEqualTo("높은 기준과 기대를 가지고 의미 있는 결과를 만들고 싶어 해요."); + } + + @Test + void 빈_segment는_제거하고_null_marks는_빈_배열로_정규화한다() { + StyledText input = new StyledText(List.of( + new Segment("", List.of()), + new Segment("의미 있는 문장", null) + )); + + StyledText result = client.normalizeStyledTextSegments(input); + + assertThat(result.segments()).containsExactly(new Segment("의미 있는 문장", List.of())); + } + + @Test + void COMPARISON_감정_분석문은_BOLD와_HIGHLIGHT_조합을_허용한다() { + StyledText valid = new StyledText(List.of( + new Segment("직전 기간보다 선택을 망설이는 시간이 줄었고, ", List.of()), + new Segment("도전", List.of(Mark.BOLD, Mark.HIGHLIGHT)), + new Segment("을 행동으로 옮기는 흐름이 여러 상황에서 더 분명하게 이어졌어요.", List.of()) + )); + client.validateComparisonEmotionSummary(valid, "도전"); + + StyledText invalid = new StyledText(List.of( + new Segment("도전", List.of(Mark.HIGHLIGHT)), + new Segment("을 행동으로 옮기는 흐름이 더 분명해졌어요.", List.of()) + )); + assertThatThrownBy(() -> client.validateComparisonEmotionSummary(invalid, "도전")) + .isInstanceOf(AiResponseParseException.class); + } + + @Test + void COMPARISON_감정_분석문은_프롬프트보다_넓은_길이_범위를_허용한다() { + client.validateLength( + "가".repeat(150), + "나".repeat(150), + "다".repeat(130), + true + ); + + assertThatThrownBy(() -> client.validateLength( + "가".repeat(150), + "나".repeat(150), + "다".repeat(151), + true + )).isInstanceOfSatisfying( + AiResponseParseException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.MONTHLY_REPORT_EMOTION_SUMMARY_LENGTH_INVALID) + ); + } + + @Test + void comment_길이_오류는_comment_전용_에러_코드를_사용한다() { + assertThatThrownBy(() -> client.validateLength( + "가".repeat(150), + "나".repeat(149), + "다".repeat(50), + true + )).isInstanceOfSatisfying( + AiResponseParseException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.MONTHLY_REPORT_COMMENT_LENGTH_INVALID) + ); + } + + private TypeEmotionStatsContent emptyEmotionStats() { + return new TypeEmotionStatsContent(0, null, 0, List.of()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClientTest.java new file mode 100644 index 00000000..2ac7fc29 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClientTest.java @@ -0,0 +1,55 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.infra; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyImagePromptComposer; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OpenAiImageClientTest { + + @Test + void context로_조립한_프롬프트를_OpenAI_요청에_사용한다() { + ExchangeFunction exchangeFunction = mock(ExchangeFunction.class); + when(exchangeFunction.exchange(any())).thenReturn(Mono.just( + ClientResponse.create(HttpStatus.OK) + .header("Content-Type", "application/json") + .body("{\"data\":[{\"b64_json\":\"aW1hZ2U=\"}]}") + .build() + )); + WebClient.Builder webClientBuilder = WebClient.builder().exchangeFunction(exchangeFunction); + MonthlyImagePromptComposer composer = mock(MonthlyImagePromptComposer.class); + MonthlyImagePromptContext context = new MonthlyImagePromptContext( + "요약", "코멘트", "키워드", + LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 31), + MonthlyImageStylePreset.INK_WASH, + MonthlyImageColorPalette.OCEAN_LIGHT + ); + when(composer.compose(context)).thenReturn("composed-prompt"); + OpenAiImageClient client = new OpenAiImageClient(webClientBuilder, composer); + ReflectionTestUtils.setField(client, "apiKey", "test-key"); + ReflectionTestUtils.setField(client, "model", "test-model"); + ReflectionTestUtils.setField(client, "size", "1024x1024"); + ReflectionTestUtils.setField(client, "quality", "medium"); + ReflectionTestUtils.setField(client, "outputFormat", "webp"); + + String result = client.generateBase64Image(1L, context); + + assertThat(result).isEqualTo("aW1hZ2U="); + verify(composer).compose(context); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java index ecd85e8e..a8428f0b 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java @@ -81,6 +81,27 @@ void find_by_status() { .containsOnly(ReportGenerationLogStatus.FAILED); } + @Test + void save_token_usage() { + // given + User user = new UserBuilder(em).build(); + ReportGenerationLog log = startLog(user, ReportGenerationType.DAILY, 101L, ReportGenerationStep.DAILY_GENERATE); + log.recordTokenUsage(100L, 50L, 150L, 30L); + ReportGenerationLog saved = reportGenerationLogRepository.save(log); + + em.flush(); + em.clear(); + + // when + ReportGenerationLog found = reportGenerationLogRepository.findById(saved.getId()).orElseThrow(); + + // then + assertThat(found.getInputTokens()).isEqualTo(100L); + assertThat(found.getOutputTokens()).isEqualTo(50L); + assertThat(found.getTotalTokens()).isEqualTo(150L); + assertThat(found.getThinkingTokens()).isEqualTo(30L); + } + private ReportGenerationLog startLog( User user, ReportGenerationType reportType, diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java index 019aa218..d3c0f3d3 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java @@ -81,6 +81,38 @@ void succeed_marks_log_succeeded() { assertThat(log.getElapsedMs()).isNotNull(); } + @Test + void record_token_usage_updates_token_fields() { + // given + ReportGenerationLog log = startLog(); + when(reportGenerationLogRepository.findById(1L)).thenReturn(Optional.of(log)); + + // when + reportGenerationLogService.recordTokenUsage(1L, 100L, 50L, 150L, 30L); + + // then + assertThat(log.getInputTokens()).isEqualTo(100L); + assertThat(log.getOutputTokens()).isEqualTo(50L); + assertThat(log.getTotalTokens()).isEqualTo(150L); + assertThat(log.getThinkingTokens()).isEqualTo(30L); + } + + @Test + void record_token_usage_allows_null_values() { + // given + ReportGenerationLog log = startLog(); + when(reportGenerationLogRepository.findById(1L)).thenReturn(Optional.of(log)); + + // when + reportGenerationLogService.recordTokenUsage(1L, null, null, null, null); + + // then + assertThat(log.getInputTokens()).isNull(); + assertThat(log.getOutputTokens()).isNull(); + assertThat(log.getTotalTokens()).isNull(); + assertThat(log.getThinkingTokens()).isNull(); + } + @Test void fail_marks_log_failed_with_external_status() { // given diff --git a/src/test/java/com/devkor/ifive/nadab/domain/test/application/TestUserServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/test/application/TestUserServiceTest.java new file mode 100644 index 00000000..69d4d09b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/test/application/TestUserServiceTest.java @@ -0,0 +1,84 @@ +package com.devkor.ifive.nadab.domain.test.application; + +import com.devkor.ifive.nadab.domain.terms.application.TermsCommandService; +import com.devkor.ifive.nadab.domain.test.api.dto.request.CreateTestUserRequest; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.user.core.service.UserInterestService; +import com.devkor.ifive.nadab.domain.user.core.service.UserProfileUpdateService; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestUserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private UserWalletRepository userWalletRepository; + @Mock + private UserProfileUpdateService userProfileUpdateService; + @Mock + private UserInterestService userInterestService; + @Mock + private TermsCommandService termsCommandService; + @Mock + private PasswordEncoder passwordEncoder; + + private TestUserService testUserService; + + @BeforeEach + void setUp() { + testUserService = new TestUserService( + userRepository, + userWalletRepository, + userProfileUpdateService, + userInterestService, + termsCommandService, + passwordEncoder + ); + ReflectionTestUtils.setField(testUserService, "testUserPassword", "test-password"); + } + + @Test + void createTestUser_sets_terms_interest_and_default_crystals() { + CreateTestUserRequest request = new CreateTestUserRequest( + "test@example.com", + "tester" + ); + when(passwordEncoder.encode("test-password")).thenReturn("encoded-password"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + }); + + var response = testUserService.createTestUser(request); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userInterestService).updateUserInterest(userCaptor.capture(), eq(InterestCode.PREFERENCE)); + verify(termsCommandService).saveConsents(1L, true, true, true, false); + + ArgumentCaptor walletCaptor = ArgumentCaptor.forClass(UserWallet.class); + verify(userWalletRepository).save(walletCaptor.capture()); + assertThat(walletCaptor.getValue().getCrystalBalance()).isEqualTo(1_000L); + assertThat(userCaptor.getValue().getSignupStatus()).isEqualTo(SignupStatusType.COMPLETED); + assertThat(response.signupStatus()).isEqualTo(SignupStatusType.COMPLETED.name()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListenerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListenerTest.java new file mode 100644 index 00000000..9e4b95c1 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListenerTest.java @@ -0,0 +1,174 @@ +package com.devkor.ifive.nadab.domain.typereport.application.listener; + +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.typereport.application.TypeReportTxService; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.dto.AnalysisTypeCandidateDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.EvidenceCardDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.PatternExtractionResultDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReportContentDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReportGenerationRequestedEventDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeSelectionResultDto; +import com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReport; +import com.devkor.ifive.nadab.domain.typereport.core.repository.AnalysisTypeRepository; +import com.devkor.ifive.nadab.domain.typereport.core.repository.TypeDailyEntryQueryRepository; +import com.devkor.ifive.nadab.domain.typereport.core.repository.TypeReportRepository; +import com.devkor.ifive.nadab.domain.typereport.core.service.EvidenceCardGenerationService; +import com.devkor.ifive.nadab.domain.typereport.core.service.PatternExtractionService; +import com.devkor.ifive.nadab.domain.typereport.core.service.TypeReportContentGenerationService; +import com.devkor.ifive.nadab.domain.typereport.core.service.TypeSelectionService; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TypeReportGenerationListenerTest { + + @Mock + TypeReportTxService typeReportTxService; + + @Mock + TypeReportRepository typeReportRepository; + + @Mock + TypeDailyEntryQueryRepository typeDailyEntryQueryRepository; + + @Mock + AnalysisTypeRepository analysisTypeRepository; + + @Mock + EvidenceCardGenerationService evidenceCardGenerationService; + + @Mock + PatternExtractionService patternExtractionService; + + @Mock + TypeSelectionService typeSelectionService; + + @Mock + TypeReportContentGenerationService typeReportContentGenerationService; + + @Mock + ReportGenerationLogRecorder reportGenerationLogRecorder; + + @Mock + ApplicationEventPublisher eventPublisher; + + @InjectMocks + TypeReportGenerationListener listener; + + @Test + void handle_records_token_usage_for_each_llm_step_before_succeeding_logs() { + // given + TypeReportGenerationRequestedEventDto event = + new TypeReportGenerationRequestedEventDto(10L, 1L, 100L, null); + TypeReport report = mock(TypeReport.class); + DailyEntryDto entry = dailyEntry(); + EvidenceCardDto card = new EvidenceCardDto("D1", LocalDate.of(2026, 1, 1), "card"); + PatternExtractionResultDto patterns = new PatternExtractionResultDto( + List.of(new PatternExtractionResultDto.PatternDto("pattern", List.of("D1"), "note")) + ); + AnalysisTypeCandidateDto selectedType = new AnalysisTypeCandidateDto( + "TYPE_A", "name", "description", "#a", "#b", "#c" + ); + TypeSelectionResultDto selection = new TypeSelectionResultDto( + "TYPE_A", + 90, + List.of(new TypeSelectionResultDto.BecauseDto("pattern", List.of("D1"))) + ); + TypeReportContentDto content = typeReportContent(); + + when(typeReportRepository.findById(10L)).thenReturn(Optional.of(report)); + when(report.getInterestCode()).thenReturn(InterestCode.ROUTINE); + when(typeDailyEntryQueryRepository.findRecentDailyEntriesByInterest(eq(1L), eq(InterestCode.ROUTINE), any())) + .thenReturn(List.of(entry)); + when(typeDailyEntryQueryRepository.countCompletedEmotionStatsByInterest(anyLong(), eq(InterestCode.ROUTINE), any())) + .thenReturn(List.of()); + when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any())) + .thenReturn(200L, 201L, 202L, 203L, 204L); + when(evidenceCardGenerationService.generate(List.of(entry))) + .thenReturn(new LlmGenerationResult<>(List.of(card), new LlmTokenUsage(10L, 5L, 15L, null))); + when(patternExtractionService.extract(List.of(card))) + .thenReturn(new LlmGenerationResult<>(patterns, new LlmTokenUsage(20L, 6L, 26L, null))); + when(analysisTypeRepository.findCandidatesByInterestCode(InterestCode.ROUTINE)) + .thenReturn(List.of(selectedType)); + when(typeSelectionService.select(List.of(selectedType), patterns)) + .thenReturn(new LlmGenerationResult<>(selection, new LlmTokenUsage(30L, 7L, 37L, null))); + when(typeReportContentGenerationService.generate(selectedType, patterns, List.of(card), TypeContentFactory.emptyEmotionStats().normalized(), "TYPE_A")) + .thenReturn(new LlmGenerationResult<>(content, new LlmTokenUsage(40L, 8L, 80L, 32L))); + + // when + listener.handle(event); + + // then + InOrder inOrder = inOrder(reportGenerationLogRecorder); + inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(200L, 10L, 5L, 15L, null); + inOrder.verify(reportGenerationLogRecorder).succeed(200L); + inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(201L, 20L, 6L, 26L, null); + inOrder.verify(reportGenerationLogRecorder).succeed(201L); + inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(202L, 30L, 7L, 37L, null); + inOrder.verify(reportGenerationLogRecorder).succeed(202L); + inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(203L, 40L, 8L, 80L, 32L); + inOrder.verify(reportGenerationLogRecorder).succeed(203L); + inOrder.verify(reportGenerationLogRecorder).succeed(204L); + + verify(typeReportTxService).confirmType( + 10L, + 100L, + null, + "TYPE_A", + content.typeAnalysis(), + content.typeAnalysisContent(), + content.emotionSummaryContent(), + TypeContentFactory.emptyEmotionStats().normalized(), + "persona1", + "content1", + "persona2", + "content2" + ); + verify(eventPublisher).publishEvent(any(Object.class)); + } + + private DailyEntryDto dailyEntry() { + return new DailyEntryDto( + LocalDate.of(2026, 1, 1), + "question", + "answer", + "daily report", + null + ); + } + + private TypeReportContentDto typeReportContent() { + return new TypeReportContentDto( + "TYPE_A", + "type analysis", + TypeContentFactory.fromPlainText("type analysis").normalized(), + TypeContentFactory.emptyText().normalized(), + List.of( + new TypeReportContentDto.PersonaDto("persona1", "content1"), + new TypeReportContentDto.PersonaDto("persona2", "content2") + ) + ); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListenerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListenerTest.java new file mode 100644 index 00000000..847cf096 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListenerTest.java @@ -0,0 +1,94 @@ +package com.devkor.ifive.nadab.domain.weeklyreport.application.listener; + +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.weeklyreport.application.WeeklyReportTxService; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportGenerationRequestedEventDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyQueryRepository; +import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto; +import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContent; +import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; +import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WeeklyReportGenerationListenerTest { + + @Mock + WeeklyQueryRepository weeklyQueryRepository; + + @Mock + WeeklyReportLlmClient weeklyReportLlmClient; + + @Mock + WeeklyReportTxService weeklyReportTxService; + + @Mock + ReportGenerationLogRecorder reportGenerationLogRecorder; + + @Mock + ApplicationEventPublisher eventPublisher; + + @InjectMocks + WeeklyReportGenerationListener listener; + + @Test + void handle_records_token_usage_before_generation_log_succeeds() { + WeeklyReportGenerationRequestedEventDto event = + new WeeklyReportGenerationRequestedEventDto(10L, 1L, 100L); + AiReportResultDto aiResult = aiResult(); + + when(weeklyQueryRepository.findWeeklyInputs(anyLong(), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(dailyEntry())); + when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any())) + .thenReturn(200L, 201L); + when(weeklyReportLlmClient.generate(anyString(), anyString(), anyString())) + .thenReturn(new LlmGenerationResult<>(aiResult, new LlmTokenUsage(100L, 50L, 150L, 30L))); + + listener.handle(event); + + InOrder inOrder = inOrder(reportGenerationLogRecorder); + inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(200L, 100L, 50L, 150L, 30L); + inOrder.verify(reportGenerationLogRecorder).succeed(200L); + verify(weeklyReportTxService).confirmWeekly(10L, 100L, aiResult.content()); + verify(eventPublisher).publishEvent(any(Object.class)); + } + + private DailyEntryDto dailyEntry() { + return new DailyEntryDto( + LocalDate.of(2026, 1, 1), + "question", + "answer", + "message", + null + ); + } + + private AiReportResultDto aiResult() { + ReportContent content = new ReportContent( + "steady growth", + new StyledText(List.of(new Segment("a".repeat(150), List.of()))), + new StyledText(List.of(new Segment("b".repeat(80), List.of()))) + ); + return new AiReportResultDto(content, content.discovered().plainText(), content.improve().plainText()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClientTest.java new file mode 100644 index 00000000..04258dff --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClientTest.java @@ -0,0 +1,141 @@ +package com.devkor.ifive.nadab.domain.weeklyreport.infra; + +import com.devkor.ifive.nadab.global.core.prompt.weekly.WeeklyReportPromptLoader; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto; +import com.devkor.ifive.nadab.global.shared.reportcontent.LlmResultDto; +import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContent; +import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; +import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WeeklyReportLlmClientTest { + + @Mock + WeeklyReportPromptLoader weeklyReportPromptLoader; + + @Mock + LlmRouter llmRouter; + + @Mock + ChatClient chatClient; + + @Mock + ChatClient.ChatClientRequestSpec requestSpec; + + @Mock + ChatClient.CallResponseSpec callResponseSpec; + + @Test + void generate_returns_content_with_token_usage() throws Exception { + WeeklyReportLlmClient client = new WeeklyReportLlmClient( + weeklyReportPromptLoader, + new ObjectMapper(), + llmRouter + ); + + when(weeklyReportPromptLoader.loadPrompt()) + .thenReturn("start={weekStartDate}, end={weekEndDate}, entries={entries}"); + when(llmRouter.route(LlmProvider.GEMINI)).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.user(anyString())).thenReturn(requestSpec); + when(requestSpec.options(any())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(callResponseSpec); + when(callResponseSpec.chatResponse()).thenReturn(chatResponse( + reportJson("steady growth", "a".repeat(150), "b".repeat(80)), + new DefaultUsage(100, 50, 150) + )); + + LlmGenerationResult result = + client.generate("2026-01-01", "2026-01-07", "entries"); + + assertThat(result.content().content().summary()).isEqualTo("steady growth"); + assertThat(result.content().discovered()).hasSize(150); + assertThat(result.content().improve()).hasSize(80); + assertThat(result.tokenUsage().inputTokens()).isEqualTo(100L); + assertThat(result.tokenUsage().outputTokens()).isEqualTo(50L); + assertThat(result.tokenUsage().totalTokens()).isEqualTo(150L); + } + + @Test + void generate_adds_rewrite_token_usage() throws Exception { + WeeklyReportLlmClient client = new WeeklyReportLlmClient( + weeklyReportPromptLoader, + new ObjectMapper(), + llmRouter + ); + + when(weeklyReportPromptLoader.loadPrompt()) + .thenReturn("start={weekStartDate}, end={weekEndDate}, entries={entries}"); + when(llmRouter.route(LlmProvider.GEMINI)).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.user(anyString())).thenReturn(requestSpec); + when(requestSpec.options(any())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(callResponseSpec); + when(callResponseSpec.chatResponse()).thenReturn( + chatResponse( + reportJson("steady growth", "a".repeat(20), "b".repeat(80)), + new DefaultUsage(100, 50, 150) + ), + chatResponse( + styledTextJson("c".repeat(150)), + new DefaultUsage(20, 10, 30) + ) + ); + + LlmGenerationResult result = + client.generate("2026-01-01", "2026-01-07", "entries"); + + assertThat(result.content().discovered()).hasSize(150); + assertThat(result.tokenUsage().inputTokens()).isEqualTo(120L); + assertThat(result.tokenUsage().outputTokens()).isEqualTo(60L); + assertThat(result.tokenUsage().totalTokens()).isEqualTo(180L); + } + + private ChatResponse chatResponse(String content, DefaultUsage usage) { + return new ChatResponse( + List.of(new Generation(new AssistantMessage(content))), + ChatResponseMetadata.builder() + .usage(usage) + .build() + ); + } + + private String reportJson(String summary, String discovered, String improve) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(new LlmResultDto( + summary, + new StyledText(List.of(new Segment(discovered, List.of()))), + new StyledText(List.of(new Segment(improve, List.of()))) + )); + } + + private String styledTextJson(String text) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(new ReportContent( + "", + new StyledText(List.of(new Segment(text, List.of()))), + null + ).discovered()); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractorTest.java b/src/test/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractorTest.java new file mode 100644 index 00000000..907da467 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractorTest.java @@ -0,0 +1,149 @@ +package com.devkor.ifive.nadab.global.infra.llm; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.metadata.EmptyUsage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LlmTokenUsageExtractorTest { + + @Test + void extract_maps_prompt_completion_total_tokens() { + // given + ChatResponse response = new ChatResponse( + List.of(), + ChatResponseMetadata.builder() + .usage(new DefaultUsage(100, 50, 150)) + .build() + ); + + // when + LlmTokenUsage usage = LlmTokenUsageExtractor.extract(response); + + // then + assertThat(usage.inputTokens()).isEqualTo(100L); + assertThat(usage.outputTokens()).isEqualTo(50L); + assertThat(usage.totalTokens()).isEqualTo(150L); + assertThat(usage.thinkingTokens()).isNull(); + } + + @Test + void extract_maps_google_genai_thinking_tokens() { + // given + GoogleGenAiUsage googleUsage = new GoogleGenAiUsage( + 3163, + 369, + 6710, + 3178, + null, + null, + null, + null, + null, + null, + null, + null + ); + + // when + LlmTokenUsage usage = LlmTokenUsageExtractor.extract(googleUsage); + + // then + assertThat(usage.inputTokens()).isEqualTo(3163L); + assertThat(usage.outputTokens()).isEqualTo(369L); + assertThat(usage.totalTokens()).isEqualTo(6710L); + assertThat(usage.thinkingTokens()).isEqualTo(3178L); + } + + @Test + void extract_returns_empty_usage_when_response_is_null() { + // when + LlmTokenUsage usage = LlmTokenUsageExtractor.extract((ChatResponse) null); + + // then + assertThat(usage.inputTokens()).isNull(); + assertThat(usage.outputTokens()).isNull(); + assertThat(usage.totalTokens()).isNull(); + assertThat(usage.thinkingTokens()).isNull(); + assertThat(usage.thinkingTokens()).isNull(); + } + + @Test + void extract_returns_empty_usage_for_empty_usage_metadata() { + // when + LlmTokenUsage usage = LlmTokenUsageExtractor.extract(new EmptyUsage()); + + // then + assertThat(usage.inputTokens()).isNull(); + assertThat(usage.outputTokens()).isNull(); + assertThat(usage.totalTokens()).isNull(); + } + + @Test + void extract_preserves_partially_missing_usage_values() { + // given + Usage partialUsage = new Usage() { + @Override + public Integer getPromptTokens() { + return null; + } + + @Override + public Integer getCompletionTokens() { + return 50; + } + + @Override + public Integer getTotalTokens() { + return null; + } + + @Override + public Object getNativeUsage() { + return null; + } + }; + + // when + LlmTokenUsage usage = LlmTokenUsageExtractor.extract(partialUsage); + + // then + assertThat(usage.inputTokens()).isNull(); + assertThat(usage.outputTokens()).isEqualTo(50L); + assertThat(usage.totalTokens()).isNull(); + assertThat(usage.thinkingTokens()).isNull(); + } + + @Test + void token_usage_plus_sums_existing_values_and_preserves_missing_values() { + // when + LlmTokenUsage usage = new LlmTokenUsage(100L, null, 150L, 10L) + .plus(new LlmTokenUsage(30L, 20L, null, 5L)); + + // then + assertThat(usage.inputTokens()).isEqualTo(130L); + assertThat(usage.outputTokens()).isEqualTo(20L); + assertThat(usage.totalTokens()).isEqualTo(150L); + assertThat(usage.thinkingTokens()).isEqualTo(15L); + } + + @Test + void generation_result_defaults_null_token_usage_to_empty() { + // when + LlmGenerationResult result = new LlmGenerationResult<>("content", null); + + // then + assertThat(result.content()).isEqualTo("content"); + assertThat(result.tokenUsage().inputTokens()).isNull(); + assertThat(result.tokenUsage().outputTokens()).isNull(); + assertThat(result.tokenUsage().totalTokens()).isNull(); + assertThat(result.tokenUsage().thinkingTokens()).isNull(); + } +}