diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..99c2d677 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,90 @@ +language: "ko-KR" +early_access: false +tone_instructions: | + 당신은 '예비 / 초기 창업가를 위한 사업계획서 플랫폼 StarLight'의 도메인 전문가 겸 코드 리뷰어입니다. + 목표는 StarLight 벡엔드 개발자들이 Spring Boot/Java 21 기반으로 헥사고날 아키텍처를 통해서 API 서버를 개발하는데, 코드 품질을 개선하며 기능구현을 하도록 돕는 것입니다. + +reviews: + ## 리뷰 스타일 + profile: chill + + ## 시 생성 비활성화 + poem: false + + ## 자동으로 “Changes requested” 상태로 바꾸지 않음 + request_changes_workflow: false + + ## 상단에 리뷰 요약을 자동 삽입하도록 설정 + high_level_summary: true + + ## 리뷰 요약이 들어갈 PR 본문 내 플레이스홀더 지정 + high_level_summary_placeholder: "@coderabbit summary" + + ## 자동 추천 리뷰어 기능 비활성화 + suggested_reviewers: false + + ## 코드의 시퀀스 다이어그램 + sequence_diagrams: true + + ## 자동 리뷰 기능 설정 + auto_review: + ## PR이 생성될 때 자동으로 리뷰 수행 + enabled: true + + ## 변경된 부분만 재리뷰 + auto_incremental_review: true + + ## PR 제목에 아래 키워드가 포함되면 리뷰 제외 + ignore_title_keywords: [ + "Docs", + "Merge branch", + "Revert" + ] + +## 코드 리뷰 시 참조할 지식 베이스 설정 +knowledge_base: + ## 웹 검색을 통한 외부 자료 참조 허용 + web_search: + enabled: true + + ## 프로젝트 내부 문서를 기반으로 리뷰 + code_guidelines: + enabled: true + filePatterns: + - 개발가이드.md + - 도메인모델.md + - 용어사전.md + +## 정적 분석 / 린트 도구 +tools: + ## Java 코드 정적 분석 (PMD) + pmd: + enabled: true + + ## SQL 문법 검사 + sqlfluff: + enabled: true + + ## 보안 취약점 검사 도구 + gitleaks: + enabled: true + + ## 코드 패턴 기반 취약점 탐지 + semgrep: + enabled: true + + ## GitHub Actions 워크플로우 YAML 문법 검사 + actionlint: + enabled: true + + ## Dockerfile 린트 검사 + hadolint: + enabled: true + + ## YAML 전체 문법 검사 + yamllint: + enabled: true + +## ChatGPT 스타일의 대화형 응답을 자동으로 활성화 +chat: + auto_reply: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..94c2a073 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: 'FEAT: ' +labels: "✨ Feature" +assignees: '' + +--- + +## 📝 Description +> 이슈 설명 + +## 📝 Todo +- [ ] + +## 📝 참고 사항 +> + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..90659791 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## #️⃣연관된 이슈 +> close # + +## 📝작업 내용 +> 작업한 내용을 작성해주세요. + +## 🔎코드 설명 +> 코드에 대한 설명을 작성해주세요. + +## 💬고민사항 및 리뷰 요구사항 +> 고민사항 및 의견 받고 싶은 부분 있으면 적어두기 + +## 비고 (Optional) +> 참고했던 링크 등 참고 사항을 적어주세요. 코드 리뷰하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다. diff --git a/.github/workflows/ci-prd.yaml b/.github/workflows/ci-prd.yaml new file mode 100644 index 00000000..cae027c1 --- /dev/null +++ b/.github/workflows/ci-prd.yaml @@ -0,0 +1,79 @@ +name: Deployment Workflow + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + SPRING_PROFILES_ACTIVE: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.PAT }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew clean build --info --stacktrace --no-daemon + + - name: Docker login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Set image tag + id: vars + run: echo "IMAGE_TAG=${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Build Docker image + run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} -f deploy/Dockerfile . + + - name: Docker Hub push + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} + + - name: Checkout manifest repository + uses: actions/checkout@v4 + with: + repository: 'StartUpLight/STARLIGHT_MANIFEST' + token: ${{ secrets.PAT }} + path: 'manifest' + + - name: Update deployment.yml + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + sed -i "s|image:.*|image: ${DOCKERHUB_USERNAME}/startuplight-be:${IMAGE_TAG}|g" manifest/production/deployment.yml + + # 변경사항 확인 + echo "Updated deployment.yml:" + cat manifest/production/deployment.yml + + - name: Commit and push changes + env: + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + cd manifest + git config --local user.email "kjeng7897@gmail.com" + git config --local user.name "SeongHo5356" + git add production/deployment.yml + git commit -m "Update image tag to $IMAGE_TAG" || exit 0 + git push \ No newline at end of file diff --git a/.github/workflows/ci-stg.yaml b/.github/workflows/ci-stg.yaml new file mode 100644 index 00000000..36655ffb --- /dev/null +++ b/.github/workflows/ci-stg.yaml @@ -0,0 +1,79 @@ +name: Deployment Workflow + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + SPRING_PROFILES_ACTIVE: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.PAT }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle Wrapper + run: ./gradlew clean build --info --stacktrace --no-daemon + + - name: Docker login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Set image tag + id: vars + run: echo "IMAGE_TAG=${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Build Docker image + run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} -f deploy/Dockerfile . + + - name: Docker Hub push + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} + + - name: Checkout manifest repository + uses: actions/checkout@v4 + with: + repository: 'StartUpLight/STARLIGHT_MANIFEST' + token: ${{ secrets.PAT }} + path: 'manifest' + + - name: Update deployment.yml + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + sed -i "s|image:.*|image: ${DOCKERHUB_USERNAME}/startuplight-be:${IMAGE_TAG}|g" manifest/staging/deployment.yml + + # 변경사항 확인 + echo "Updated deployment.yml:" + cat manifest/staging/deployment.yml + + - name: Commit and push changes + env: + IMAGE_TAG: ${{ env.IMAGE_TAG }} + run: | + cd manifest + git config --local user.email "kjeng7897@gmail.com" + git config --local user.name "SeongHo5356" + git add staging/deployment.yml + git commit -m "Update image tag to $IMAGE_TAG" || exit 0 + git push \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..6b9bea16 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,40 @@ +name: PR Test + +permissions: + contents: read # checkout, 빌드 스크립트 읽기 권한 + checks: write # Test Results 액션이 check-run 생성/수정 권한 + issues: write # 이슈에 코멘트 작성 권한 + pull-requests: write # PR에 코멘트 작성 권한 + +on: + pull_request: + branches: [ develop, main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.PAT }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Test with Gradle + run: ./gradlew --info test + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: ${{ always() }} + with: + files: build/test-results/**/*.xml \ No newline at end of file diff --git a/.gitignore b/.gitignore index b5ceafdf..e4857ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,10 +36,6 @@ out/ ### VS Code ### .vscode/ -src/main/resources/application.properties -src/main/resources/application-dev.properties -src/main/resources/application-stage.properties -src/main/resources/application-prod.properties .env .DS_Store *.p8 @@ -49,5 +45,3 @@ src/main/resources/application-prod.properties node_modules/ dist/ *.log - -deploy/docker-compose.yaml \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..b9a602de --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "config"] + path = config + url = https://github.com/StartUpLight/config.git diff --git a/README.md b/README.md index 16385451..0ac83b82 100644 --- a/README.md +++ b/README.md @@ -1 +1,168 @@ -# STARLIGHT_BE +# Starlight Server +대학생 IT경영학회 큐시즘 32기 밋업 프로젝트 4조 Starlight 백엔드 레포지토리 +1 + +

+ +## 👬 Member +| 정성호 | 이호근 | +| :------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------: | +| 정성호 | 이호근 | +| [@SeongHo5356](https://github.com/SeongHo5356) | [@2ghrms](https://github.com/2ghrms) | + +

+ +## 📝 Technology Stack +| Category | Technology | +|----------------------|---------------------------------------------------------------------------| +| **Language** | Java 21 | +| **Framework** | Spring Boot 3.3.10 | +| **Databases** | Postgresql, Redis | +| **Authentication** | JWT, Spring Security, OAuth2.0 | +| **Development Tools**| Lombok | +| **API Documentation**| Swagger UI (SpringDoc) | +| **Storage** | AWS S3, Naver Object Storage | +| **Infrastructure** | Terraform, NCP Server, HashiCorp Vault | +| **Build Tools** | Gradle | +| **Monitoring** | Prometheus, Grafana, Loki, Promtail | + +

+ +## 📅 ERD +https://www.erdcloud.com/d/bEeEkcvDoau3kf7W5 +스타라이트 ERD 제출본 (1) + +

+ +## 🔨 Project Architecture +image (2) + +

+ +## ⭐️ 기술스택/선정이유 + +**1️⃣ Java 21** + +- Java 21은 최신 언어 기능(예: 패턴 매칭, 레코드, 가상 스레드 등)을 제공하여 코드의 가독성과 유지보수성을 높이며, 개발 생산성을 향상시킵니다. +- 최신 버전의 자바는 성능 최적화와 효율적인 메모리 관리 기능이 개선되어, 대규모 애플리케이션에서도 안정적이고 빠른 실행이 가능합니다. +- 장기 지원 버전이므로, 앞으로의 유지보수와 안정성 측면에서 신뢰할 수 있는 기반을 제공합니다. + +**2️⃣ SpringBoot 3.4.9** +- 클라우드 네이티브 최적화: Jakarta EE 기반의 높은 성숙도를 갖추고 있으며, 헬스체크·Actuator·Micrometer 등 컨테이너 및 클라우드 운영에 필수적인 관측성 기능을 기본으로 제공합니다. +- 성능 및 보안 강화: 개선된 AOT/Native 이미지 지원을 통해 콜드스타트를 최적화할 수 있으며, 신속한 보안 패치로 안정적인 서비스 운영을 돕습니다. +- 생산성 및 유지보수: 표준화된 스타터와 강력한 자동 설정(Auto Configuration)으로 개발 복잡도를 낮추고, 팀 온보딩 및 유지보수 비용을 최소화합니다. + +**3️⃣ SpringData JPA** + +- Spring Data JPA는 데이터베이스와의 인터랙션을 단순화하고, 불필요한 보일러플레이트 코드를 줄여 개발 효율성을 높여줍니다. + +**4️⃣ Spring AI** +- LLM 통합 단순화: Spring 생태계에 통합된 AI 프레임워크로, 복잡한 LLM 연동 과정을 간소화합니다. +- 유연한 추상화 레이어: OpenAI, Pinecone 등 다양한 제공업체에 대한 추상화 레이어를 제공하여 벤더 교체가 용이합니다. +- 높은 생산성: Spring Boot 자동 설정 및 WebFlux를 지원하며, 벡터 검색·프롬프트 템플릿·체이닝 등 RAG 패턴을 쉽게 구현할 수 있습니다. + +**5️⃣ MySQL** +- 검증된 안정성: 성숙한 InnoDB 엔진과 풍부한 운영 레퍼런스를 보유하고 있으며, TCO(총 소유 비용)가 낮습니다. +- 기능 및 생태계: 8.x 버전의 CTE와 윈도우 함수로 복잡한 쿼리에 대응 가능하며, 리플리케이션·백업·모니터링 도구가 잘 갖춰져 있습니다. +- OLTP 최적화: HikariCP 커넥션 풀과 적절한 인덱싱을 통해 대규모 트래픽에서도 안정적인 운영이 가능합니다. + +**6️⃣ NCP (CLOVA Studio, Server, Object Storage)** +- 안정적 인프라: 네이버 클라우드 플랫폼(NCP)의 서버 인프라와 Object Storage를 활용하여 안정적이고 보안성이 뛰어난 클라우드 환경을 제공합니다. +- AI 서비스 연동: CLOVA Studio와의 연동을 통해 프로젝트의 AI 기능을 효율적으로 지원하고 운영 효율성을 높입니다. + +**7️⃣ ArgoCD, K3s** +- ArgoCD (GitOps): Pull 기반 배포로 'Git 상태=배포 상태'를 보장하며, 자동 동기화 및 자가 치유(Self-healing)로 운영 복잡도를 줄입니다. +- K3s (경량 K8s): 리소스가 제한된 환경이나 스테이징/소규모 서비스에 최적화된 가벼운 쿠버네티스입니다. +- 표준 호환성: Helm, Ingress 등 표준 K8s 도구를 그대로 사용할 수 있어 진입 장벽이 낮고, 추후 매니지드 서비스로의 확장이 쉽습니다. + +**8️⃣ Promtail, Loki, Prometheus, Grafana** +- 로그 수집 및 검색 (Loki): Promtail이 수집한 애플리케이션 및 시스템 로그를 Loki로 전송하여 대용량 로그를 효율적으로 인덱싱하고 검색합니다. +- 메트릭 모니터링 (Prometheus): Pull 방식을 통해 API 응답 시간, CPU, 메모리 등의 시계열 데이터를 수집합니다. +- 데이터 시각화 (Grafana): 수집된 로그와 메트릭 데이터를 통합 대시보드로 시각화하여 시스템 상태를 한눈에 파악합니다. + +**9️⃣ Flannel + WireGuard VPN** +- 네트워크 연결성: 서로 다른 네트워크(VPC, IDC, 공인망)에 분산된 노드 간의 통신을 지원합니다. +- 보안 통신 보장: K3s 클러스터 내에서 WireGuard VPN을 통해 빠르고 안전한 Pod-to-Pod 보안 통신을 보장합니다. + +**🔟 Nginx** +- 고성능 리버스 프록시: 경량화된 구조로 TLS 종료, 정적 자산 서빙, 라우팅 및 리라이트 처리에 강점을 가집니다. +- 백엔드 보호: 헬스체크, 타임아웃, 버퍼링 등의 세밀한 튜닝을 통해 백엔드 애플리케이션의 부하를 줄이고 성능을 보장합니다. +- 유연한 배치: 쿠버네티스 환경에서 Ingress Controller 또는 엣지 프록시로 활용하여 트래픽을 효율적으로 관리합니다. + +**1️⃣1️⃣ K6** +- 사용자 흐름 테스트: JavaScript를 사용하여 실제 사용자 행동(로그인 → API 호출 → 스케줄링)을 스크립트화하고 REST API 성능을 측정합니다. +- 커스텀 시나리오: vus(가상 사용자), stages 등의 옵션으로 스파이크 테스트나 지속성 테스트 등 다양한 부하 시나리오를 검증합니다. +- 결과 시각화: 응답 시간, 처리량, 에러율 등의 메트릭을 수집하고 Grafana와 연동하여 테스트 결과를 시각적으로 분석합니다. + + +

+ +## 💬 Convention + +**commit convention**
+`#이슈번호 conventionType: 구현한 내용`

+ + +**convention Type**
+| convention type | description | +| --- | --- | +| `feat` | 새로운 기능 구현 | +| `chore` | 부수적인 코드 수정 및 기타 변경사항 | +| `docs` | 문서 추가 및 수정, 삭제 | +| `fix` | 버그 수정 | +| `test` | 테스트 코드 추가 및 수정, 삭제 | +| `refactor` | 코드 리팩토링 | + +

+ +## 🪵 Branch +### +- `컨벤션명/#이슈번호-작업내용` +- pull request를 통해 develop branch에 merge 후, branch delete +- 부득이하게 develop branch에 직접 commit 해야 할 경우, `!hotfix:` 사용 + +

+ +## 📁 Directory + +```PlainText +src/ +├── main/ +│ ├── domain/ +│ │ ├── entity/ +│ │ ├── controller/ +│ │ ├── service/ +│ │ ├── repository/ +│ │ └── dto/ + ├── request/ + └── response/ +│ ├── global/ +│ │ ├── apiPayload/ +│ │ ├── config/ +│ │ ├── security/ + +``` + +

+ +## 📈 부하테스트 +각 플랫폼(Instagram, Facebook, Threads) API에는 계정/시간 당 발행 가능한 게시물 수에 제한이 있어, 부하 테스트에는 제약이 존재합니다. 이에 따라 저희는 즉시 발행이 아닌 **"예약 발행" API**를 활용한 부하 시뮬레이션 방식을 구성하였습니다. + +|시나리오 ① 10명이 1초 동안 최대한의 요청을 보낸다.| 시나리오 ② 2000명이 1초 동안 최대한의 요청을 보낸다.| +| :-------| :-------| +|![image](https://github.com/user-attachments/assets/026eb04b-4aa3-4e23-8820-5d22f1d94d12)|![image](https://github.com/user-attachments/assets/b73b7838-48e6-4392-99cd-c6497a4958d1)| +|✅ 총 120개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 82.09 ms
- 최소 요청 처리 시간 : 22.52ms
- 최대 요청 처리 시간 : 164.64ms |✅ 총 4002개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 7.74s
- 최소 요청 처리 시간 : 21.9s
- 최대 요청 처리 시간 : 18.28s
- 95th 퍼센타일 : 14.95s| + +
+ +| 시나리오 ③ 사용자 수 변동 시나리오 | 시나리오 ④ 응답 시간이 5초 이내인 최대 요청 수 파악| +| :-------|:----| +|![image](https://github.com/user-attachments/assets/c77e54f8-765f-4ef5-a79b-f8896eb761a7)|![image](https://github.com/user-attachments/assets/a856af66-9d1b-47df-b287-156c125bd9b3)| +|0초 ~ 2초 : `50명`, 2초 ~ 12초 : `300명`, 12초 ~ 17초 : `1000명`, 17초 ~ 18초 : `500명`| 5초가 지날 경우 사용자 이탈이 늘어날 것이라고 판단하여 1초 동안 `1000명`의 사용자가 요청을 보내 `요청 처리 시간이 5초 이내`인 요청 개수를 파악 | +|✅ 총 3789개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 1.94s
- 최소 요청 처리 시간 : 20.53ms
- 최대 요청 처리 시간 : 7.88s |✅ 총 2002개의 요청이 시간 내 처리됨 | + +### 테스트 결과 분석 +- 현재 시스템은 동시 약 `1,000건` 수준까지는 안정적으로 요청을 처리할 수 있는 것으로 보입니다. **시나리오 ③**처럼 사용자 수가 점차 증가하는 상황에서도 평균 응답 시간은 `1.94초`, 최대 응답 시간은 `7.88초`로, 대부분의 요청이 정상적으로 처리되었습니다. +- 하지만 **시나리오 ②**처럼 `2,000명`의 동시 요청이 들어오면 평균 응답 시간이 `7.74초`, 최대 `18.28초`까지 증가하면서 응답 지연이 발생하였습니다. 이 결과는 대규모 트래픽에 대한 성능 한계가 있음을 보여주며, 추후 이를 개선할 필요가 있습니다. +- **시나리오 ④**에서는 `1000명`의 사용자가 동시에 요청을 보낸 경우, 총 `2,002건`의 요청이 `5초` 이내에 처리되었습니다. 이는 현재 시스템이 실시간 대응보다는 예약 처리에 더 적합한 구조임을 보여줍니다. +- 일반적으로 사용자 이탈이 늘기 시작하는 5초 이내 응답을 기준으로 예상 접속자 수 약 `1,000명` 정도에 대해서는 충분히 안정적인 성능을 제공할 수 있다고 판단됩니다. diff --git a/build.gradle b/build.gradle index 4a42dceb..d8afb991 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,12 @@ plugins { id 'java' + id 'jacoco' id 'org.springframework.boot' version '3.4.10' id 'io.spring.dependency-management' version '1.1.7' } +springBoot { buildInfo() } + group = 'com.example' version = '0.0.1-SNAPSHOT' description = 'Light up the Start Up Eco System' @@ -14,28 +17,16 @@ java { } } -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} - repositories { mavenCentral() + maven { url 'https://jitpack.io' } } -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -tasks.named('test') { - useJUnitPlatform() -} +apply from: 'gradle/config.gradle' +apply from: 'gradle/database.gradle' +apply from: 'gradle/docs.gradle' +apply from: 'gradle/jwt.gradle' +apply from: 'gradle/spring.gradle' +apply from: 'gradle/test.gradle' +apply from: 'gradle/util.gradle' +apply from: 'gradle/ai.gradle' diff --git a/config b/config new file mode 160000 index 00000000..3a581e52 --- /dev/null +++ b/config @@ -0,0 +1 @@ +Subproject commit 3a581e527b6117d5929ba57a0e2c8c8b90d9d14a diff --git a/data/feedback_test.py b/data/feedback_test.py new file mode 100644 index 00000000..5b922604 --- /dev/null +++ b/data/feedback_test.py @@ -0,0 +1,248 @@ +""" +피드백 요청 API 성능 테스트 스크립트 (병렬 버전) +- 파일 크기별로 병렬 호출 +- 스레드별 requests.Session 재사용(커넥션 풀) +- 결과 CSV 저장 및 요약 출력 +""" + +import os +import time +import csv +import threading +import statistics +from datetime import datetime +from io import BytesIO +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests +from requests.adapters import HTTPAdapter + +# ============== 설정 ============== +BASE_URL = "http://localhost:8080/v1/expert-applications" +EXPERT_ID = 6 # Path Variable +BUSINESS_PLAN_ID = 2 # Request Param + +# 인증 토큰 (JWT) - 환경변수 권장: export AUTH_TOKEN="xxxxx" +AUTH_TOKEN = "" + +# 테스트할 파일 크기 (MB) +FILE_SIZES_MB = [5, 10, 15, 20] + +# 각 크기별 반복 횟수 +ITERATIONS_PER_SIZE = 10 + +# 크기별 동시 실행 스레드 수 (예: 5). 과도하면 서버를 과부하시킬 수 있음. +CONCURRENCY_PER_SIZE = 5 + +# 요청 타임아웃(초) +REQUEST_TIMEOUT = None + +# (선택) 워밍업 요청 개수: 첫 연결/템플릿 로딩 등 콜드스타트 흡수 +WARMUP_REQUESTS = 0 +# =================================== + +# 스레드별 세션 보관소 +_thread_local = threading.local() +# 출력 동기화용 +_print_lock = threading.Lock() + + +def get_session() -> requests.Session: + """ + 스레드별 Session을 생성/재사용. + 커넥션 풀을 늘려 다중 연결 병렬성을 확보. + """ + sess = getattr(_thread_local, "session", None) + if sess is None: + sess = requests.Session() + # 풀 크기는 동시성보다 여유 있게 + pool_size = max(10, CONCURRENCY_PER_SIZE * 2) + adapter = HTTPAdapter(pool_connections=pool_size, pool_maxsize=pool_size) + sess.mount("http://", adapter) + sess.mount("https://", adapter) + _thread_local.session = sess + return sess + + +def create_dummy_pdf_bytes(size_mb: int) -> bytes: + """ + 더미 PDF 바이트 생성 (헤더 + 패딩) + 매 호출 시 BytesIO를 새로 감싸 쓰되 원본 바이트는 캐시해 재사용. + """ + pdf_header = b"%PDF-1.4\n" + size_bytes = size_mb * 1024 * 1024 + if size_bytes < len(pdf_header): + size_bytes = len(pdf_header) + return pdf_header + (b"0" * (size_bytes - len(pdf_header))) + + +def send_feedback_request(file_bytes: bytes, file_size_mb: int): + """ + 피드백 요청 API 호출 (단일 요청) + """ + try: + # 각 요청마다 새로운 BytesIO(스트림 포지션 충돌 방지) + file_obj = BytesIO(file_bytes) + + url = f"{BASE_URL}/{EXPERT_ID}/request" + files = {"file": (f"test_{file_size_mb}MB.pdf", file_obj, "application/pdf")} + params = {"businessPlanId": BUSINESS_PLAN_ID} + headers = {} + if AUTH_TOKEN: + headers["Authorization"] = f"Bearer {AUTH_TOKEN}" + + sess = get_session() + start = time.perf_counter() + resp = sess.post(url, files=files, params=params, headers=headers, timeout=REQUEST_TIMEOUT) + elapsed_ms = (time.perf_counter() - start) * 1000.0 + + success = (resp.status_code == 200) + error_msg = None if success else (resp.text[:200] if resp.text else f"HTTP {resp.status_code}") + return success, elapsed_ms, resp.status_code, error_msg + + except requests.exceptions.Timeout: + return False, None, None, "Timeout" + except Exception as e: + return False, None, None, str(e)[:200] + + +def run_test_parallel(): + print("=" * 70) + print("📊 피드백 요청 API 성능 테스트 (병렬)") + print("=" * 70) + print(f"API URL: {BASE_URL}/{EXPERT_ID}/request") + print(f"Expert ID: {EXPERT_ID} (Path Variable)") + print(f"Business Plan ID: {BUSINESS_PLAN_ID} (Request Param)") + print(f"테스트 파일 크기: {FILE_SIZES_MB} MB") + print(f"각 크기별 반복 횟수: {ITERATIONS_PER_SIZE}") + print(f"크기별 동시성: {CONCURRENCY_PER_SIZE}") + print("=" * 70) + + # 결과 저장 + all_results = [] + + # 크기별 워밍업 (옵션) + if WARMUP_REQUESTS > 0: + print("\n🔥 워밍업 시작...") + fb = create_dummy_pdf_bytes(FILE_SIZES_MB[0]) + for _ in range(WARMUP_REQUESTS): + send_feedback_request(fb, FILE_SIZES_MB[0]) + print("🔥 워밍업 종료") + + for size_mb in FILE_SIZES_MB: + print(f"\n🔍 [{size_mb}MB 파일] 병렬 테스트 시작") + print("-" * 70) + + file_bytes = create_dummy_pdf_bytes(size_mb) + size_results_times = [] + success_count = 0 + + futures = [] + with ThreadPoolExecutor(max_workers=CONCURRENCY_PER_SIZE) as ex: + for i in range(1, ITERATIONS_PER_SIZE + 1): + futures.append(ex.submit(send_feedback_request, file_bytes, size_mb)) + + for idx, fut in enumerate(as_completed(futures), start=1): + success, elapsed_ms, status_code, err = fut.result() + + # 결과 집계 + if success and elapsed_ms is not None: + success_count += 1 + size_results_times.append(elapsed_ms) + + # 결과 저장(행 단위) + all_results.append({ + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "file_size_mb": size_mb, + "iteration": idx, + "success": success, + "response_time_ms": round(elapsed_ms, 2) if elapsed_ms is not None else None, + "status_code": status_code, + "error": err + }) + + # 진행 출력은 락으로 깔끔하게 + with _print_lock: + if success: + print(f" ✅ [{idx}/{ITERATIONS_PER_SIZE}] {elapsed_ms:.2f} ms (HTTP {status_code})") + else: + print(f" ❌ [{idx}/{ITERATIONS_PER_SIZE}] {err}") + + # 크기별 통계 + if size_results_times: + avg_time = statistics.mean(size_results_times) + min_time = min(size_results_times) + max_time = max(size_results_times) + success_rate = (success_count / ITERATIONS_PER_SIZE) * 100 + print(f"\n📈 [{size_mb}MB] 결과") + print(f" 성공률: {success_rate:.1f}% ({success_count}/{ITERATIONS_PER_SIZE})") + print(f" 평균: {avg_time:.2f} ms | 최소: {min_time:.2f} ms | 최대: {max_time:.2f} ms") + else: + print(f"\n❌ [{size_mb}MB] 모든 요청 실패") + + save_results_to_csv(all_results) + print_summary(all_results) + + +def save_results_to_csv(results): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"feedback_test_results_{timestamp}.csv" + with open(filename, "w", newline="", encoding="utf-8") as f: + fieldnames = ["timestamp", "file_size_mb", "iteration", "success", + "response_time_ms", "status_code", "error"] + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(results) + print(f"\n💾 결과가 저장되었습니다: {filename}") + + +def print_summary(all_results): + print("\n" + "=" * 70) + print("📊 전체 테스트 요약") + print("=" * 70) + + # 크기별 그룹핑 + by_size = {} + for r in all_results: + size = r["file_size_mb"] + if size not in by_size: + by_size[size] = [] + if r["success"] and r["response_time_ms"] is not None: + by_size[size].append(r["response_time_ms"]) + + # 표 출력 + print(f"\n{'파일크기':<10} {'성공률':<10} {'평균(ms)':<12} {'최소(ms)':<12} {'최대(ms)':<12}") + print("-" * 70) + total_success = 0 + for size_mb in FILE_SIZES_MB: + times = by_size.get(size_mb, []) + if times: + success_rate = (len(times) / ITERATIONS_PER_SIZE) * 100 + avg_t = statistics.mean(times) + min_t = min(times) + max_t = max(times) + total_success += len(times) + print(f"{size_mb}MB{'':<6} {success_rate:.1f}%{'':<5} " + f"{avg_t:.2f}{'':<6} {min_t:.2f}{'':<6} {max_t:.2f}") + else: + print(f"{size_mb}MB{'':<6} {'0.0%':<10} {'-':<12} {'-':<12} {'-':<12}") + + total_requests = len(FILE_SIZES_MB) * ITERATIONS_PER_SIZE + if total_success > 0: + all_times = [t for v in by_size.values() for t in v] + overall_avg = statistics.mean(all_times) + print(f"\n전체 성공률: {(total_success/total_requests)*100:.1f}% " + f"({total_success}/{total_requests})") + print(f"전체 평균 응답시간: {overall_avg:.2f} ms") + print() + + +if __name__ == "__main__": + try: + run_test_parallel() + except KeyboardInterrupt: + print("\n\n⚠️ 테스트가 중단되었습니다.") + except Exception as e: + print(f"\n\n❌ 오류 발생: {e}") + import traceback + traceback.print_exc() diff --git a/data/feedback_test_results_20251103_000249.csv b/data/feedback_test_results_20251103_000249.csv new file mode 100644 index 00000000..7292aa6d --- /dev/null +++ b/data/feedback_test_results_20251103_000249.csv @@ -0,0 +1,41 @@ +timestamp,file_size_mb,iteration,success,response_time_ms,status_code,error +2025-11-02 23:56:45,5,1,True,20668.33,200, +2025-11-02 23:56:45,5,2,True,20868.55,200, +2025-11-02 23:56:45,5,3,True,21233.69,200, +2025-11-02 23:56:46,5,4,True,21680.87,200, +2025-11-02 23:56:46,5,5,True,22196.0,200, +2025-11-02 23:57:05,5,6,True,20567.75,200, +2025-11-02 23:57:06,5,7,True,21385.54,200, +2025-11-02 23:57:06,5,8,True,21212.89,200, +2025-11-02 23:57:07,5,9,True,21174.66,200, +2025-11-02 23:57:07,5,10,True,20765.67,200, +2025-11-02 23:57:45,10,1,True,37923.18,200, +2025-11-02 23:57:46,10,2,True,38570.55,200, +2025-11-02 23:57:46,10,3,True,38669.21,200, +2025-11-02 23:57:47,10,4,True,39942.81,200, +2025-11-02 23:57:48,10,5,True,40790.38,200, +2025-11-02 23:58:23,10,6,True,37937.61,200, +2025-11-02 23:58:23,10,7,True,37479.75,200, +2025-11-02 23:58:26,10,8,True,40096.5,200, +2025-11-02 23:58:26,10,9,True,37929.71,200, +2025-11-02 23:58:27,10,10,True,39790.88,200, +2025-11-02 23:59:22,15,1,True,54793.16,200, +2025-11-02 23:59:22,15,2,True,54836.05,200, +2025-11-02 23:59:22,15,3,True,55065.84,200, +2025-11-02 23:59:22,15,4,True,55242.78,200, +2025-11-02 23:59:23,15,5,True,56406.86,200, +2025-11-03 00:00:16,15,6,True,54626.29,200, +2025-11-03 00:00:18,15,7,True,56505.05,200, +2025-11-03 00:00:18,15,8,True,56737.58,200, +2025-11-03 00:00:19,15,9,True,55631.56,200, +2025-11-03 00:00:19,15,10,True,57458.97,200, +2025-11-03 00:01:34,20,1,False,74583.63,500,"{""result"":""ERROR"",""data"":null,""error"":{""code"":""INTERNAL_ERROR"",""message"":""알 수 없는 내부 오류입니다.""}}" +2025-11-03 00:01:35,20,2,True,75106.38,200, +2025-11-03 00:01:36,20,3,False,76066.9,500,"{""result"":""ERROR"",""data"":null,""error"":{""code"":""INTERNAL_ERROR"",""message"":""알 수 없는 내부 오류입니다.""}}" +2025-11-03 00:01:36,20,4,True,76194.86,200, +2025-11-03 00:01:36,20,5,True,76408.71,200, +2025-11-03 00:02:47,20,6,True,73147.09,200, +2025-11-03 00:02:48,20,7,True,72123.34,200, +2025-11-03 00:02:48,20,8,True,72701.03,200, +2025-11-03 00:02:48,20,9,True,73792.32,200, +2025-11-03 00:02:49,20,10,True,73508.53,200, diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 00000000..a01896bd --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,12 @@ +# 빌드 단계 +FROM eclipse-temurin:21-jdk AS builder +WORKDIR /app +COPY . . +RUN ./gradlew build -x test + +# 실행 단계 +FROM eclipse-temurin:21-jre-alpine AS runtime +WORKDIR /app +COPY --from=builder /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml new file mode 100644 index 00000000..fc86b377 --- /dev/null +++ b/deploy/docker-compose.yaml @@ -0,0 +1,47 @@ +version: "3.9" + +name: starLight + +services: + mysql: + image: mysql:8.0 + container_name: mysql-starLight + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: "root" + MYSQL_DATABASE: "starLight" + MYSQL_USER: "starLight" + MYSQL_PASSWORD: "starLight7897!" + TZ: "Asia/Seoul" + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: redis-starLight + ports: + - "6379:6379" + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "PING"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + mysql_data: + redis_data: diff --git a/gradle/ai.gradle b/gradle/ai.gradle new file mode 100644 index 00000000..734be155 --- /dev/null +++ b/gradle/ai.gradle @@ -0,0 +1,17 @@ +ext { + set('springAiVersion', "1.0.0") +} + +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" + } +} + +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-openai' + implementation 'org.springframework.ai:spring-ai-starter-vector-store-pinecone' + implementation 'org.springframework.ai:spring-ai-advisors-vector-store' + + implementation 'org.springframework.boot:spring-boot-starter-webflux' +} \ No newline at end of file diff --git a/gradle/config.gradle b/gradle/config.gradle new file mode 100644 index 00000000..30339b70 --- /dev/null +++ b/gradle/config.gradle @@ -0,0 +1,13 @@ +sourceSets { + main { + resources { + srcDir 'config/resources/main' + } + } + + test { + resources { + srcDir 'config/resources/test' + } + } +} \ No newline at end of file diff --git a/gradle/database.gradle b/gradle/database.gradle new file mode 100644 index 00000000..8ee1d918 --- /dev/null +++ b/gradle/database.gradle @@ -0,0 +1,9 @@ +dependencies { + // MySQL Driver + runtimeOnly 'com.mysql:mysql-connector-j' + + // JPA, Redis, Spring Session + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.session:spring-session-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' +} \ No newline at end of file diff --git a/gradle/docs.gradle b/gradle/docs.gradle new file mode 100644 index 00000000..c25d7526 --- /dev/null +++ b/gradle/docs.gradle @@ -0,0 +1,5 @@ +dependencies { + // Swagger (OpenAPI) + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' + implementation 'io.swagger.core.v3:swagger-core-jakarta:2.2.24' +} \ No newline at end of file diff --git a/gradle/jwt.gradle b/gradle/jwt.gradle new file mode 100644 index 00000000..dcba55f7 --- /dev/null +++ b/gradle/jwt.gradle @@ -0,0 +1,7 @@ +dependencies { + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' +} \ No newline at end of file diff --git a/gradle/spring.gradle b/gradle/spring.gradle new file mode 100644 index 00000000..dff83b0a --- /dev/null +++ b/gradle/spring.gradle @@ -0,0 +1,25 @@ +jar { + enabled = false +} + +bootJar { + enabled = true +} + +dependencies { + // Spring Boot Starter + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Emailclient + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-mail' +} diff --git a/gradle/test.gradle b/gradle/test.gradle new file mode 100644 index 00000000..add1c201 --- /dev/null +++ b/gradle/test.gradle @@ -0,0 +1,81 @@ +jacoco { + toolVersion = "0.8.11" +} + +dependencies { + // DATABASE + testRuntimeOnly 'com.h2database:h2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + testImplementation platform("org.junit:junit-bom:5.13.4") + testImplementation "org.junit.jupiter:junit-jupiter" + testImplementation 'org.assertj:assertj-core:3.26.3' + testImplementation 'org.springframework.security:spring-security-test' + + testImplementation "org.mockito:mockito-core:5.19.0" + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + systemProperty "spring.profiles.active", "test" + jvmArgs += ['-Duser.timezone=Asia/Seoul', '-Dfile.encoding=UTF-8'] + + finalizedBy jacocoTestReport + finalizedBy jacocoTestCoverageVerification +} + +jacocoTestReport { + dependsOn test + + reports { + xml.required = true + html.required = true + csv.required = false + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + '**/*Application.class', + '**/config/**', + '**/dto/**', + '**/entity/**', + '**/exception/**' + ]) + })) + } +} + +jacocoTestCoverageVerification { + dependsOn jacocoTestReport + + violationRules { + rule { + element = 'BUNDLE' // 전체 프로젝트 기준 + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.0 + } + } + +// rule { +// element = 'CLASS' // 클래스 단위로 검증 +// limit { +// counter = 'LINE' // 라인 커버리지 +// value = 'COVEREDRATIO' // 커버된 비율 +// minimum = 0.50 +// } +// } +// +// rule { +// element = 'METHOD' // 메서드 단위로 검증 +// limit { +// counter = 'LINE' +// value = 'COVEREDRATIO' +// minimum = 0.50 +// } +// } + } +} \ No newline at end of file diff --git a/gradle/util.gradle b/gradle/util.gradle new file mode 100644 index 00000000..ead717f5 --- /dev/null +++ b/gradle/util.gradle @@ -0,0 +1,22 @@ +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +dependencies { + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // HTML Parser + implementation("org.jsoup:jsoup:1.18.1") + + // AWS SDK for S3 + implementation 'software.amazon.awssdk:s3:2.20.40' + implementation 'software.amazon.awssdk:auth:2.20.40' + implementation 'software.amazon.awssdk:regions:2.20.40' + + // PDF Box + implementation 'org.apache.pdfbox:pdfbox:2.0.30' +} \ No newline at end of file diff --git a/src/main/java/starlight/StarlightApplication.java b/src/main/java/starlight/StarlightApplication.java index ac930b4b..d254238b 100644 --- a/src/main/java/starlight/StarlightApplication.java +++ b/src/main/java/starlight/StarlightApplication.java @@ -2,7 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class StarlightApplication { diff --git a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java b/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java new file mode 100644 index 00000000..6ea468e4 --- /dev/null +++ b/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java @@ -0,0 +1,45 @@ +package starlight.adapter.ai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import starlight.adapter.ai.infra.OpenAiGenerator; +import starlight.application.businessplan.required.ChecklistGrader; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; +import java.util.ArrayList; +import starlight.adapter.ai.util.ChecklistCatalog; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OpenAiChecklistGrader implements ChecklistGrader { + + private final OpenAiGenerator generator; + private final ChecklistCatalog checklistCatalog; + + @Override + public List check( + SubSectionType subSectionType, + String content + ) { + // 1) 서브섹션별 체크리스트 기준 5개 확보 + List criteria = checklistCatalog.getCriteriaBySubSectionType(subSectionType); + List detailedCriteria = checklistCatalog.getDetailedCriteriaBySubSectionType(subSectionType); + + // 2) LLM 호출 → Boolean 배열 파싱 + List result = generator.generateChecklistArray(subSectionType, content, criteria, detailedCriteria); + + // 3) 보정: 항상 길이 5 보장 + return normalizeToFive(result); + } + + private List normalizeToFive(List in) { + List out = new ArrayList<>(5); + for (int i = 0; i < 5; i++) { + out.add(i < in.size() && in.get(i) != null ? in.get(i) : false); + } + return out; + } +} diff --git a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java new file mode 100644 index 00000000..510d5181 --- /dev/null +++ b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java @@ -0,0 +1,29 @@ +package starlight.adapter.ai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.adapter.ai.infra.OpenAiGenerator; +import starlight.adapter.ai.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.application.aireport.required.AiReportGrader; + +/** + * AI 리포트 채점을 오케스트레이션하는 컴포넌트 + * 각 단계별 책임을 다른 컴포넌트에 위임하여 단일 책임 원칙을 준수 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenAiReportGrader implements AiReportGrader { + + private final OpenAiGenerator chatClientGenerator; + private final AiReportResponseParser responseParser; + + @Override + public AiReportResponse gradeContent(String content){ + String llmResponse = chatClientGenerator.generateReport(content); + + return responseParser.parse(llmResponse); + } +} diff --git a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java b/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java new file mode 100644 index 00000000..61e73481 --- /dev/null +++ b/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java @@ -0,0 +1,39 @@ +package starlight.adapter.ai.infra; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.core.Ordered; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AdvisorProvider { + + private final VectorStore vectorStore; + + public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold, int topK, String filter){ + SearchRequest.Builder builder = SearchRequest.builder() + .similarityThreshold(similarityThreshold) + .topK(topK); + + if (filter != null && !filter.trim().isEmpty()) { + builder.filterExpression(filter); + } + + SearchRequest searchRequest = builder.build(); + + return QuestionAnswerAdvisor + .builder(vectorStore) + .searchRequest(searchRequest) + .build(); + } + + public SimpleLoggerAdvisor getSimpleLoggerAdvisor(){ + return new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE-1); + } +} diff --git a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java new file mode 100644 index 00000000..c90ffb21 --- /dev/null +++ b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java @@ -0,0 +1,80 @@ +package starlight.adapter.ai.infra; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Component; +import starlight.application.infrastructure.provided.LlmGenerator; +import starlight.domain.businessplan.enumerate.SubSectionType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenAiGenerator implements LlmGenerator { + + private final ChatClient.Builder chatClientBuilder; + private final PromptProvider promptProvider; + private final AdvisorProvider advisorProvider; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public List generateChecklistArray( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria + ) { + Prompt prompt = promptProvider.createChecklistGradingPrompt( + subSectionType, content, criteria, detailedCriteria + ); + + ChatClient chatClient = chatClientBuilder.build(); + + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + String output = chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.1) + .topP(0.1) + .build()) + .advisors(slAdvisor) + .call() + .content(); + + try { + return objectMapper.readValue(output, new TypeReference>() { + }); + } catch (Exception e) { + return List.of(false, false, false, false, false); + } + } + + @Override + public String generateReport(String content) { + Prompt prompt = promptProvider.createReportGradingPrompt(content); + + ChatClient chatClient = chatClientBuilder.build(); + QuestionAnswerAdvisor qaAdvisor = advisorProvider + .getQuestionAnswerAdvisor(0.6, 3, null); + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + return chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.0) + .topP(0.1) + .build()) + .advisors(qaAdvisor, slAdvisor) + .call() + .content(); + } +} diff --git a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java new file mode 100644 index 00000000..9b283363 --- /dev/null +++ b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java @@ -0,0 +1,96 @@ +package starlight.adapter.ai.infra; + +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class PromptProvider { + + @Value("${prompt.report.grading.system}") + private String reportGradingSystemPrompt; + + @Value("${prompt.report.grading.user}") + private String reportGradingUserPromptTemplate; + + @Value("${prompt.checklist.grading.system}") + private String checklistGradingSystemPrompt; + + @Value("${prompt.checklist.grading.user.template}") + private String checklistGradingUserPromptTemplate; + + /** + * 리포트 채점용 Prompt 객체 생성 + */ + public Prompt createReportGradingPrompt(String businessPlanContent) { + Message systemMessage = new SystemMessage(getReportGradingSystemPrompt()); + Message userMessage = new UserMessage(buildReportGradingUserPrompt(businessPlanContent)); + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 체크리스트 채점용 Prompt 객체 생성 + */ + public Prompt createChecklistGradingPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria + ) { + String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); + Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); + Message userMessage = new UserMessage(userPrompt); + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 리포트 채점용 시스템 프롬프트 + */ + private String getReportGradingSystemPrompt() { + return reportGradingSystemPrompt; + } + + /** + * 리포트 채점용 사용자 프롬프트 생성 + */ + private String buildReportGradingUserPrompt(String businessPlanContent) { + PromptTemplate promptTemplate = new PromptTemplate(reportGradingUserPromptTemplate); + Map variables = Map.of("businessPlanContent", businessPlanContent); + return promptTemplate.render(variables); + } + + /** + * 체크리스트 채점용 사용자 프롬프트 생성 + */ + private String buildChecklistGradingUserPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria) { + // 체크리스트 상세 기준 포맷팅 + StringBuilder criteriaBuilder = new StringBuilder(); + for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { + criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); + criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); + } + String formattedCriteria = criteriaBuilder.toString().trim(); + + Map variables = new HashMap<>(); + variables.put("subsectionType", subSectionType.getDescription()); + variables.put("checklistCriteria", formattedCriteria); + variables.put("input", content); + variables.put("requestLength", criteria.size()); + + PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); + return promptTemplate.render(variables); + } +} diff --git a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java new file mode 100644 index 00000000..7d5c44b8 --- /dev/null +++ b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java @@ -0,0 +1,314 @@ +package starlight.adapter.ai.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.domain.aireport.entity.AiReport; +import starlight.domain.aireport.exception.AiReportException; +import starlight.domain.aireport.exception.AiReportErrorType; + +import java.util.ArrayList; +import java.util.List; + +/** + * LLM 응답을 파싱하여 AiReportResponse로 변환하는 컴포넌트 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AiReportResponseParser { + + private final ObjectMapper objectMapper; + + /** + * AiReportResponse를 JsonNode로 변환 (저장용) + * 또는 JsonNode에서 AiReportResponse로 변환 (조회용) + * 통합된 변환 메소드 + */ + public JsonNode convertToJsonNode(AiReportResponse response) { + ObjectNode rootNode = objectMapper.createObjectNode(); + + // 점수 필드 + rootNode.put("problemRecognitionScore", + response.problemRecognitionScore() != null ? response.problemRecognitionScore() : 0); + rootNode.put("feasibilityScore", + response.feasibilityScore() != null ? response.feasibilityScore() : 0); + rootNode.put("growthStrategyScore", + response.growthStrategyScore() != null ? response.growthStrategyScore() : 0); + rootNode.put("teamCompetenceScore", + response.teamCompetenceScore() != null ? response.teamCompetenceScore() : 0); + + // 강점 배열 + ArrayNode strengthsArray = rootNode.putArray("strengths"); + if (response.strengths() != null) { + for (AiReportResponse.StrengthWeakness strength : response.strengths()) { + ObjectNode strengthNode = strengthsArray.addObject(); + strengthNode.put("title", strength.title() != null ? strength.title() : ""); + strengthNode.put("content", strength.content() != null ? strength.content() : ""); + } + } + + // 약점 배열 + ArrayNode weaknessesArray = rootNode.putArray("weaknesses"); + if (response.weaknesses() != null) { + for (AiReportResponse.StrengthWeakness weakness : response.weaknesses()) { + ObjectNode weaknessNode = weaknessesArray.addObject(); + weaknessNode.put("title", weakness.title() != null ? weakness.title() : ""); + weaknessNode.put("content", weakness.content() != null ? weakness.content() : ""); + } + } + + // 섹션별 점수 배열: sectionType과 gradingListScores + ArrayNode sectionScoresArray = rootNode.putArray("sectionScores"); + if (response.sectionScores() != null) { + for (AiReportResponse.SectionScoreDetailResponse sectionScore : response.sectionScores()) { + ObjectNode sectionScoreNode = sectionScoresArray.addObject(); + sectionScoreNode.put("sectionType", + sectionScore.sectionType() != null ? sectionScore.sectionType() : ""); + sectionScoreNode.put("gradingListScores", + sectionScore.gradingListScores() != null ? sectionScore.gradingListScores() : "[]"); + } + } + + return rootNode; + } + + /** + * AiReport에서 AiReportResponse로 변환 + * 파싱 로직은 AiReportResponseParser를 재사용하고, id와 businessPlanId만 추가 + */ + public AiReportResponse toResponse(AiReport aiReport) { + JsonNode jsonNode = aiReport.getRawJson().asTree(); + + // 공통 파싱 로직 재사용 + AiReportResponse baseResponse = parseFromJsonNode(jsonNode); + + // totalScore 계산 + Integer totalScore = (baseResponse.problemRecognitionScore() != null ? baseResponse.problemRecognitionScore() : 0) + + (baseResponse.feasibilityScore() != null ? baseResponse.feasibilityScore() : 0) + + (baseResponse.growthStrategyScore() != null ? baseResponse.growthStrategyScore() : 0) + + (baseResponse.teamCompetenceScore() != null ? baseResponse.teamCompetenceScore() : 0); + + // id와 businessPlanId를 포함하여 새 인스턴스 생성 + return new AiReportResponse( + aiReport.getId(), + aiReport.getBusinessPlanId(), + totalScore, + baseResponse.problemRecognitionScore(), + baseResponse.feasibilityScore(), + baseResponse.growthStrategyScore(), + baseResponse.teamCompetenceScore(), + baseResponse.sectionScores(), + baseResponse.strengths(), + baseResponse.weaknesses() + ); + } + + /** + * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인 + */ + private boolean isDefaultResponse(AiReportResponse response) { + return (response.problemRecognitionScore() == null || response.problemRecognitionScore() == 0) && + (response.feasibilityScore() == null || response.feasibilityScore() == 0) && + (response.growthStrategyScore() == null || response.growthStrategyScore() == 0) && + (response.teamCompetenceScore() == null || response.teamCompetenceScore() == 0) && + (response.strengths() == null || response.strengths().isEmpty()) && + (response.weaknesses() == null || response.weaknesses().isEmpty()) && + (response.sectionScores() == null || response.sectionScores().isEmpty()); + } + + /** + * LLM 응답 문자열을 AiReportResponse로 파싱 + * 파싱 실패 시 예외를 던집니다. + */ + public AiReportResponse parse(String llmResponse) { + log.debug("Raw LLM response: {}", llmResponse); + + // 1. 기본 검증 + if (llmResponse == null || llmResponse.trim().isEmpty()) { + log.error("LLM response is null or empty"); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + try { + // 2. JSON 문자열 정리 + String cleanedJson = cleanJsonResponse(llmResponse); + log.debug("Cleaned JSON: {}", cleanedJson); + + // 3. JSON 파싱 시도 + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + // 4. 필수 필드 존재 여부 확인 + if (!jsonNode.has("problemRecognitionScore") || + !jsonNode.has("feasibilityScore") || + !jsonNode.has("growthStrategyScore") || + !jsonNode.has("teamCompetenceScore")) { + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + // 5. 파싱 시도 + AiReportResponse response = parseFromJsonNode(jsonNode); + + // 6. 파싱된 값이 기본값인지 확인 + if (isDefaultResponse(response)) { + log.error("Parsed response is default (all zeros), likely parsing failure"); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + return response; + } catch (Exception e) { + log.error("Failed to parse LLM response. Response: {}", llmResponse, e); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + } + + /** + * JSON 응답 문자열 정리 및 복구 + */ + private String cleanJsonResponse(String json) { + if (json == null || json.trim().isEmpty()) { + return "{}"; + } + + String cleaned = json.trim(); + + // 1. JSON 코드 블록 마커 제거 (```json ... ``` 또는 ``` ... ```) + if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7); + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.substring(3); + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.substring(0, cleaned.length() - 3); + } + cleaned = cleaned.trim(); + + // 2. "text" 필드에서 JSON 추출 (더 강력한 추출) + // 정규식으로 "text" 필드 추출 시도 + if (cleaned.contains("\"text\"") || cleaned.contains("'text'")) { + try { + // 먼저 JSON 파싱 시도 + JsonNode root = objectMapper.readTree(cleaned); + if (root.has("text") && root.get("text").isTextual()) { + cleaned = root.get("text").asText(); + } + } catch (Exception e) { + // JSON 파싱 실패 시 정규식으로 추출 시도 + try { + // "text" : "..." 패턴 찾기 + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile( + "\"text\"\\s*:\\s*\"(.*)\"", + java.util.regex.Pattern.DOTALL + ); + java.util.regex.Matcher matcher = pattern.matcher(cleaned); + if (matcher.find()) { + String extracted = matcher.group(1); + // 이스케이프된 문자 처리 + extracted = extracted.replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + cleaned = extracted; + log.debug("Extracted text field using regex"); + } + } catch (Exception e2) { + log.warn("Failed to extract text field using regex: {}", e2.getMessage()); + } + } + } + + // 3. 잘못된 따옴표 패턴 수정 (공백이 포함된 필드명) + cleaned = cleaned.replaceAll("\"\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+\"", "\"$1\""); + + return cleaned; + } + + /** + * JsonNode를 파싱하여 AiReportResponse로 변환 + */ + private AiReportResponse parseFromJsonNode(JsonNode jsonNode) { + Integer problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(0); + Integer feasibilityScore = jsonNode.path("feasibilityScore").asInt(0); + Integer growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0); + Integer teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0); + + // 강점 파싱 + List strengths = parseStrengthWeaknessList(jsonNode.path("strengths")); + + // 약점 파싱 + List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses")); + + // sectionScores 파싱: sectionType과 gradingListScores만 포함 + List sectionScores = parseSectionScores( + jsonNode.path("sectionScores")); + + return AiReportResponse.fromGradingResult( + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses); + } + + /** + * 강점/약점 리스트 파싱 + */ + private List parseStrengthWeaknessList(JsonNode node) { + List list = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode itemNode : node) { + list.add(new AiReportResponse.StrengthWeakness( + itemNode.path("title").asText(""), + itemNode.path("content").asText(""))); + } + } + return list; + } + + /** + * 섹션 점수 리스트 파싱 + * 불완전한 항목은 건너뛰거나 기본값으로 대체 + */ + private List parseSectionScores(JsonNode node) { + List list = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode sectionScoreNode : node) { + try { + String sectionType = sectionScoreNode.path("sectionType").asText(""); + String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]"); + + // gradingListScores가 유효한 JSON 문자열인지 검증 + if (!gradingListScores.equals("[]")) { + try { + // JSON 배열 형식인지 확인 + if (!gradingListScores.trim().startsWith("[")) { + log.warn("Invalid gradingListScores format for sectionType: {}, using default", sectionType); + gradingListScores = "[]"; + } else { + // JSON 파싱 가능 여부 확인 + objectMapper.readTree(gradingListScores); + } + } catch (Exception e) { + log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}", + sectionType, gradingListScores); + gradingListScores = "[]"; + } + } + + list.add(new AiReportResponse.SectionScoreDetailResponse(sectionType, gradingListScores)); + } catch (Exception e) { + log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage()); + // 불완전한 항목은 건너뛰기 + } + } + } + return list; + } + +} diff --git a/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java new file mode 100644 index 00000000..86a5236e --- /dev/null +++ b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java @@ -0,0 +1,65 @@ +package starlight.adapter.ai.util; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@ConfigurationProperties(prefix = "prompt.checklist") +@Getter +@Setter +public class ChecklistCatalog { + + private Map catalog; + + @Getter + @Setter + public static class CatalogSection { + private List items; + } + + @Getter + @Setter + public static class ChecklistItem { + private String criteria; + private String detailed; + } + + // 서브섹션 타입에 해당하는 criteria 리스트를 반환합니다 + public List getCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getCriteria) + .filter(c -> c != null && !c.isEmpty()) + .collect(Collectors.toList()); + } + + // 서브섹션 타입에 해당하는 detailed-criteria 리스트를 반환합니다. + public List getDetailedCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getDetailed) + .filter(d -> d != null && !d.isEmpty()) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java new file mode 100644 index 00000000..0eb8ca06 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java @@ -0,0 +1,26 @@ +package starlight.adapter.aireport.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import starlight.application.aireport.required.AiReportQuery; +import starlight.domain.aireport.entity.AiReport; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class AiReportJpa implements AiReportQuery { + + private final AiReportRepository aiReportRepository; + + @Override + public AiReport save(AiReport aiReport) { + return aiReportRepository.save(aiReport); + } + + @Override + public Optional findByBusinessPlanId(Long businessPlanId) { + return aiReportRepository.findByBusinessPlanId(businessPlanId); + } +} + diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java new file mode 100644 index 00000000..31c245e4 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java @@ -0,0 +1,12 @@ +package starlight.adapter.aireport.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.aireport.entity.AiReport; + +import java.util.Optional; + +public interface AiReportRepository extends JpaRepository { + + Optional findByBusinessPlanId(Long businessPlanId); +} + diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java new file mode 100644 index 00000000..b8f0e84c --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -0,0 +1,56 @@ +package starlight.adapter.aireport.webapi; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.application.aireport.provided.AiReportService; +import starlight.shared.apiPayload.response.ApiResponse; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/ai-reports") +@Tag(name = "AI 리포트", description = "AI 리포트 채점 및 조회 API") +public class AiReportController { + + private final AiReportService aiReportService; + + @Operation(summary = "사업계획서를 AI로 채점 및 생성합니다.") + @PostMapping("/evaluation/{planId}") + public ApiResponse gradeBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ) { + return ApiResponse.success(aiReportService.gradeBusinessPlan(planId, authDetails.getMemberId())); + } + + @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.") + @PostMapping("/evaluation/pdf") + public ApiResponse createAndGradeBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + ) { + return ApiResponse.success(aiReportService.createAndGradePdfBusinessPlan( + request.title(), + request.pdfUrl(), + authDetails.getMemberId() + )); + } + + @Operation(summary = "AI 리포트를 조회합니다.") + @GetMapping("/{planId}") + public ApiResponse getAiReport( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ) { + return ApiResponse.success(aiReportService.getAiReport(planId, authDetails.getMemberId())); + } +} + diff --git a/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java b/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java new file mode 100644 index 00000000..e6e69b97 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java @@ -0,0 +1,79 @@ +package starlight.adapter.auth.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; +import starlight.application.auth.required.KeyValueMap; +import starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class RedisKeyValueMap implements KeyValueMap { + + private final RedisTemplate redisTemplate; + + /** + * 지정된 키에 값을 저장합니다. + * + * @param key 저장할 키 + * @param value 저장할 값 + * @param timeout (선택 사항) 값의 만료 시간(초 단위). null이면 만료되지 않음. + */ + public void setValue(String key, String value, Long timeout) { + try { + ValueOperations values = redisTemplate.opsForValue(); + values.set(key, value, Duration.ofMillis(timeout)); + } catch (Exception e) { + throw new GlobalException(GlobalErrorType.REDIS_SET_ERROR); + } + } + + /** + * 지정된 키에 대한 값을 가져옵니다. + * + * @param key 값을 가져올 키 + * @return 키에 해당하는 값, 키가 존재하지 않으면 null + */ + public String getValue(String key) { + try { + ValueOperations values = redisTemplate.opsForValue(); + if (values.get(key) == null) { + return ""; + } + return values.get(key).toString(); + } catch (Exception e) { + throw new GlobalException(GlobalErrorType.REDIS_GET_ERROR); + } + } + + /** + * 지정된 키에 대한 값을 삭제합니다. + * + * @param key 삭제할 키 + */ + public void deleteValue(String key) { + try { + redisTemplate.delete(key); + } catch (Exception e) { + throw new GlobalException(GlobalErrorType.REDIS_DELETE_ERROR); + } + } + + /** + * 지정된 키가 존재하는지 확인합니다. + * + * @param key 확인할 키 + * @return 키가 존재하면 true, 그렇지 않으면 false + */ + public boolean checkExistsValue(String key) { + try { + return redisTemplate.hasKey(key); + } catch (Exception e) { + throw new GlobalException(GlobalErrorType.REDIS_GET_ERROR); + } + } +} diff --git a/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java b/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java new file mode 100644 index 00000000..3f5dc2cd --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java @@ -0,0 +1,83 @@ +package starlight.adapter.auth.security.auth; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import starlight.domain.member.entity.Member; + +import java.util.*; +import java.util.stream.Collectors; + +public record AuthDetails(Member member, Map attributes, String nameAttributeKey) implements UserDetails, OAuth2User { + + // 폼 로그인 호환용 보조 생성자 + public AuthDetails(Member member) { + this(member, Collections.emptyMap(), "id"); + } + + @Override + public Collection getAuthorities() { + List roles = new ArrayList<>(); + roles.add(member.getMemberType().toString()); + + return roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + public Member getUser() { + return member; + } + + public Long getMemberId() { + return member.getId(); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + //OAuth2User + @Override public Map getAttributes() { + return attributes == null ? Map.of() : Collections.unmodifiableMap(attributes); + } + + @Override public String getName() { + if (attributes != null && nameAttributeKey != null && attributes.containsKey(nameAttributeKey)) { + return String.valueOf(attributes.get(nameAttributeKey)); + } + return member.getId() != null ? String.valueOf(member.getId()) : member.getEmail(); + } + + public static AuthDetails of(Member member, Map attrs, String nameKey) { + return new AuthDetails(member, attrs, nameKey); + } +} + diff --git a/src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java b/src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java new file mode 100644 index 00000000..4488825d --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java @@ -0,0 +1,28 @@ +package starlight.adapter.auth.security.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.adapter.member.persistence.MemberRepository; +import starlight.domain.member.entity.Member; +import starlight.domain.member.exception.MemberErrorType; +import starlight.domain.member.exception.MemberException; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AuthDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.findByEmail(email).orElseThrow(() + -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND)); + + return new AuthDetails(member); + } +} diff --git a/src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java b/src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java new file mode 100644 index 00000000..a27f629d --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java @@ -0,0 +1,49 @@ +package starlight.adapter.auth.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import starlight.shared.apiPayload.exception.GlobalException; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class ExceptionFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (GlobalException e) { //Todo: Error handling (Core Exception Handling) + setResponse(response, e); + } + } + + + private void setResponse(HttpServletResponse response, GlobalException e) throws IOException { + if (response.isCommitted()) { + return; + } + response.setContentType("application/json;charset=UTF-8"); + + int statusCode = e.getErrorType().getStatus().value(); + response.setStatus(statusCode); + + ApiResponse errorResponse = ApiResponse.error(e.getErrorType()); + + String errorJson = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(errorJson); + } +} diff --git a/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java b/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java new file mode 100644 index 00000000..f02ee3f1 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java @@ -0,0 +1,54 @@ +package starlight.adapter.auth.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import starlight.adapter.auth.security.auth.AuthDetailsService; +import starlight.application.auth.required.TokenProvider; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final TokenProvider tokenProvider; + private final AuthDetailsService authDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = getTokenFromRequest(request); + + if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { + String email = tokenProvider.getEmail(token); + UserDetails userDetails = authDetailsService.loadUserByUsername(email); + + if (userDetails != null) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + filterChain.doFilter(request, response); + } + + private String getTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { + return token.substring(7); + } + return null; + } +} diff --git a/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java b/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..00369de6 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,20 @@ +package starlight.adapter.auth.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException{ + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/auth/security/handler/JwtAuthenticationHandler.java b/src/main/java/starlight/adapter/auth/security/handler/JwtAuthenticationHandler.java new file mode 100644 index 00000000..c4ba9344 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/handler/JwtAuthenticationHandler.java @@ -0,0 +1,20 @@ +package starlight.adapter.auth.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationHandler implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException{ + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java b/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..3ac31b2f --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java @@ -0,0 +1,213 @@ +package starlight.adapter.auth.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.application.auth.required.KeyValueMap; +import starlight.application.auth.required.TokenProvider; +import starlight.domain.member.entity.Member; +import starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +import java.security.Key; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider implements TokenProvider { + + private Key key; + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.token.access-expiration-time}") + private long accessTokenExpirationTime; + + @Value("${jwt.token.refresh-expiration-time}") + private long refreshTokenExpirationTime; + + private final KeyValueMap redisClient; + + @PostConstruct + protected void init() { + byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey); + key = Keys.hmacShaKeyFor(secretKeyBytes); + } + + /** + * AccessToken을 생성하는 메서드 + * + * @param member + * @return String AccessToken + */ + @Override + public String createAccessToken(Member member) { + Claims claims = getClaims(member); + Date now = new Date(); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + accessTokenExpirationTime)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * RefreshToken을 생성하는 메서드 + * + * @param member + * @return String RefreshToken + */ + private String createRefreshToken(Member member) { + Claims claims = getClaims(member); + Date now = new Date(); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + refreshTokenExpirationTime)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * AccessToken과 RefreshToken을 생성하는 메서드 + * + * @param member + * @return TokenResponse + */ + @Override + public TokenResponse createToken(Member member) { + return TokenResponse.of( + createAccessToken(member), + createRefreshToken(member) + ); + } + + /** + * AccessToken과 RefreshToken을 재발급하는 메서드 + * + * @param member + * @param refreshToken + * @return TokenResponse + */ + @Override + public TokenResponse recreate(Member member, String refreshToken) { + String accessToken = createAccessToken(member); + + if(getExpirationTime(refreshToken) <= getExpirationTime(accessToken)) { + refreshToken = createRefreshToken(member); + } + return TokenResponse.of(accessToken, refreshToken); + } + + /** + * 토큰 유효성 검사 메서드 + * + * @param token + * @return boolean + */ + @Override + public boolean validateToken(String token) { + try { + Jws claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return !claims.getBody().getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } + + /** + * Bearer Token에서 이메일을 추출하는 메서드 + * + * @param token + * @return String + */ + @Override + public String getEmail(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject(); + } + + /** + * AccessToken의 만료 시간을 가져오는 메서드 + * + * @param token + * @return Long + */ + @Override + public Long getExpirationTime(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration().getTime(); + } + + /** + * Claims 객체를 생성하는 메서드 + * + * @param member + * @return Claims + */ + private Claims getClaims(Member member) { + return Jwts.claims().setSubject(member.getEmail()); + } + + + /** + * Bearer Token에서 RefreshToken을 추출하는 메서드 + * + * @param request + * @return String + */ + @Override + public String resolveRefreshToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + /** + * Bearer Token에서 AccessToken을 추출하는 메서드 + * + * @param request + * @return String + */ + @Override + public String resolveAccessToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + /** + * 토큰을 무효화하는 메서드 + * + * @param refreshToken + * @param accessToken + * @throws GlobalException + */ + @Override + @Transactional + public void invalidateTokens(String refreshToken, String accessToken) { + if (!validateToken(refreshToken)) { + throw new GlobalException(GlobalErrorType.INVALID_TOKEN); + } + redisClient.deleteValue(getEmail(refreshToken)); + redisClient.setValue(accessToken, "logout", getExpirationTime(accessToken)); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java b/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java new file mode 100644 index 00000000..3e7948fb --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java @@ -0,0 +1,11 @@ +package starlight.adapter.auth.security.jwt.dto; + +public record TokenResponse( + String accessToken, + + String refreshToken +) { + public static TokenResponse of(String accessToken, String refreshToken) { + return new TokenResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java b/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java new file mode 100644 index 00000000..99fa1cc3 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,52 @@ +package starlight.adapter.auth.security.oauth2; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.member.persistence.MemberRepository; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; + +import java.util.Optional; + +@Service +public class CustomOAuth2UserService implements OAuth2UserService { + + private final MemberRepository memberRepository; + private final OAuth2UserService delegate; + + public CustomOAuth2UserService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + this.delegate = new DefaultOAuth2UserService(); + } + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = delegate.loadUser(request); + OAuth2Attributes.Parsed parsed = OAuth2Attributes.parse(request, oAuth2User); + + Optional found = memberRepository.findByProviderAndProviderId(parsed.provider(), parsed.providerId()); + if (found.isEmpty() && parsed.email() != null) { + found = memberRepository.findByEmail(parsed.email()); + } + + Member member = found.orElseGet(() -> + memberRepository.save(Member.newSocial(parsed.name(), parsed.email(), parsed.provider(), parsed.providerId(), null, MemberType.FOUNDER, parsed.profileImageUrl())) + ); + + String newImage = parsed.profileImageUrl(); + if (newImage != null && !newImage.isBlank() && (member.getProfileImageUrl() == null || !member.getProfileImageUrl().equals(newImage))) { + member.updateProfileImage(newImage); + + memberRepository.save(member); + } + + return AuthDetails.of(member, oAuth2User.getAttributes(), parsed.nameAttributeKey()); + } +} diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java new file mode 100644 index 00000000..2232b637 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java @@ -0,0 +1,50 @@ +package starlight.adapter.auth.security.oauth2; + +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Map; + +public final class OAuth2Attributes { + + private OAuth2Attributes() {} + + public record Parsed( + String provider, String providerId, + String email, String name, + String profileImageUrl, + Map attributes, String nameAttributeKey + ) {} + + @SuppressWarnings("unchecked") + public static Parsed parse(OAuth2UserRequest req, OAuth2User o) { + String registrationId = req.getClientRegistration().getRegistrationId(); // google/naver/kakao + Map attributes = o.getAttributes(); + + return switch (registrationId) { + case "naver" -> { + Map response = (Map) attributes.get("response"); + if (response == null) response = Map.of(); + + String id = String.valueOf(response.getOrDefault("id", "")); + String email = (String) response.get("email"); + String name = (String) (response.getOrDefault("name", response.getOrDefault("nickname", ""))); + String profileImage = (String) response.getOrDefault("profile_image", ""); + + yield new Parsed("naver", id, email, name, profileImage, attributes, "id"); + } + case "kakao" -> { + Map response = (Map) attributes.get("kakao_account"); + if (response == null) response = Map.of(); + + String id = String.valueOf(attributes.getOrDefault("id", "")); + String email = (String) response.get("email"); + String name = (String) ((Map) response.getOrDefault("profile", Map.of())).getOrDefault("nickname", ""); + String profileImage = (String) ((Map) response.getOrDefault("profile", Map.of())).getOrDefault("profile_image_url", ""); + + yield new Parsed("kakao", id, email, name, profileImage, attributes, "id"); + } + default -> new Parsed(registrationId, null, null, null, " ", attributes, "id"); + }; + } +} diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java new file mode 100644 index 00000000..70569cb5 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java @@ -0,0 +1,51 @@ +package starlight.adapter.auth.security.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.application.auth.required.KeyValueMap; +import starlight.application.auth.required.TokenProvider; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + private final KeyValueMap redisClient; + + @Value("${app.oauth2.success-redirect:/}") + private String successRedirectBase; + + @Value("${jwt.token.refresh-expiration-time}") + private Long refreshTokenExpirationTime; + + @Override + public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse res, Authentication auth) throws IOException { + AuthDetails principal = (AuthDetails) auth.getPrincipal(); + + TokenResponse tokens = tokenProvider.createToken(principal.getUser()); + + String access = tokens.accessToken(); + String refresh = tokens.refreshToken(); + + String redirect = successRedirectBase + + "?access=" + URLEncoder.encode(access, StandardCharsets.UTF_8) + + "&refresh=" + URLEncoder.encode(refresh, StandardCharsets.UTF_8); + + redisClient.setValue(principal.member().getEmail(), tokens.refreshToken(), refreshTokenExpirationTime); + + res.sendRedirect(redirect); + } +} diff --git a/src/main/java/starlight/adapter/auth/webapi/AuthController.java b/src/main/java/starlight/adapter/auth/webapi/AuthController.java new file mode 100644 index 00000000..06ebb10e --- /dev/null +++ b/src/main/java/starlight/adapter/auth/webapi/AuthController.java @@ -0,0 +1,54 @@ +package starlight.adapter.auth.webapi; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.auth.webapi.dto.request.SignInRequest; +import starlight.adapter.auth.webapi.dto.response.MemberResponse; +import starlight.adapter.auth.webapi.swagger.AuthApiDoc; +import starlight.application.auth.provided.AuthService; +import starlight.application.auth.required.TokenProvider; +import starlight.shared.apiPayload.response.ApiResponse; + +@RestController +@RequestMapping("/v1/auth") +@RequiredArgsConstructor +public class AuthController implements AuthApiDoc { + + @Value("${jwt.header}") + private String tokenHeader; + + private final AuthService authService; + private final TokenProvider tokenProvider; + + @PostMapping("/sign-up") + public ApiResponse signUp(@Validated @RequestBody AuthRequest authRequest) { + return ApiResponse.success(authService.signUp(authRequest)); + } + + @PostMapping("/sign-in") + public ApiResponse signIn(@Validated @RequestBody SignInRequest signInRequest) { + return ApiResponse.success(authService.signIn(signInRequest)); + } + + @PostMapping("/sign-out") + public ApiResponse signOut(HttpServletRequest request) { + String refreshToken = tokenProvider.resolveRefreshToken(request); + String accessToken = tokenProvider.resolveAccessToken(request); + + authService.signOut(refreshToken, accessToken); + return ApiResponse.success("로그아웃 성공"); + } + + @GetMapping("/recreate") + public ApiResponse recreate(HttpServletRequest request, @AuthenticationPrincipal AuthDetails authDetails) { + String token = request.getHeader(tokenHeader); + return ApiResponse.success(authService.recreate(token, authDetails.getUser())); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java b/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java new file mode 100644 index 00000000..ebf134e8 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java @@ -0,0 +1,36 @@ +package starlight.adapter.auth.webapi.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; + +public record AuthRequest( + + @NotBlank(message = "이름은 필수입니다.") + @Schema(description = "이름", example = "정성호") + String name, + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "유효한 이메일 형식이어야 합니다") + @Schema(description = "이메일", example = "starLight@gmail.com") + String email, + + @NotBlank(message = "전화번호는 필수입니다") + @Pattern(regexp = "^01[0-9]-[0-9]{4}-[0-9]{4}$", message = "전화번호 형식이 올바르지 않습니다") + @Schema(description = "전화번호", example = "010-1234-5678") + String phoneNumber, + + @NotBlank(message = "비밀번호는 필수입니다") + @Schema(description = "비밀번호", example = "password123") + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + String password +) { + public Member toMember(Credential credential) { + return Member.create(name, email, phoneNumber, MemberType.FOUNDER, credential, null); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java b/src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java new file mode 100644 index 00000000..dbf7277c --- /dev/null +++ b/src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java @@ -0,0 +1,17 @@ +package starlight.adapter.auth.webapi.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record SignInRequest( + + @Schema(description = "이메일", example = "starLight@gmail.com") + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "유효한 이메일 형식이어야 합니다") + String email, + + @Schema(description = "비밀번호", example = "password123") + @NotBlank(message = "비밀번호는 필수입니다") + String password +) { } \ No newline at end of file diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java b/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java new file mode 100644 index 00000000..6d7ef416 --- /dev/null +++ b/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java @@ -0,0 +1,28 @@ +package starlight.adapter.auth.webapi.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; + +public record MemberResponse( + @Schema(description = "회원 ID", example = "1") + Long id, + + @Schema(description = "이메일", example = "starLight@gmail.com") + String email, + + @Schema(description = "전화번호", example = "010-1234-5678") + String phoneNumber, + + @Schema(description = "회원 타입", example = "FOUNDER | EXPERT") + MemberType memberType +) { + public static MemberResponse of(Member member) { + return new MemberResponse( + member.getId(), + member.getEmail(), + member.getPhoneNumber(), + member.getMemberType() + ); + } +} diff --git a/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java b/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java new file mode 100644 index 00000000..dbc8b0fd --- /dev/null +++ b/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java @@ -0,0 +1,154 @@ +package starlight.adapter.auth.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.auth.webapi.dto.request.SignInRequest; +import starlight.adapter.auth.webapi.dto.response.MemberResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +@Tag(name = "사용자", description = "사용자 관련 API") +public interface AuthApiDoc { + + @Operation( + summary = "회원가입", + description = "사용자 회원가입 기능" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "회원가입 성공", + content = @Content( + schema = @Schema(implementation = MemberResponse.class), + examples = @ExampleObject( + name = "회원가입 성공", + value = """ + { + "result": "SUCCESS", + "data": { + "id": 1, + "email": "starLight@gmail.com", + "phoneNumber": null, + "nickname": "starLight" + }, + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = @Content( + examples = { + @ExampleObject( + name = "이미 존재하는 회원", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MEMBER_ALREADY_EXISTS", + "message": "이미 존재하는 회원입니다." + } + } + """ + ) + } + ) + ) + }) + @PostMapping("/sign-up") + ApiResponse signUp( + @RequestBody( + description = "회원가입 정보", + required = true, + content = @Content( + examples = @ExampleObject( + name = "회원가입 요청", + value = """ + { + "name": "박나리", + "email": "starLight@gmail.com", + "phoneNumber": "010-2112-9765", + "password": "password123" + } + """ + ) + ) + ) + @org.springframework.web.bind.annotation.RequestBody AuthRequest authRequest + ); + + @Operation( + summary = "로그인", + description = "사용자 로그인 기능" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content( + schema = @Schema(implementation = TokenResponse.class), + examples = @ExampleObject( + name = "로그인 성공", + value = """ + { + "result": "SUCCESS", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NTk2OTA5MDB9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NjAyOTIxMDB9..." + }, + "error": null + } + """ + ) + ) + ) + }) + @PostMapping("/sign-in") + ApiResponse signIn( + @RequestBody( + description = "로그인 정보", + required = true, + content = @Content( + examples = @ExampleObject( + name = "로그인 요청", + value = """ + { + "email": "starLight@gmail.com", + "password": "password123" + } + """ + ) + ) + ) + @org.springframework.web.bind.annotation.RequestBody SignInRequest signInRequest + ); + + @Operation( + summary = "로그아웃", + description = "사용자 로그아웃 기능" + ) + @PostMapping("/sign-out") + ApiResponse signOut(HttpServletRequest request); + + @Operation( + summary = "토큰 재발급", + description = "AccessToken 만료 시 RefreshToken으로 AccessToken 재발급" + ) + @GetMapping("/recreate") + ApiResponse recreate(HttpServletRequest request, @AuthenticationPrincipal AuthDetails authDetails); +} diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java new file mode 100644 index 00000000..ce461cc3 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java @@ -0,0 +1,39 @@ +package starlight.adapter.businessplan.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Repository; +import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.exception.BusinessPlanErrorType; +import starlight.domain.businessplan.exception.BusinessPlanException; + +@Repository +@RequiredArgsConstructor +public class BusinessPlanJpa implements BusinessPlanQuery { + + private final BusinessPlanRepository businessPlanRepository; + + @Override + public BusinessPlan getOrThrow(Long id) { + return businessPlanRepository.findById(id).orElseThrow( + () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND) + ); + } + + @Override + public BusinessPlan save(BusinessPlan businessPlan) { + return businessPlanRepository.save(businessPlan); + } + + @Override + public void delete(BusinessPlan businessPlan) { + businessPlanRepository.delete(businessPlan); + } + + @Override + public Page findPreviewPage(Long memberId, Pageable pageable) { + return businessPlanRepository.findAllByMemberIdOrderedByLastSavedAt(memberId, pageable); + } +} diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java new file mode 100644 index 00000000..10beb7a6 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java @@ -0,0 +1,25 @@ +package starlight.adapter.businessplan.persistence; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import starlight.domain.businessplan.entity.BusinessPlan; + +import java.util.Optional; + +public interface BusinessPlanRepository extends JpaRepository { + + @EntityGraph(attributePaths = { "feasibility", "problemRecognition", "growthTactic", "teamCompetence", "overview" }) + Optional findById(Long id); + + @Query(""" + SELECT bp + FROM BusinessPlan bp + WHERE bp.memberId = :memberId + ORDER BY COALESCE(bp.modifiedAt, bp.createdAt) DESC, bp.id DESC + """) + Page findAllByMemberIdOrderedByLastSavedAt(@Param("memberId") Long memberId, Pageable pageable); +} diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java new file mode 100644 index 00000000..875bd0d3 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java @@ -0,0 +1,86 @@ +package starlight.adapter.businessplan.spellcheck; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import starlight.adapter.businessplan.spellcheck.dto.Finding; +import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; +import starlight.application.businessplan.required.SpellChecker; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class DaumSpellChecker implements SpellChecker { + + private static final int MAX_CHARS = 1000; // 요청 글자 수 제한 + private static final long DAUM_MIN_INTERVAL_MS = 400L; // 호출 간 최소 간격 + + private final RestClient spellCheckClient; + private final SpellCheckUtil spellCheckUtil; + + public String applyTopSuggestions(String original, List findings) { + + String fixed = original; + if (findings == null || findings.isEmpty()) return fixed; + + List sorted = new ArrayList<>(findings); + sorted.sort(Comparator.comparingInt((Finding f) -> f.token() != null ? f.token().length() : 0) + .reversed()); + + for (Finding f : sorted) { + if (f.token() != null && f.suggestions() != null && !f.suggestions().isEmpty()) { + fixed = fixed.replace(f.token(), f.suggestions().get(0)); + } + } + return fixed; + } + + public List check(String sentence) { + + List parts = spellCheckUtil.splitByLength(sentence, ".,\n", MAX_CHARS); + + if (parts.isEmpty()) { + return List.of(); + } + + List checkedSpells = new ArrayList<>(parts.size() * 8); + + for (int i = 0; i < parts.size(); i++) { + String chunk = parts.get(i); + String spellCheckResponseRaw = requestHtml(chunk); + checkedSpells.addAll(spellCheckUtil.parseToFinding(spellCheckResponseRaw)); + throttle(i, parts.size()); + } + + return checkedSpells; + } + + private String requestHtml(String chunk) { + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("sentence", chunk); + return spellCheckClient.post() + .uri("/grammar_checker.do") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(String.class); + } + + private void throttle(int index, int total) { + + if (index < total - 1) { + try { + Thread.sleep(DAUM_MIN_INTERVAL_MS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/dto/Finding.java b/src/main/java/starlight/adapter/businessplan/spellcheck/dto/Finding.java new file mode 100644 index 00000000..06acb5c2 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/spellcheck/dto/Finding.java @@ -0,0 +1,38 @@ +package starlight.adapter.businessplan.spellcheck.dto; + +import java.util.List; + +public record Finding( + String type, // data-error-type (space, spell, doubt, ...) + String severity, // txt_word 강조: error / doubt / normal + String token, // 잘못된 원문 토큰(data-error-input) + List suggestions, // 교정 제안(data-error-output) - 보통 1개 + String visible, // 화면에 보이는 제안 텍스트(.txt_word) + String original, // 원문 스팬(.inner_spell) + String context, // 주변 문맥(data-error-context) + String help, // 도움말(ul#help 등) + List examples // 예문 리스트(div.lst ul#examples li) +) { + public static Finding of(String type, String severity, String token, List suggestions, + String visible, String original, String context, String help, List examples) + { + String severe = (severity == null || severity.isBlank()) ? "normal" : severity; + List suggestion = (suggestions == null) ? List.of() : List.copyOf(suggestions); + List example = (examples == null) ? List.of() : List.copyOf(examples); + return new Finding( + nullIfBlank(type), + severe, + nullIfBlank(token), + suggestion, + nullIfBlank(visible), + nullIfBlank(original), + nullIfBlank(context), + nullIfBlank(help), + example + ); + } + + private static String nullIfBlank(String s) { + return (s == null || s.isBlank()) ? null : s; + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/util/SpellCheckUtil.java b/src/main/java/starlight/adapter/businessplan/spellcheck/util/SpellCheckUtil.java new file mode 100644 index 00000000..756c3c7f --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/spellcheck/util/SpellCheckUtil.java @@ -0,0 +1,104 @@ +package starlight.adapter.businessplan.spellcheck.util; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.springframework.stereotype.Component; +import starlight.adapter.businessplan.spellcheck.dto.Finding; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Component +public class SpellCheckUtil { + + public List splitByLength(String s, String delimiters, int maxLen) { + if (s.length() <= maxLen) { + return List.of(s); + }; + + Set sep = new HashSet<>(); + for (char c : delimiters.toCharArray()) sep.add(c); + + List out = new ArrayList<>(); + StringBuilder buf = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + buf.append(s.charAt(i)); + if (buf.length() >= maxLen) { + int cut = -1; + for (int j = buf.length() - 1; j >= Math.max(0, buf.length() - 50); j--) { + if (sep.contains(buf.charAt(j))) { cut = j + 1; break; } + } + if (cut == -1) cut = buf.length(); + out.add(buf.substring(0, cut)); + buf.delete(0, cut); + } + } + if (buf.length() > 0) out.add(buf.toString()); + return out; + } + + + public List parseToFinding(String html) { + Document doc = Jsoup.parse(html); + List list = new ArrayList<>(); + + for (Element el : doc.select("a.txt_spell[data-error-type]")) { + String type = decode(el.attr("data-error-type")); + String token = decode(el.attr("data-error-input")); + String out = decode(el.attr("data-error-output")); + String ctx = decode(el.attr("data-error-context")); + + String visible = null; + String severity = "normal"; + Element word = el.selectFirst("span.txt_word"); + if (word != null) { + word.select("button").remove(); // 버튼 제거 + visible = decode(word.text()).trim(); + var classes = word.classNames(); + if (classes.contains("txt_error")) severity = "error"; // 확정 오류 + else if (classes.contains("txt_error2")) severity = "doubt"; // 오류 의심 + } + + String original = null; + Element inner = el.selectFirst("span.inner_spell"); + if (inner != null) original = decode(inner.text()).trim(); + + String help = null; + List examples = new ArrayList<>(); + Element contents = el.selectFirst("span[name=contents]"); + if (contents != null) { + Element helpUl = contents.selectFirst("ul#help"); + if (helpUl != null) { + help = decode(helpUl.text()).trim(); + if ("도움말이 없습니다.".equals(help)) help = null; + } else { + String raw = decode(contents.text()); + raw = raw.replaceAll("\\t", "") + .replaceAll("\\n{3,}", "\n(예)\n") + .replaceAll("\\n+$", "") + .replaceAll("^[ \\n]+", "") + .trim(); + if (!raw.isBlank()) help = raw; + } + for (Element li : contents.select("div.lst ul#examples li")) { + String ex = decode(li.text()).trim(); + if (!ex.isBlank()) examples.add(ex); + } + } + + List suggestions = new ArrayList<>(); + if (out != null && !out.isBlank()) suggestions.add(out.trim()); + + list.add(Finding.of(type, severity, token, suggestions, visible, original, ctx, help, examples)); + } + return list; + } + + private static String decode(String s) { + if (s == null) return null; + return Jsoup.parse(s).text(); + } +} diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java new file mode 100644 index 00000000..fcc9d431 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java @@ -0,0 +1,163 @@ +package starlight.adapter.businessplan.webapi; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateRequest; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; +import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/business-plans") +@Tag(name = "사업계획서", description = "사업계획서 API") +public class BusinessPlanController { + + private final BusinessPlanService businessPlanService; + private final ObjectMapper objectMapper; + + @GetMapping + @Operation(summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)") + public ApiResponse getBusinessPlanList( + @AuthenticationPrincipal AuthDetails authDetails, + @Parameter(description = "페이지 번호 (1 이상 정수 / 기본 1)") @RequestParam(defaultValue = "1") @Min(1)int page, + @Parameter(description = "페이지 크기 (1 이상 정수 / 기본 3)") @RequestParam(defaultValue = "3") @Min(1) int size + ) { + int zeroBasedPage = Math.max(0, page - 1); + Pageable pageable = PageRequest.of(zeroBasedPage, size); + return ApiResponse.success(businessPlanService.getBusinessPlanList( + authDetails.getMemberId(), pageable + )); + } + + @GetMapping("/{planId}/subsections") + @Operation(summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)") + public ApiResponse getBusinessPlanDetail( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ) { + return ApiResponse.success(businessPlanService.getBusinessPlanDetail( + planId, authDetails.getMemberId() + )); + } + + @GetMapping("/{planId}/titles") + @Operation(summary = "사업 계획서의 제목을 조회합니다.") + public ApiResponse getBusinessPlanTitle( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ) { + return ApiResponse.success(businessPlanService + .getBusinessPlanInfo(planId, authDetails.getMemberId()) + .title() + ); + } + + @PostMapping + @Operation(summary = "사업 계획서를 생성합니다.") + public ApiResponse createBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails + ) { + return ApiResponse.success(businessPlanService.createBusinessPlan(authDetails.getMemberId())); + } + + @PostMapping("/pdf") + @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성합니다.") + public ApiResponse createBusinessPlanWithPdfAndAiReport( + @AuthenticationPrincipal AuthDetails authDetails, + @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + ) { + return ApiResponse.success(businessPlanService.createBusinessPlanWithPdf( + request.title(), request.pdfUrl(), authDetails.getMemberId() + )); + } + + @PatchMapping("/{planId}") + @Operation(summary = "사업 계획서 제목을 수정합니다.") + public ApiResponse updateBusinessPlanTitle( + @AuthenticationPrincipal AuthDetails authDetails, + @RequestBody @Valid BusinessPlanCreateRequest request, + @PathVariable Long planId + ) { + return ApiResponse.success(businessPlanService.updateBusinessPlanTitle( + planId, request.title(), authDetails.getMemberId() + )); + } + + @Operation(summary = "사업 계획서를 삭제합니다.") + @DeleteMapping("/{planId}") + public ApiResponse deleteBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ) { + return ApiResponse.success(businessPlanService.deleteBusinessPlan( + planId, authDetails.getMemberId() + )); + } + + @Operation(summary = "서브섹션을 생성 또는 수정합니다.") + @PostMapping("/{planId}/subsections") + public ApiResponse upsertSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @Valid @RequestBody SubSectionCreateRequest request + ) { + return ApiResponse.success(businessPlanService.upsertSubSection( + planId, objectMapper.valueToTree(request), request.checks(), request.subSectionType(), authDetails.getMemberId() + )); + } + + @Operation(summary = "서브섹션을 조회합니다.") + @GetMapping("/{planId}/subsections/{subSectionType}") + public ApiResponse getSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @PathVariable SubSectionType subSectionType + ) { + return ApiResponse.success(businessPlanService.getSubSectionDetail( + planId, subSectionType, authDetails.getMemberId() + )); + } + + @Operation(summary = "서브섹션의 체크리스트를 점검 후 업데이트합니다.") + @PostMapping("/{planId}/subsections/check-and-update") + public ApiResponse> checkAndUpdateSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @Valid @RequestBody SubSectionCreateRequest request + ) { + return ApiResponse.success(businessPlanService.checkAndUpdateSubSection( + planId, objectMapper.valueToTree(request), request.subSectionType(), authDetails.getMemberId() + )); + } + + @Operation(summary = "서브섹션을 삭제합니다.") + @DeleteMapping("/{planId}/subsections/{subSectionType}") + public ApiResponse deleteSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @PathVariable SubSectionType subSectionType + ) { + return ApiResponse.success(businessPlanService.deleteSubSection( + planId, subSectionType, authDetails.getMemberId() + )); + } +} diff --git a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java new file mode 100644 index 00000000..71df82ec --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java @@ -0,0 +1,38 @@ +package starlight.adapter.businessplan.webapi; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.businessplan.webapi.dto.SpellCheckResponse; +import starlight.adapter.businessplan.spellcheck.dto.Finding; +import starlight.adapter.businessplan.webapi.swagger.SpellCheckApiDoc; +import starlight.application.businessplan.required.SpellChecker; +import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; +import starlight.application.businessplan.util.PlainTextExtractUtils; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequestMapping("/v1/business-plans") +@RequiredArgsConstructor +public class SpellController implements SpellCheckApiDoc { + + private final ObjectMapper objectMapper; + private final SpellChecker spellChecker; + + @Override + public ApiResponse check( + @Valid @RequestBody SubSectionCreateRequest subSectionCreateRequest + ) { + String text = PlainTextExtractUtils.extractPlainText(objectMapper, subSectionCreateRequest); + + List typos = spellChecker.check(text); + String corrected = spellChecker.applyTopSuggestions(text, typos); + + return ApiResponse.success(SpellCheckResponse.of(typos, corrected)); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateRequest.java new file mode 100644 index 00000000..36357cff --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateRequest.java @@ -0,0 +1,10 @@ +package starlight.adapter.businessplan.webapi.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record BusinessPlanCreateRequest ( + @NotBlank(message = "제목 입력은 필수입니다.") + @Schema(description = "제목", example = "예비창업패키지 사업계획서") + String title +) {} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java new file mode 100644 index 00000000..a2b85000 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java @@ -0,0 +1,13 @@ +package starlight.adapter.businessplan.webapi.dto; + +import jakarta.validation.constraints.NotBlank; + +public record BusinessPlanCreateWithPdfRequest( + @NotBlank(message = "제목은 필수입니다.") + String title, + + @NotBlank(message = "PDF URL은 필수입니다.") + String pdfUrl +) {} + + diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/SpellCheckResponse.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/SpellCheckResponse.java new file mode 100644 index 00000000..b50e5d34 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/SpellCheckResponse.java @@ -0,0 +1,16 @@ +package starlight.adapter.businessplan.webapi.dto; + +import starlight.adapter.businessplan.spellcheck.dto.Finding; + +import java.util.List; + +public record SpellCheckResponse( + + List typos, + + String corrected +) { + public static SpellCheckResponse of(List typos, String corrected) { + return new SpellCheckResponse(typos, corrected); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java new file mode 100644 index 00000000..6c6ba559 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/SubSectionCreateRequest.java @@ -0,0 +1,188 @@ +package starlight.adapter.businessplan.webapi.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public record SubSectionCreateRequest( + @NotNull SubSectionType subSectionType, + @NotNull List checks, + @Valid @NotNull Meta meta, + @Valid @NotNull List<@Valid Block> blocks) { + public record Meta( + @NotBlank String author, + @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$") String createdAt) { + } + + public record Block( + @Valid @NotNull BlockMeta meta, + @Valid List<@Valid GeneralContent> content) { + } + + public record BlockMeta( + @NotBlank String title) { + } + + // 테이블 셀 내부에서 사용하는 기본 콘텐츠 (TextItem, ImageItem만) + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) + @JsonSubTypes({ + @JsonSubTypes.Type(value = TextItem.class, name = "text"), + @JsonSubTypes.Type(value = ImageItem.class, name = "image") + }) + public sealed interface BasicContent + permits TextItem, ImageItem { + String type(); + } + + // 블록의 content에서 사용하는 일반 콘텐츠 (BasicContent + TableItem) + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = true) + @JsonSubTypes({ + @JsonSubTypes.Type(value = TextItem.class, name = "text"), + @JsonSubTypes.Type(value = ImageItem.class, name = "image"), + @JsonSubTypes.Type(value = TableItem.class, name = "table") + }) + public sealed interface GeneralContent + permits TextItem, ImageItem, TableItem { + String type(); + } + + public record TextItem( + @NotBlank String type, + String value) implements BasicContent, GeneralContent { + } + + public record ImageItem( + @NotBlank String type, + @NotBlank @Size(max = 1024) String src, + @JsonProperty(defaultValue = "400") Integer width, + @JsonProperty(defaultValue = "400") Integer height, + @Size(max = 255) String caption) implements BasicContent, GeneralContent { + public ImageItem { + width = width != null ? width : 400; + height = height != null ? height : 400; + } + } + + // 컬럼 정보 (헤더 제거, 개수와 너비만) + public record TableColumn( + Integer width) { + } + + // 셀 데이터 + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public record TableCell( + @NotEmpty List<@Valid BasicContent> content, + @Min(1) Integer rowspan, + @Min(1) Integer colspan) { + public TableCell { + rowspan = rowspan != null ? rowspan : 1; + colspan = colspan != null ? colspan : 1; + } + } + + public record TableItem( + @NotBlank String type, + @NotEmpty List<@Valid TableColumn> columns, + @NotEmpty List> rows) implements GeneralContent { + + @AssertTrue(message = "table rows must match columns length considering cell spans") + @JsonIgnore + public boolean isValidCellSpans() { + if (columns == null || rows == null || rows.isEmpty()) + return false; + + int expectedWidth = columns.size(); + int rowCount = rows.size(); + + // 각 위치에서 rowspan으로 차지된 셀을 추적 + // grid[row][col] = 위에서 내려온 rowspan 셀의 남은 rowspan 값 (0이면 비어있음) + int[][] grid = new int[rowCount][expectedWidth]; + + for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { + var row = rows.get(rowIndex); + if (row == null) + return false; + + // 현재 행에서 rowspan으로 차지된 위치 수 계산 + int occupiedByRowspan = 0; + for (int col = 0; col < expectedWidth; col++) { + if (grid[rowIndex][col] > 0) { + occupiedByRowspan++; + } + } + + // 빈 행인 경우 (모든 위치가 rowspan으로 차지됨) + if (row.isEmpty()) { + if (occupiedByRowspan == expectedWidth) { + // 빈 행이 유효함 (모든 위치가 rowspan으로 차지됨) + continue; + } else { + // 빈 행인데 rowspan으로 차지되지 않은 위치가 있음 + return false; + } + } + + // 현재 행의 셀들의 colspan 합 계산 + int rowCellWidth = 0; + int colIndex = 0; + + for (var cell : row) { + if (cell == null) + return false; + + // rowspan으로 차지된 위치는 건너뛰기 (HTML 테이블 규칙) + while (colIndex < expectedWidth && grid[rowIndex][colIndex] > 0) { + colIndex++; + } + + if (colIndex >= expectedWidth) { + // 컬럼 범위를 벗어남 + return false; + } + + // colspan 범위 확인 + if (colIndex + cell.colspan() > expectedWidth) { + return false; + } + + // colspan 범위 내에 rowspan으로 차지된 셀이 있는지 확인 (HTML 테이블 규칙) + for (int i = 0; i < cell.colspan(); i++) { + if (grid[rowIndex][colIndex + i] > 0) { + // rowspan으로 이미 차지된 위치와 겹침 + return false; + } + } + + // 셀을 그리드에 배치 (rowspan이 있으면 아래 행들도 표시) + int cellRowspan = cell.rowspan(); + int cellColspan = cell.colspan(); + + for (int r = 0; r < cellRowspan && rowIndex + r < rowCount; r++) { + for (int c = 0; c < cellColspan; c++) { + if (rowIndex + r < rowCount && colIndex + c < expectedWidth) { + grid[rowIndex + r][colIndex + c] = cellRowspan - r; + } + } + } + + rowCellWidth += cellColspan; + colIndex += cellColspan; + } + + // 각 행의 실제 너비는 (컬럼 수 - rowspan으로 차지된 위치 수)와 일치해야 함 + if (rowCellWidth != expectedWidth - occupiedByRowspan) { + return false; + } + } + + return true; + } + } +} diff --git a/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java b/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java new file mode 100644 index 00000000..b3fb621c --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/swagger/SpellCheckApiDoc.java @@ -0,0 +1,92 @@ +package starlight.adapter.businessplan.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PostMapping; +import starlight.adapter.businessplan.webapi.dto.SpellCheckResponse; +import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; +import starlight.shared.apiPayload.response.ApiResponse; + +@Tag(name = "사업계획서", description = "사업계획서 API") +public interface SpellCheckApiDoc { + + @Operation( + summary = "맞춤법 검사 및 교정", + description = "입력된 텍스트의 맞춤법을 검사하고 교정된 텍스트를 반환합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "검사 성공", + content = @Content( + schema = @Schema(implementation = SpellCheckResponse.class), + examples = @ExampleObject( + name = "맞춤법 오류 발견", + value = """ + { + "result": "SUCCESS", + "data": { + "typos": [ + { + "type": "space", + "severity": "normal", + "token": "반갑습니다다리", + "suggestions": [ + "반갑습니다 다리" + ], + "visible": "반갑습니다 다리", + "original": "반갑습니다다리", + "context": "안녕하세요 반갑습니다다리", + "help": "띄어쓰기 오류입니다. 대치어를 참고하여 고쳐 쓰세요.", + "examples": [] + } + ], + "corrected": "안녕하세요 반갑습니다 다리" + }, + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = @Content( + examples = @ExampleObject( + name = "빈 텍스트", + value = """ + { + "result": "ERROR", + "data": null, + "error": "텍스트를 입력해주세요" + } + """ + ) + ) + ) + }) + @PostMapping("/spellcheck") + ApiResponse check( + @RequestBody( + description = "검사할 텍스트", + required = true, + content = @Content( + examples = @ExampleObject( + name = "예시 요청", + value = """ + { + "text": "안녕하세요 반갑습니다다리" + } + """ + ) + ) + ) + @org.springframework.web.bind.annotation.RequestBody SubSectionCreateRequest subSectionCreateRequest + ); +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java new file mode 100644 index 00000000..e3a18e2b --- /dev/null +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -0,0 +1,68 @@ +package starlight.adapter.expert.persistence; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.application.expert.required.ExpertQuery; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpertJpa implements ExpertQuery { + + private final ExpertRepository repository; + + @Override + public Expert findById(Long id) { + return repository.findById(id).orElseThrow( + () -> new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND) + ); + } + + @Override + public Expert findByIdWithDetails(Long id) { + return repository.findByIdWithDetails(id).orElseThrow( + () -> new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND) + ); + } + + @Override + public List findAllWithDetails() { + try { + return repository.findAllWithDetails(); + } catch (Exception e) { + log.error("전문가 목록 조회 중 오류가 발생했습니다.", e); + throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR); + } + } + + @Override + public List findByAllCategories(Collection categories) { + try { + return repository.findByAllCategories(categories, categories.size()); + } catch (Exception e) { + log.error("전문가 목록 필터링 중 오류가 발생했습니다.", e); + throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR); + } + } + + @Override + public Map findExpertMapByIds(Set expertIds) { + + List experts = repository.findAllWithDetailsByIds(expertIds); + + return experts.stream() + .collect(Collectors.toMap(Expert::getId, Function.identity())); + } +} diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java new file mode 100644 index 00000000..dfe059d9 --- /dev/null +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java @@ -0,0 +1,46 @@ +package starlight.adapter.expert.persistence; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface ExpertRepository extends JpaRepository { + + @Query("select distinct e from Expert e") + @EntityGraph(attributePaths = {"categories", "careers", "tags"}) + List findAllWithDetails(); + + @Query("select distinct e from Expert e where e.id in :expertIds") + @EntityGraph(attributePaths = {"categories", "careers", "tags"}) + List findAllWithDetailsByIds(Set expertIds); + + @Query(""" + select distinct e from Expert e where e.id in ( + select e2.id + from Expert e2 + join e2.categories c2 + where c2 in :cats + group by e2.id + having count(distinct c2) = :size) + """) + @EntityGraph(attributePaths = {"categories", "careers", "tags"}) + List findByAllCategories(@Param("cats") Collection cats, + @Param("size") long size); + + @Query(""" + select e from Expert e + left join fetch e.categories + left join fetch e.careers + left join fetch e.tags + where e.id = :id + """) + Optional findByIdWithDetails(@Param("id") Long id); +} diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java new file mode 100644 index 00000000..4b5cf662 --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java @@ -0,0 +1,35 @@ +package starlight.adapter.expert.webapi; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; +import starlight.adapter.expert.webapi.swagger.ExpertQueryApiDoc; +import starlight.application.expert.provided.ExpertFinder; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; +import java.util.Set; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/experts") +public class ExpertController implements ExpertQueryApiDoc { + + private final ExpertFinder expertFinder; + + @GetMapping + public ApiResponse> search( + @RequestParam(name = "categories", required = false) Set categories + ) { + List experts = (categories == null || categories.isEmpty()) + ? expertFinder.loadAll() + : expertFinder.findByAllCategories(categories); + + return ApiResponse.success(ExpertDetailResponse.fromAll(experts)); + } +} diff --git a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java new file mode 100644 index 00000000..f02ce085 --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java @@ -0,0 +1,51 @@ +package starlight.adapter.expert.webapi.dto; + +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.Collection; +import java.util.List; + +public record ExpertDetailResponse( + + Long id, + + String name, + + String profileImageUrl, + + Long workedPeriod, + + String email, + + Integer mentoringPriceWon, + + List careers, + + List tags, + + List categories +) { + public static ExpertDetailResponse from(Expert expert) { + List categories = expert.getCategories().stream() + .map(TagCategory::name) + .distinct() + .toList(); + + return new ExpertDetailResponse( + expert.getId(), + expert.getName(), + expert.getProfileImageUrl(), + expert.getWorkedPeriod(), + expert.getEmail(), + expert.getMentoringPriceWon(), + expert.getCareers(), + expert.getTags().stream().distinct().toList(), + categories + ); + } + + public static List fromAll(Collection experts){ + return experts.stream().map(ExpertDetailResponse::from).toList(); + } +} diff --git a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java new file mode 100644 index 00000000..99187fc4 --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java @@ -0,0 +1,77 @@ +package starlight.adapter.expert.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; +import starlight.domain.expert.enumerate.TagCategory; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; +import java.util.Set; + +@Tag(name = "전문가", description = "전문가 관련 API") +public interface ExpertQueryApiDoc { + + @Operation( + summary = "전문가 검색(AND 매칭)", + description = """ + 카테고리 파라미터가 없으면 전체 전문가를 반환합니다. + \n카테고리를 하나 이상 전달하면 **전달된 모든 카테고리**를 보유한 전문가만 반환합니다(AND 매칭). + \n MARKET_BM: 시장성/BM, TEAM_CAPABILITY: 팀 역량, PROBLEM_DEFINITION: 문제 정의, GROWTH_STRATEGY: 성장 전략, METRIC_DATA: 지표/데이터 + \nSwagger UI에서는 'Add item'으로 항목을 추가하면 ?categories=A&categories=B 형태로 전송됩니다. + \n예) GET /v1/experts?categories=GROWTH_STRATEGY&categories=TEAM_CAPABILITY + \n예) GET /v1/experts?categories=GROWTH_STRATEGY,TEAM_CAPABILITY + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = ExpertDetailResponse.class)), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "result": "SUCCESS", + "data": [ + { + "id": 1, + "name": "홍길동", + "profileImageUrl": "https://cdn.example.com/profiles/1.png", + "email": "hong@example.com", + "mentoringPriceWon": 50000, + "careers": ["A사 PO (2019-2022)","B스타트업 PM (2023-)"], + "categories": ["성장 전략","팀 역량"] + }, + { + "id": 2, + "name": "이영희", + "profileImageUrl": "https://cdn.example.com/profiles/2.png", + "email": "lee@example.com", + "mentoringPriceWon": 70000, + "careers": ["C기업 데이터분석 (2020-)"], + "categories": ["시장성/BM","지표/데이터"] + } + ], + "error": null + } + """ + ) + ) + ), + }) + @GetMapping + ApiResponse> search( + @RequestParam(name = "categories", required = false) + Set categories + ); +} diff --git a/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java b/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java new file mode 100644 index 00000000..fb7b2a2d --- /dev/null +++ b/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java @@ -0,0 +1,62 @@ +package starlight.adapter.expertApplication.email; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import starlight.application.expertApplication.required.EmailSender; +import starlight.application.expertApplication.event.FeedbackRequestDto; +import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; +import starlight.domain.expertApplication.exception.ExpertApplicationException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SMTPEmailSender implements EmailSender { + + private final JavaMailSender javaMailSender; + private final SpringTemplateEngine templateEngine; + + @Value("${spring.mail.username}") + private String senderEmail; + + @Override + public void sendFeedbackRequestMail(FeedbackRequestDto dto) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(senderEmail); + helper.setTo(dto.mentorEmail()); + helper.setSubject("[STARLIGHT] " + dto.menteeName() + "의 사업계획서 검토 요청"); + + Context ctx = new Context(); + ctx.setVariable("mentorName", dto.mentorName()); + ctx.setVariable("studentName", dto.menteeName()); + ctx.setVariable("planTitle", dto.businessPlanTitle()); + ctx.setVariable("feedbackDeadline", dto.feedbackDeadline()); + ctx.setVariable("feedbackUrl", dto.feedbackUrl()); + + String htmlContent = templateEngine.process("feedback-request", ctx); + helper.setText(htmlContent, true); + + if (dto.attachedFile() != null && dto.filename() != null) { + helper.addAttachment(dto.filename(), new ByteArrayResource(dto.attachedFile())); + } + + javaMailSender.send(message); + log.info("피드백 요청 메일 발송 성공 - To: {}", dto.mentorEmail()); + + } catch (MessagingException e) { + log.error("피드백 요청 메일 발송 실패 - To: {}", dto.mentorEmail(), e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EMAIL_SEND_ERROR); + } + } +} diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java new file mode 100644 index 00000000..5b57ab7c --- /dev/null +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -0,0 +1,44 @@ +package starlight.adapter.expertApplication.persistence; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.application.expertApplication.required.ExpertApplicationQuery; +import starlight.domain.expertApplication.entity.ExpertApplication; +import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; +import starlight.domain.expertApplication.exception.ExpertApplicationException; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpertApplicationJpa implements ExpertApplicationQuery { + + private final ExpertApplicationRepository repository; + + @Override + public Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId) { + try { + return repository.existsByExpertIdAndBusinessPlanId(expertId, businessPlanId); + } catch (Exception e) { + log.error("전문가 신청 존재 여부 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } + + @Override + public List findRequestedExpertIds(Long businessPlanId) { + try { + return repository.findRequestedExpertIdsByPlanId(businessPlanId); + } catch (Exception e) { + log.error("신청된 전문가 목록 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } + + @Override + public ExpertApplication save(ExpertApplication application) { + return repository.save(application); + } +} diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java new file mode 100644 index 00000000..a912bd5b --- /dev/null +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java @@ -0,0 +1,20 @@ +package starlight.adapter.expertApplication.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import starlight.domain.expertApplication.entity.ExpertApplication; + +import java.util.List; + +public interface ExpertApplicationRepository extends JpaRepository { + + Boolean existsByExpertIdAndBusinessPlanId(Long mentorId, Long businessPlanId); + + @Query(""" + select distinct e.expertId + from ExpertApplication e + where e.businessPlanId = :businessPlanId + """) + List findRequestedExpertIdsByPlanId(@Param("businessPlanId") Long businessPlanId); +} diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java new file mode 100644 index 00000000..7d46e333 --- /dev/null +++ b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java @@ -0,0 +1,43 @@ +package starlight.adapter.expertApplication.webapi; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.expertApplication.webapi.swagger.ExpertApplicationApiDoc; +import starlight.application.expertApplication.provided.ExpertApplicationService; +import starlight.application.expertApplication.required.ExpertApplicationQuery; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/expert-applications") +public class ExpertApplicationController implements ExpertApplicationApiDoc { + + private final ExpertApplicationQuery finder; + private final ExpertApplicationService expertApplicationService; + + @GetMapping + public ApiResponse> search( + @RequestParam Long businessPlanId + ) { + return ApiResponse.success(finder.findRequestedExpertIds(businessPlanId)); + } + + @PostMapping(value = "/{expertId}/request", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse requestFeedback( + @PathVariable Long expertId, + @RequestParam Long businessPlanId, + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal AuthDetails auth + ) throws Exception { + expertApplicationService.requestFeedback(expertId, businessPlanId, file, auth.getUser().getName()); + return ApiResponse.success("피드백 요청이 전달되었습니다."); + } +} diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java new file mode 100644 index 00000000..b73f81b4 --- /dev/null +++ b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java @@ -0,0 +1,251 @@ +package starlight.adapter.expertApplication.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import starlight.adapter.auth.security.auth.AuthDetails; + +import java.util.List; + +@Tag(name = "전문가", description = "전문가 관련 API") +public interface ExpertApplicationApiDoc { + + @Operation( + summary = "피드백 요청한 전문가 목록 조회", + description = "특정 사업계획서에 피드백을 요청한 전문가들의 ID 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": [1, 3, 5, 7], + "error": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "사업계획서를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "사업계획서를 찾을 수 없습니다." + } + } + """ + ) + ) + ) + }) + starlight.shared.apiPayload.response.ApiResponse> search( + @Parameter( + description = "사업계획서 ID", + required = true, + example = "1" + ) + @RequestParam Long businessPlanId + ); + + @Operation( + summary = "전문가에게 피드백 요청", + description = """ + 특정 전문가에게 사업계획서에 대한 피드백을 요청합니다. + + - 사업계획서 PDF 파일을 첨부하여 전문가 이메일로 발송합니다. + - 동일한 전문가에게 동일한 사업계획서로 중복 요청할 수 없습니다. + - 이메일 발송은 비동기로 처리되며, 요청 즉시 응답을 반환합니다. + """, + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "피드백 요청 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "피드백 요청이 전달되었습니다.", + "error": null + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (파일 없음, 파일 형식 오류 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_FILE", + "message": "유효하지 않은 파일입니다." + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "전문가 또는 사업계획서를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "전문가를 찾을 수 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "전문가를 찾을 수 없습니다." + } + } + """ + ), + @ExampleObject( + name = "사업계획서를 찾을 수 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "사업계획서를 찾을 수 없습니다." + } + } + """ + ) + } + ) + ), + @ApiResponse( + responseCode = "409", + description = "이미 피드백을 요청한 전문가", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "APPLICATION_ALREADY_EXISTS", + "message": "이미 신청한 전문가입니다." + } + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 오류 (파일 처리 실패, 이메일 발송 실패 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 오류가 발생했습니다." + } + } + """ + ) + ) + ) + }) + @RequestBody( + description = "피드백 요청 정보", + required = true, + content = @Content( + mediaType = "multipart/form-data", + schema = @Schema(implementation = FeedbackRequestSchema.class) + ) + ) + starlight.shared.apiPayload.response.ApiResponse requestFeedback( + @Parameter( + description = "전문가 ID", + required = true, + example = "1" + ) + @PathVariable Long expertId, + + @Parameter( + description = "사업계획서 ID", + required = true, + example = "10" + ) + @RequestParam Long businessPlanId, + + @Parameter( + description = "사업계획서 PDF 파일 (최대 50MB)", + required = true, + content = @Content(mediaType = "application/pdf") + ) + @RequestParam("file") MultipartFile file, + + @Parameter(hidden = true) + @AuthenticationPrincipal AuthDetails auth + ) throws Exception; + + /** + * Swagger 문서화를 위한 스키마 클래스 + */ + @Schema(description = "피드백 요청 데이터") + class FeedbackRequestSchema { + + @Schema( + description = "사업계획서 ID", + example = "10", + required = true + ) + public Long businessPlanId; + + @Schema( + description = "사업계획서 PDF 파일", + type = "string", + format = "binary", + required = true, + maxLength = 52428800 // 50MB + ) + public MultipartFile file; + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java new file mode 100644 index 00000000..4cd23163 --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java @@ -0,0 +1,53 @@ +package starlight.adapter.expertReport.persistence; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.application.expertReport.required.ExpertReportQuery; +import starlight.domain.expertReport.entity.ExpertReport; +import starlight.domain.expertReport.exception.ExpertReportErrorType; +import starlight.domain.expertReport.exception.ExpertReportException; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpertReportJpa implements ExpertReportQuery { + + private final ExpertReportRepository repository; + + @Override + public ExpertReport getOrThrow(Long id) { + return repository.findById(id).orElseThrow( + () -> new ExpertReportException(ExpertReportErrorType.EXPERT_REPORT_NOT_FOUND) + ); + } + + @Override + public ExpertReport save(ExpertReport expertReport) { + return repository.save(expertReport); + } + + @Override + public void delete(ExpertReport expertReport) { + repository.delete(expertReport); + } + + @Override + public boolean existsByToken(String token) { + return repository.existsByToken(token); + } + + @Override + public ExpertReport findByTokenWithDetails(String token) { + return repository.findByToken(token).orElseThrow( + () -> new ExpertReportException(ExpertReportErrorType.EXPERT_REPORT_NOT_FOUND) + ); + } + + @Override + public List findAllByBusinessPlanId(Long businessPlanId) { + return repository.findAllByBusinessPlanIdOrderByCreatedAtDesc(businessPlanId); + } +} diff --git a/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java new file mode 100644 index 00000000..b46a4b66 --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java @@ -0,0 +1,19 @@ +package starlight.adapter.expertReport.persistence; + +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.expertReport.entity.ExpertReport; + +import java.util.List; +import java.util.Optional; + +public interface ExpertReportRepository extends JpaRepository { + + boolean existsByToken(String token); + + @EntityGraph(attributePaths = {"details"}) + Optional findByToken(String token); + + @EntityGraph(attributePaths = {"details"}) + List findAllByBusinessPlanIdOrderByCreatedAtDesc(Long businessPlanId); +} diff --git a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java new file mode 100644 index 00000000..5b62567c --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java @@ -0,0 +1,78 @@ +package starlight.adapter.expertReport.webapi; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.expertReport.webapi.dto.ExpertReportResponse; +import starlight.adapter.expertReport.webapi.dto.UpsertExpertReportRequest; +import starlight.adapter.expertReport.webapi.mapper.ExpertReportMapper; +import starlight.application.expertReport.provided.ExpertReportService; +import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto; +import starlight.domain.expertReport.entity.ExpertReport; +import starlight.domain.expertReport.entity.ExpertReportDetail; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequestMapping("/v1/expert-reports") +@RequiredArgsConstructor +@Tag(name = "전문가", description = "전문가 관련 API") +public class ExpertReportController { + + private final ExpertReportMapper mapper; + private final ExpertReportService expertReportService; + + @Operation(summary = "전문가 리포트 목록을 조회합니다. (사용자 사용)") + @GetMapping + public ApiResponse> getExpertReports( + @RequestParam Long businessPlanId + ) { + List dtos = expertReportService + .getExpertReportsWithExpertByBusinessPlanId(businessPlanId); + + List responses = dtos.stream() + .map(dto -> ExpertReportResponse.fromEntities( + dto.report(), + dto.expert() + )) + .toList(); + + return ApiResponse.success(responses); + } + + @Operation(summary = "전문가 리포트를 조회합니다. (전문가 사용)") + @GetMapping("/{token}") + public ApiResponse getExpertReport( + @PathVariable String token + ) { + ExpertReportWithExpertDto dto = expertReportService.getExpertReportWithExpert(token); + + ExpertReportResponse response = ExpertReportResponse.fromEntities( + dto.report(), + dto.expert() + ); + + return ApiResponse.success(response); + } + + @Operation(summary = "전문가 리포트를 저장합니다 (전문가 사용)") + @PostMapping("/{token}") + public ApiResponse save( + @PathVariable String token, + @Valid @RequestBody UpsertExpertReportRequest request + ) { + List details = mapper.toEntityList(request.details()); + + ExpertReport report = expertReportService.saveReport( + token, + request.overallComment(), + details, + request.saveType() + ); + + return ApiResponse.success(ExpertReportResponse.from(report)); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java new file mode 100644 index 00000000..b18164ca --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java @@ -0,0 +1,13 @@ +package starlight.adapter.expertReport.webapi.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import starlight.domain.expertReport.enumerate.CommentType; + +public record CreateExpertReportDetailRequest( + @NotNull(message = "평가 타입은 필수입니다") + CommentType commentType, + + @NotBlank(message = "내용은 필수입니다") + String content +) { } \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java new file mode 100644 index 00000000..0390448b --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java @@ -0,0 +1,17 @@ +package starlight.adapter.expertReport.webapi.dto; + +import starlight.domain.expertReport.entity.ExpertReportDetail; +import starlight.domain.expertReport.enumerate.CommentType; + +public record ExpertReportDetailResponse( + CommentType commentType, + + String content +) { + public static ExpertReportDetailResponse from(ExpertReportDetail detail) { + return new ExpertReportDetailResponse( + detail.getCommentType(), + detail.getContent() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java new file mode 100644 index 00000000..d53d86c8 --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java @@ -0,0 +1,44 @@ +package starlight.adapter.expertReport.webapi.dto; + +import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expertReport.entity.ExpertReport; +import starlight.domain.expertReport.enumerate.SubmitStatus; + +import java.util.List; + +public record ExpertReportResponse( + ExpertDetailResponse expertDetailResponse, + + SubmitStatus status, + + boolean canEdit, + + String overallComment, + + List details +) { + public static ExpertReportResponse fromEntities(ExpertReport report, Expert expert) { + return new ExpertReportResponse( + ExpertDetailResponse.from(expert), + report.getSubmitStatus(), + report.canEdit(), + report.getOverallComment(), + report.getDetails().stream() + .map(ExpertReportDetailResponse::from) + .toList() + ); + } + + public static ExpertReportResponse from(ExpertReport report) { + return new ExpertReportResponse( + null, + report.getSubmitStatus(), + report.canEdit(), + report.getOverallComment(), + report.getDetails().stream() + .map(ExpertReportDetailResponse::from) + .toList() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java new file mode 100644 index 00000000..1f2c429c --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java @@ -0,0 +1,16 @@ +package starlight.adapter.expertReport.webapi.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import starlight.domain.expertReport.enumerate.SaveType; + +import java.util.List; + +public record UpsertExpertReportRequest( + @NotNull(message = "저장 유형은 필수입니다") + SaveType saveType, + + String overallComment, + + List<@Valid CreateExpertReportDetailRequest> details +) { } \ No newline at end of file diff --git a/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java new file mode 100644 index 00000000..89cb75e0 --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java @@ -0,0 +1,23 @@ +package starlight.adapter.expertReport.webapi.mapper; + +import org.springframework.stereotype.Component; +import starlight.adapter.expertReport.webapi.dto.CreateExpertReportDetailRequest; +import starlight.domain.expertReport.entity.ExpertReportDetail; + +import java.util.List; + +@Component +public class ExpertReportMapper { + public ExpertReportDetail toEntity(CreateExpertReportDetailRequest dto) { + return ExpertReportDetail.create( + dto.commentType(), + dto.content() + ); + } + + public List toEntityList(List dtos) { + return dtos.stream() + .map(this::toEntity) + .toList(); + } +} diff --git a/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java b/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java new file mode 100644 index 00000000..9e3f7638 --- /dev/null +++ b/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java @@ -0,0 +1,7 @@ +package starlight.adapter.member.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.member.entity.Credential; + +public interface CredentialRepository extends JpaRepository { +} diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java new file mode 100644 index 00000000..3d5280aa --- /dev/null +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -0,0 +1,22 @@ +package starlight.adapter.member.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.member.required.MemberQuery; +import starlight.domain.member.entity.Member; +import starlight.domain.member.exception.MemberErrorType; +import starlight.domain.member.exception.MemberException; + +@Repository +@RequiredArgsConstructor +public class MemberJpa implements MemberQuery { + + private final MemberRepository memberRepository; + + @Override + public Member getOrThrow(Long id) { + return memberRepository.findById(id).orElseThrow( + () -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND) + ); + } +} diff --git a/src/main/java/starlight/adapter/member/persistence/MemberRepository.java b/src/main/java/starlight/adapter/member/persistence/MemberRepository.java new file mode 100644 index 00000000..3b5d370a --- /dev/null +++ b/src/main/java/starlight/adapter/member/persistence/MemberRepository.java @@ -0,0 +1,13 @@ +package starlight.adapter.member.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.member.entity.Member; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(String email); + + Optional findByProviderAndProviderId(String provider, String providerId); +} diff --git a/src/main/java/starlight/adapter/member/webapi/MemberController.java b/src/main/java/starlight/adapter/member/webapi/MemberController.java new file mode 100644 index 00000000..f5e8b042 --- /dev/null +++ b/src/main/java/starlight/adapter/member/webapi/MemberController.java @@ -0,0 +1,29 @@ +package starlight.adapter.member.webapi; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.member.webapi.dto.MemberDetailResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +@Slf4j +@RestController +@RequiredArgsConstructor +@Tag(name = "사용자", description = "사용자 관련 API") +@RequestMapping("/v1/members") +public class MemberController { + + @GetMapping + @Operation(summary = "멤버 정보를 조회합니다.") + public ApiResponse getMemberDetail( + @AuthenticationPrincipal AuthDetails authDetails + ) { + return ApiResponse.success(MemberDetailResponse.from(authDetails.getUser())); + } +} diff --git a/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java b/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java new file mode 100644 index 00000000..1d123553 --- /dev/null +++ b/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java @@ -0,0 +1,28 @@ +package starlight.adapter.member.webapi.dto; + +import starlight.domain.member.entity.Member; + +public record MemberDetailResponse ( + Long id, + + String name, + + String email, + + String phoneNumber, + + String provider, + + String profileImageUrl +){ + public static MemberDetailResponse from(Member member) { + return new MemberDetailResponse( + member.getId(), + member.getName(), + member.getEmail(), + member.getPhoneNumber(), + member.getProvider(), + member.getProfileImageUrl() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java b/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java new file mode 100644 index 00000000..07e35068 --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java @@ -0,0 +1,31 @@ +package starlight.adapter.ncp.clova.infra; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import starlight.shared.dto.infrastructure.ClovaStudioResponse; +import starlight.adapter.ncp.clova.util.ClovaUtil; + +import java.util.Map; + +@Component +public class ClovaStudioClient { + + private final RestClient restClient; + + public ClovaStudioClient(@Qualifier("clovaClient") RestClient restClient) { + this.restClient = restClient; + } + + public ClovaStudioResponse check(String systemMsg, String userMsg, int criteriaSize) { + Map body = ClovaUtil.buildClovaRequestBody(systemMsg, userMsg, criteriaSize); + + return restClient.post() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(ClovaStudioResponse.class); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java b/src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java new file mode 100644 index 00000000..3513f6a6 --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java @@ -0,0 +1,80 @@ +package starlight.adapter.ncp.clova.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ClovaUtil { + + public static Map buildClovaRequestBody(String systemMsg, String userMsg, int n){ + Map props = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (int i = 1; i <= n; i++) { + String key = "c" + i; + props.put(key, Map.of("type", "boolean")); + required.add(key); + } + + Map body = new LinkedHashMap<>(); + body.put("messages", List.of( + Map.of("role", "system", "content", systemMsg), + Map.of("role", "user", "content", userMsg) + )); + body.put("thinking", Map.of("effort", "none")); + body.put("responseFormat", Map.of( + "type", "json", + "schema", Map.of( + "type", "array", + "items", Map.of("type", "boolean"), + "minItems", n, + "maxItems", n + ) + )); + // 필요 시 파라미터 사용 + // body.put("temperature", 0.0); + // body.put("topP", 0.9); + // body.put("topK", 0); + // body.put("repetitionPenalty", 1.1); + // body.put("maxCompletionTokens", Math.max(64, n * 8)); // maxTokens 금지 + + return body; + } + + public static String buildUserContent(String input, List criteria) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("[CHECKLIST]\n"); + + for (int i = 0; i < criteria.size(); i++) { + stringBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); + } + stringBuilder.append("\n[INPUT]\n").append(input); + stringBuilder.append("\n\n[REQUEST]\n").append("위의 CHECKLIST 항목에 대해 각각 TRUE 또는 FALSE로 답변해 주세요. 답변은 JSON 배열 형식으로 제공해 주세요."); + return stringBuilder.toString(); + } + + public static List toBooleanList(String contentJson, int n) { + ObjectMapper mapper = new ObjectMapper(); + JsonNode root; + try { + root = mapper.readTree(contentJson); + } catch (Exception e) { + throw new IllegalStateException("Invalid JSON from Clova: " + contentJson, e); + } + + List checks = new ArrayList<>(); + for (int i = 0; i < n; i++) { + JsonNode node = root.get(i); + if (node == null || !node.isBoolean()) { + throw new IllegalStateException("Non-boolean value at index " + i); + } + checks.add(node.asBoolean()); + } + + return checks; + } +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java b/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java new file mode 100644 index 00000000..0711e00b --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java @@ -0,0 +1,69 @@ +package starlight.adapter.ncp.ocr; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import starlight.adapter.ncp.ocr.exception.OcrException; +import starlight.adapter.ncp.ocr.infra.ClovaOcrClient; +import starlight.adapter.ncp.ocr.infra.PdfDownloadClient; +import starlight.adapter.ncp.ocr.util.OcrResponseMerger; +import starlight.adapter.ncp.ocr.util.OcrTextExtractor; +import starlight.adapter.ncp.ocr.util.PdfUtils; +import starlight.application.infrastructure.provided.OcrProvider; +import starlight.shared.dto.infrastructure.OcrResponse; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClovaOcrProvider implements OcrProvider { + + private static final int MAX_PAGES_PER_REQUEST = 10; + + private final ClovaOcrClient clovaOcrClient; + private final PdfDownloadClient pdfDownloadClient; + + /** + * 지정한 PDF URL을 전체 페이지 OCR 처리한 뒤, 단일 응답으로 병합해 반환한다. + * 1) PDF 다운로드 → 2) 10페이지씩 분할 → 3) 각 조각별 OCR 호출 → 4) 전체 응답 병합 + * + * @param pdfUrl 접근 가능한 원격 PDF의 절대 URL + * @return 병합된 CLOVA OCR 응답 + * @throws OcrException 다음 에러 타입으로 래핑되어 발생할 수 있음 + * - {@code PDF_DOWNLOAD_ERROR} : 네트워크/HTTP 오류 등으로 PDF 다운로드 실패 + * - {@code PDF_EMPTY_RESPONSE} : 응답 본문이 비어 있음 + * - {@code PDF_TOO_LARGE} : 허용된 최대 크기를 초과 + * - {@code PDF_SPLIT_ERROR} : PDF 분할 실패 + * - {@code OCR_CLIENT_ERROR} : OCR 서버에서 4xx/5xx 등 오류 응답 + */ + @Override + public OcrResponse ocrPdfByUrl(String pdfUrl) { + byte[] pdfBytes = pdfDownloadClient.downloadPdfFromUrl(pdfUrl); + + List chunks = PdfUtils.splitByPageLimit(pdfBytes, MAX_PAGES_PER_REQUEST); + + List parts = new ArrayList<>(); + for (byte[] chunk : chunks) { + parts.add(clovaOcrClient.recognizePdfBytes(chunk)); + } + + return OcrResponseMerger.merge(parts); + } + + /** + * 한 번에 텍스트 완성본까지 반환하는 편의 메서드. + * - 위의 ocrPdfByUrl로 병합 응답을 만든 뒤, + * - OcrTextExtractor로 토큰을 정제/라인브레이크 반영하여 평문 텍스트를 생성. + * + * @param pdfUrl 원격 PDF URL + * @return 페이지 구분선(“-----”)이 포함된 최종 텍스트 + */ + @Override + public String ocrPdfTextByUrl(String pdfUrl) { + OcrResponse ocrResponse = ocrPdfByUrl(pdfUrl); + + return OcrTextExtractor.toPlainText(ocrResponse); + } +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java b/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java new file mode 100644 index 00000000..251a019f --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java @@ -0,0 +1,43 @@ +package starlight.adapter.ncp.ocr.dto; + + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +public record ClovaOcrRequest( + String version, + String requestId, + long timestamp, + String lang, + List images +) { + public static ClovaOcrRequest create(String version, String lang, List images) { + return new ClovaOcrRequest( + version, + UUID.randomUUID().toString(), + System.currentTimeMillis(), + lang, + images + ); + } + + public static ClovaOcrRequest createPdfByBytes(String version, byte[] pdfBytes) { + String b64 = Base64.getEncoder().encodeToString(pdfBytes); + return create(version, "ko", List.of(Image.ofData("pdf", "input", b64))); + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Image( + String format, + String name, + String url, + String data + ) { + public static Image ofData(String format, String name, String base64) { + return new Image(format, name, null, base64); + } + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java new file mode 100644 index 00000000..263e7180 --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java @@ -0,0 +1,22 @@ +package starlight.adapter.ncp.ocr.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum OcrErrorType implements ErrorType { + PDF_SPLIT_ERROR(HttpStatus.BAD_REQUEST, "PDF 분할 실패"), + PDF_DOWNLOAD_ERROR(HttpStatus.BAD_GATEWAY, "PDF 다운로드 실패"), + OCR_CLIENT_ERROR(HttpStatus.BAD_GATEWAY, "CLOVA OCR 호출 실패"), + PDF_EMPTY_RESPONSE(HttpStatus.BAD_GATEWAY, "PDF 응답이 비어있음"), + PDF_TOO_LARGE(HttpStatus.INTERNAL_SERVER_ERROR, "PDF의 크기가 업로드 제한 크기를 넘습니다."), + PAGE_COUNT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "페이지 수를 가져오는 데 실패했습니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java new file mode 100644 index 00000000..31128d49 --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java @@ -0,0 +1,10 @@ +package starlight.adapter.ncp.ocr.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class OcrException extends GlobalException { + public OcrException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java b/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java new file mode 100644 index 00000000..9427738b --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java @@ -0,0 +1,42 @@ +package starlight.adapter.ncp.ocr.infra; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import starlight.adapter.ncp.ocr.dto.ClovaOcrRequest; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; +import starlight.shared.dto.infrastructure.OcrResponse; + +@Slf4j +@Component +public class ClovaOcrClient { + + private final RestClient clovaOcrRestClient; + + public ClovaOcrClient(@Qualifier("clovaOcrRestClient") RestClient restClient) { + this.clovaOcrRestClient = restClient; + } + + public OcrResponse recognizePdfBytes(byte[] pdfBytes) { + ClovaOcrRequest request = ClovaOcrRequest.createPdfByBytes("V2", pdfBytes); + + try { + OcrResponse resp = clovaOcrRestClient.post() + .body(request) + .retrieve() + .body(OcrResponse.class); + + if (resp == null) { + log.warn("CLOVA OCR 응답이 null 입니다."); + throw new OcrException(OcrErrorType.OCR_CLIENT_ERROR); + } + + return resp; + } catch (Exception e) { + log.warn("CLOVA OCR 호출 실패", e); + throw new OcrException(OcrErrorType.OCR_CLIENT_ERROR); + } + } +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java b/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java new file mode 100644 index 00000000..188532bb --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java @@ -0,0 +1,61 @@ +package starlight.adapter.ncp.ocr.infra; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; + +import java.net.URI; + +@Slf4j +@Component +public class PdfDownloadClient { + + private static final int MAX_PDF_BYTES = 30 * 1024 * 1024; // 30MB까지 허용 + + private final RestClient pdfDownloadClient; + + public PdfDownloadClient(@Qualifier("downloadClient") RestClient downloadClient) { + this.pdfDownloadClient = downloadClient; + } + + /** + * 원격 PDF를 Spring RestClient로 다운로드하여 바이트 배열로 반환한다. + * - URI를 그대로 사용해(이중 인코딩 방지) GET 요청 수행 + * - 2xx 가 아니면 내부적으로 예외 발생(RestClient의 기본 동작) + * - 응답 바이트 검증: 비어있으면 PDF_EMPTY_RESPONSE, 최대 크기 초과면 PDF_TOO_LARGE + * - 그 외 네트워크/타임아웃/HTTP 예외는 PDF_DOWNLOAD_ERROR로 래핑 + * + * @param url 다운로드할 PDF의 절대 URL(프리사인드/퍼센트 인코딩 포함 가능) + * @return 다운로드한 PDF 바이트 배열 + * @throws OcrException 다음의 에러타입으로 발생 + * - PDF_EMPTY_RESPONSE : 본문이 비어있음 + * - PDF_TOO_LARGE : 허용 최대 크기 초과 + * - PDF_DOWNLOAD_ERROR : 네트워크/HTTP/기타 예외 전반 + */ + public byte[] downloadPdfFromUrl(String url) { + try { + ResponseEntity entity = pdfDownloadClient.get() + .uri(URI.create(url)) + .retrieve() + .toEntity(byte[].class); + + byte[] data = entity.getBody(); + if (data == null || data.length == 0) { + throw new OcrException(OcrErrorType.PDF_EMPTY_RESPONSE); + } + if (data.length > MAX_PDF_BYTES) { + throw new OcrException(OcrErrorType.PDF_TOO_LARGE); + } + return data; + } catch (OcrException e) { + throw e; // 이미 처리된 OcrException은 재던짐 + } catch (Exception e) { + log.error("PDF 다운로드 실패: {}", e.getMessage()); + throw new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR); + } + } +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java b/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java new file mode 100644 index 00000000..4c2f21cb --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java @@ -0,0 +1,28 @@ +package starlight.adapter.ncp.ocr.util; + +import starlight.shared.dto.infrastructure.OcrResponse; + +import java.util.ArrayList; +import java.util.List; + +public final class OcrResponseMerger { + + private OcrResponseMerger() {} + + public static OcrResponse merge(List parts) { + if (parts == null || parts.isEmpty()) { + return OcrResponse.createEmpty(); + } + + OcrResponse first = parts.get(0); + + List images = new ArrayList<>(); + for (OcrResponse resp : parts) { + if (resp.images() != null) { + images.addAll(resp.images()); + } + } + + return OcrResponse.create(first.version(), first.requestId(), images); + } +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java b/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java new file mode 100644 index 00000000..e52f872a --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java @@ -0,0 +1,97 @@ +package starlight.adapter.ncp.ocr.util; + +import starlight.shared.dto.infrastructure.OcrResponse; + +import java.util.ArrayList; +import java.util.List; + +public final class OcrTextExtractor { + + private OcrTextExtractor() {} + + private static final double MINIMUM_CONFIDENCE_THRESHOLD = 0.85; + + /** + * 모든 페이지를 하나의 문자열로 병합. + * 페이지 사이에는 "\n\n-----\n\n" 구분선을 넣는다. + * + * @param response OCR 원본 응답 (널 가능) + * @return 결합된 전체 텍스트 (널 입력이면 빈 문자열) + */ + public static String toPlainText(OcrResponse response) { + List pageTexts = toPages(response); + return String.join("\n\n-----\n\n", pageTexts); + } + + /** + * 페이지(= images 배열의 각 요소)별 텍스트를 리스트로 반환. + * + * @param ocrResponse OCR 원본 응답 + * @return 각 페이지의 문자열 (images가 비었거나 널이면 빈 리스트) + */ + public static List toPages(OcrResponse ocrResponse) { + List pages = new ArrayList<>(); + if (ocrResponse == null || ocrResponse.images() == null) { + return pages; + } + + for (OcrResponse.ImageResult page : ocrResponse.images()) { + if (page == null || page.fields() == null || page.fields().isEmpty()) { + pages.add(""); + continue; + } + + StringBuilder pageBuilder = new StringBuilder(); + boolean atLineStart = true; + + for (OcrResponse.ImageResult.Field fieldItem : page.fields()) { + if (fieldItem == null) { + continue; + } + + Double confidence = fieldItem.inferConfidence(); + if (confidence == null || confidence < MINIMUM_CONFIDENCE_THRESHOLD) { + continue; + } + + String normalizedToken = normalize(fieldItem.inferText()); + if (normalizedToken.isEmpty()) { + continue; + } + + if (!atLineStart) { + pageBuilder.append(' '); + } + + pageBuilder.append(normalizedToken); + if (Boolean.TRUE.equals(fieldItem.lineBreak())) { + pageBuilder.append('\n'); + atLineStart = true; + } else { + atLineStart = false; + } + } + + pages.add(pageBuilder.toString().strip()); + } + + return pages; + } + + /** + * 토큰 정규화: + * - null → "" (스킵되도록) + * - 앞뒤 공백 제거 + * - 연속 공백 1칸으로 축약 + * - 구두점 앞의 공백 제거, 괄호 주변 공백 정리 + */ + private static String normalize(String raw) { + if (raw == null) return ""; + String out = raw.strip() + .replaceAll("\\s+", " "); + out = out.replaceAll("\\s+([,.:;!?])", "$1") + .replaceAll("\\(\\s+", "(") + .replaceAll("\\s+\\)", ")"); + return out; + } +} diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java b/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java new file mode 100644 index 00000000..c11eab05 --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java @@ -0,0 +1,59 @@ +package starlight.adapter.ncp.ocr.util; + +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.pdmodel.PDDocument; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public final class PdfUtils { + + private PdfUtils() {} + + /** + * PDF를 페이지 단위로 잘라 maxPagesPerChunk 크기의 조각들로 분할하여 반환합니다. + * + * @param sourcePdfBytes 원본 PDF 바이트 + * @param maxPagesPerChunk 조각당 최대 페이지 수(예: 10) + * @return 분할된 PDF 바이트 배열 목록(페이지 순서 유지) + */ + public static List splitByPageLimit(byte[] sourcePdfBytes, int maxPagesPerChunk) { + try (PDDocument sourceDoc = PDDocument.load(new ByteArrayInputStream(sourcePdfBytes))) { + + int totalPages = sourceDoc.getNumberOfPages(); + if (totalPages <= maxPagesPerChunk) { + return List.of(sourcePdfBytes); + } + + List chunks = new ArrayList<>(); + int startPageIndex = 0; + + while (startPageIndex < totalPages) { + int endPageIndexExclusive = Math.min(startPageIndex + maxPagesPerChunk, totalPages); + + // 부분 문서 생성 + try (PDDocument chunkDoc = new PDDocument()) { + for (int pageIndex = startPageIndex; pageIndex < endPageIndexExclusive; pageIndex++) { + chunkDoc.addPage(sourceDoc.getPage(pageIndex)); + } + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + chunkDoc.save(out); + chunks.add(out.toByteArray()); + } + } + + startPageIndex = endPageIndexExclusive; + } + + return chunks; + } catch (Exception e) { + log.error("PDF 분할 실패: {}", e.getMessage()); + throw new OcrException(OcrErrorType.PDF_SPLIT_ERROR); + } + } +} diff --git a/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java new file mode 100644 index 00000000..eca9cb4c --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java @@ -0,0 +1,119 @@ +package starlight.adapter.ncp.storage; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; +import software.amazon.awssdk.services.s3.model.PutObjectAclRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import starlight.application.infrastructure.provided.PresignedUrlProvider; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NcpPresignedUrlProvider implements PresignedUrlProvider { + + private final S3Client ncpS3Client; + private final S3Presigner s3Presigner; + + @Value("${cloud.ncp.object-storage.bucket-name}") + private String bucket; + @Value("${cloud.ncp.object-storage.end-point}") + private String endpoint; + + /** + * 업로드용 Presigned URL 생성 + * - 클라이언트는 추가 헤더 없이 PUT(binary)만 하면 됨 + * + * @param userId 사용자 ID (파일 경로 생성에 사용) + * @param originalFileName 원본 파일명 (확장자 추출에 사용) + * @return Presigned URL 및 최종 공개 URL을 포함한 응답 객체 + */ + @Override + public PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName) { + String safeFileName = encodePathSegment(originalFileName); + String key = String.format("%d/%s", userId, safeFileName); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + + String presignedUrl = presignedRequest.url().toString(); + String objectUrl = buildObjectUrl(key); + + return PreSignedUrlResponse.of(presignedUrl, objectUrl); + } + + /** + * 업로드 후 공개가 필요할 때 서버에서 ACL을 지정 + * + * @param objectUrl objectUrl ACL 변경 대상 오브젝트의 URL + * @return 공개 설정이 완료된 오브젝트 URL + */ + @Override + public String makePublic(String objectUrl) { + String key = extractKeyFromUrl(objectUrl); + + try { + PutObjectAclRequest aclRequest = PutObjectAclRequest.builder() + .bucket(bucket) + .key(key) + .acl(ObjectCannedACL.PUBLIC_READ) + .build(); + ncpS3Client.putObjectAcl(aclRequest); + log.info("객체 공개 처리 완료(PUBLIC_READ): key={}", objectUrl); + } catch (S3Exception e) { + log.error("객체 공개 처리 실패 - Message: {}", e.getMessage()); + throw new RuntimeException("객체 공개 처리 실패: " + e.getMessage(), e); + } + return objectUrl; + } + + /** + * 파일명/경로 segment-safe 인코딩 (공백을 %20으로 보존) + */ + private static String encodePathSegment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + /** + * 가상호스트 형태의 공개 URL 생성 + */ + private String buildObjectUrl(String key) { + String host = endpoint.replaceFirst("^https?://", "").replaceAll("/$", ""); + + return String.format("https://%s.%s/%s", bucket, host, key); + } + + /** + * Object URL에서 key 부분 추출 + */ + private String extractKeyFromUrl(String objectUrl) { + int schemeEnd = objectUrl.indexOf("://"); + if (schemeEnd == -1) throw new IllegalArgumentException("잘못된 URL 형식"); + + int pathStart = objectUrl.indexOf("/", schemeEnd + 3); + if (pathStart == -1) throw new IllegalArgumentException("잘못된 URL 형식 - path가 없습니다"); + + return objectUrl.substring(pathStart + 1); + } +} diff --git a/src/main/java/starlight/adapter/ncp/webapi/ImageController.java b/src/main/java/starlight/adapter/ncp/webapi/ImageController.java new file mode 100644 index 00000000..7ff465b4 --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/webapi/ImageController.java @@ -0,0 +1,32 @@ +package starlight.adapter.ncp.webapi; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; +import starlight.application.infrastructure.provided.PresignedUrlProvider; +import starlight.adapter.ncp.webapi.swagger.ImageApiDoc; +import starlight.shared.apiPayload.response.ApiResponse; + +@RestController +@RequestMapping("/v1/images") +@RequiredArgsConstructor +public class ImageController implements ImageApiDoc { + + private final PresignedUrlProvider presignedUrlReader; + + @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse getPresignedUrl( + @AuthenticationPrincipal AuthDetails authDetails, + @RequestParam String fileName + ) { + return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authDetails.getMemberId(), fileName)); + } + + @PostMapping("/upload-url/public") + public ApiResponse finalizePublic(@RequestParam String objectUrl) { + return ApiResponse.success(presignedUrlReader.makePublic(objectUrl)); + } +} diff --git a/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java b/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java new file mode 100644 index 00000000..8919e99e --- /dev/null +++ b/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java @@ -0,0 +1,80 @@ +package starlight.adapter.ncp.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +@Tag(name = "UTIL", description = "유틸리티 API") +public interface ImageApiDoc { + + @Operation( + summary = "Presigned URL 발급", + description = "S3 Presigned URL을 발급합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공적으로 Presigned URL 반환", + content = @Content( + schema = @Schema(implementation = PreSignedUrlResponse.class), + examples = @ExampleObject( + name = "Presigned URL 예시", + value = """ + { + "result": "SUCCESS", + "data": { + "preSignedUrl": "https://starlight-s3.kr.object.ncloudstorage.com/test/..........", + "objectUrl": "https://starlight-s3.kr.object.ncloudstorage.com/test/78e6919c-0b0f-47bd-96c6-2b3e9b176167-test" + }, + "error": null + } + """ + ) + ) + ) + }) + @GetMapping(value = "/v1/image/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + ApiResponse getPresignedUrl( + @AuthenticationPrincipal AuthDetails authDetails, + @io.swagger.v3.oas.annotations.Parameter(description = "파일명", required = true) @RequestParam String fileName + ); + + @Operation( + summary = "이미지 공개 전환", + description = "업로드된 이미지를 공개 상태로 전환합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공적으로 공개 처리", + content = @Content( + examples = @ExampleObject( + name = "공개 처리 성공", + value = """ + { + "result": "SUCCESS", + "data": "test/000239dc-542e-493a-aceb-6eda786d0eaf-tests", + "error": null + } + """ + ) + ) + ) + }) + @PostMapping("/v1/images/upload-url/public") + ApiResponse finalizePublic( + @io.swagger.v3.oas.annotations.Parameter(description = "S3 Object URL", required = true) @RequestParam String objectUrl + ); +} + diff --git a/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java new file mode 100644 index 00000000..f4cf0f5d --- /dev/null +++ b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java @@ -0,0 +1,40 @@ +package starlight.adapter.order.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.order.provided.OrdersQuery; +import starlight.domain.expertReport.entity.ExpertReport; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.order.Orders; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryJpa implements OrdersQuery { + + private final OrdersRepository repository; + + @Override + public Optional findByOrderCode(String orderCode) { + return repository.findByOrderCode(orderCode); + } + + @Override + public List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(Long buyerId) { + return repository.findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(buyerId); + } + + @Override + public Orders getByOrderCodeOrThrow(String orderCode) { + return repository.findByOrderCode(orderCode) + .orElseThrow(() -> new OrderException(OrderErrorType.ORDER_NOT_FOUND)); + } + + @Override + public Orders save(Orders order) { + return repository.save(order); + } +} diff --git a/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java b/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java new file mode 100644 index 00000000..b6ea0b27 --- /dev/null +++ b/src/main/java/starlight/adapter/order/persistence/OrdersRepository.java @@ -0,0 +1,24 @@ +package starlight.adapter.order.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import starlight.domain.order.order.Orders; + +import java.util.List; +import java.util.Optional; + +public interface OrdersRepository extends JpaRepository { + + Optional findByOrderCode(String orderCode); + + @Query(""" + select distinct o + from Orders o + left join fetch o.payments p + where o.buyerId = :buyerId + order by o.createdAt desc + """) + List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(@Param("buyerId") Long buyerId); + +} diff --git a/src/main/java/starlight/adapter/order/toss/TossClient.java b/src/main/java/starlight/adapter/order/toss/TossClient.java new file mode 100644 index 00000000..ee8640ea --- /dev/null +++ b/src/main/java/starlight/adapter/order/toss/TossClient.java @@ -0,0 +1,120 @@ +package starlight.adapter.order.toss; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +import java.util.Map; +import java.util.Objects; + +@Slf4j +@Component +public class TossClient { + + private final RestClient restClient; + + public TossClient(@Qualifier("tossRestClient") RestClient restClient) { + this.restClient = restClient; + } + + public TossClientResponse.Confirm confirm(String orderCode, String paymentKey, Long price) { + Map body = Map.of( + "paymentKey", paymentKey, + "orderId", orderCode, + "amount", price + ); + try { + TossClientResponse.Confirm response = restClient.post() + .uri("/v1/payments/confirm") + .header("Idempotency-Key", orderCode) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(TossClientResponse.Confirm.class); + + // 응답 검증 + validateConfirmResponse(response, orderCode, price); + + return response; + + } catch (Exception e) { + log.error("토스 결제 승인 요청 중 에러 발생. orderId: {}, paymentKey: {}, message: {}", orderCode, paymentKey, e.getMessage(), e); + throw new OrderException(OrderErrorType.TOSS_CLIENT_CONFIRM_ERROR); + } + } + + public TossClientResponse.Cancel cancel(String paymentKey, String reason) { + Map body = Map.of( + "cancelReason", reason != null ? reason : "user_request" + ); + try { + TossClientResponse.Cancel response = restClient.post() + .uri("/v1/payments/{paymentKey}/cancel", paymentKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(TossClientResponse.Cancel.class); + + // 응답 검증 + validateCancelResponse(response); + + return response; + + } catch (Exception e) { + log.error("토스 환불 요청 중 에러발생: {}", e.getMessage(), e); + log.error("paymentKey: {}, reason: {}", paymentKey, reason); + throw new OrderException(OrderErrorType.TOSS_CLIENT_CONFIRM_ERROR); + } + + } + + /** + * confirm 응답 검증 + */ + private void validateConfirmResponse(TossClientResponse.Confirm response, String expectedOrderId, Long expectedAmount) { + if (response == null) { + throw new IllegalStateException("PG 응답이 null입니다."); + } + + if (!Objects.equals(response.orderId(), expectedOrderId)) { + throw new IllegalStateException( + String.format("PG 응답의 주문번호가 일치하지 않습니다. 예상: %s, 실제: %s", + expectedOrderId, response.orderId()) + ); + } + + if (!Objects.equals(response.totalAmount(), expectedAmount)) { + throw new IllegalStateException( + String.format("PG 응답 금액이 주문 금액과 일치하지 않습니다. 예상: %d, 실제: %d", + expectedAmount, response.totalAmount()) + ); + } + + if (!"DONE".equals(response.status())) { + throw new IllegalStateException( + "PG 응답 상태가 완료(DONE)가 아닙니다. status=" + response.status() + ); + } + } + + /** + * cancel 응답 검증 + */ + private void validateCancelResponse(TossClientResponse.Cancel response) { + if (response == null) { + throw new IllegalStateException("PG 취소 응답이 null입니다."); + } + + // 취소 응답은 status가 CANCELED여야 함 + if (response.status() != null && !"CANCELED".equals(response.status())) { + throw new IllegalStateException( + "PG 취소가 완료 상태(CANCELED)가 아닙니다. status=" + response.status() + ); + } + } +} diff --git a/src/main/java/starlight/adapter/order/webapi/OrderController.java b/src/main/java/starlight/adapter/order/webapi/OrderController.java new file mode 100644 index 00000000..46829fee --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/OrderController.java @@ -0,0 +1,102 @@ +package starlight.adapter.order.webapi; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.adapter.order.webapi.dto.request.OrderCancelRequest; +import starlight.adapter.order.webapi.dto.request.OrderConfirmRequest; +import starlight.adapter.order.webapi.dto.request.OrderPrepareRequest; +import starlight.adapter.order.webapi.dto.response.OrderCancelResponse; +import starlight.adapter.order.webapi.dto.response.OrderConfirmResponse; +import starlight.adapter.order.webapi.dto.response.OrderPrepareResponse; +import starlight.adapter.order.webapi.dto.response.WalletCheckResponse; +import starlight.application.order.provided.OrderPaymentService; +import starlight.application.order.provided.dto.PaymentHistoryItemDto; +import starlight.application.usage.provided.UsageCreditPort; +import starlight.domain.order.order.Orders; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Tag(name = "결제", description = "결제 관련 API") +@RequestMapping("/v1/orders") +public class OrderController { + + private final OrderPaymentService orderPaymentService; + private final UsageCreditPort usageCreditPort; + + /** + * 결제 준비 (주문 생성) + * POST /api/toss/request + */ + @PostMapping("/request") + public ApiResponse prepareOrder( + @Valid @RequestBody OrderPrepareRequest request, + @AuthenticationPrincipal AuthDetails authDetails + ) { + Orders order = orderPaymentService.prepare( + request.orderCode(), + authDetails.getMemberId(), + request.productCode() + ); + + OrderPrepareResponse response = OrderPrepareResponse.from(order); + + return ApiResponse.success(response); + } + + /** + * 결제 승인 + * POST /api/toss/confirm + */ + @PostMapping("/confirm") + public ApiResponse confirmPayment( + @Valid @RequestBody OrderConfirmRequest request, + @AuthenticationPrincipal AuthDetails authDetails + ) { + Orders order = orderPaymentService.confirm( + request.orderCode(), + request.paymentKey(), + authDetails.getMemberId() + ); + + OrderConfirmResponse response = OrderConfirmResponse.from(order); + + return ApiResponse.success(response); + } + + /** + * 결제 취소 + * POST /api/toss/cancel + */ + @PostMapping("/cancel") + public ApiResponse cancelPayment( + @Valid @RequestBody OrderCancelRequest request + ) { + TossClientResponse.Cancel tossResponse = orderPaymentService.cancel(request); + + OrderCancelResponse response = OrderCancelResponse.from(tossResponse); + + return ApiResponse.success(response); + } + + /** + * 나의 결제 내역 조회 + * GET /api/orders + */ + @GetMapping + public ApiResponse> getMyPayments( + @AuthenticationPrincipal AuthDetails authDetails + ) { + Long memberId = authDetails.getMemberId(); + List history = orderPaymentService.getPaymentHistory(memberId); + + return ApiResponse.success(history); + } +} diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java new file mode 100644 index 00000000..7aa386f3 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderCancelRequest.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record OrderCancelRequest( + @NotBlank(message = "orderCode는 필수입니다.") + String orderCode, + + @NotBlank(message = "취소 사유는 필수입니다.") + String reason +) { } diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java new file mode 100644 index 00000000..9e246bc0 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderConfirmRequest.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record OrderConfirmRequest( + @NotBlank + String paymentKey, + + @NotBlank + String orderCode +) { } diff --git a/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java new file mode 100644 index 00000000..60db489f --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/request/OrderPrepareRequest.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record OrderPrepareRequest( + @NotBlank + String orderCode, + + @NotBlank + String productCode +) { } diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java new file mode 100644 index 00000000..6ffc25cd --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java @@ -0,0 +1,19 @@ +package starlight.adapter.order.webapi.dto.response; + +import starlight.application.order.provided.dto.TossClientResponse; + +public record OrderCancelResponse( + String orderId, + String paymentKey, + String status, + Integer totalAmount +) { + public static OrderCancelResponse from(TossClientResponse.Cancel tossResponse) { + return new OrderCancelResponse( + tossResponse.orderId(), + tossResponse.paymentKey(), + tossResponse.status(), + tossResponse.totalAmount() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java new file mode 100644 index 00000000..06c87607 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderConfirmResponse.java @@ -0,0 +1,34 @@ +package starlight.adapter.order.webapi.dto.response; + +import starlight.domain.order.order.Orders; +import starlight.domain.order.order.PaymentRecords; + +import java.time.Instant; + +public record OrderConfirmResponse( + Long buyerId, + String paymentKey, + String orderId, + Long amount, + String status, + Instant approvedAt, + String receiptUrl, + String method, + String provider +) { + public static OrderConfirmResponse from(Orders order) { + PaymentRecords done = order.getLatestPaymentOrThrow(); + + return new OrderConfirmResponse( + order.getBuyerId(), + done.getPaymentKey(), + order.getOrderCode(), + order.getPrice(), + order.getStatus().name(), + done.getApprovedAt(), + done.getReceiptUrl(), + done.getMethod(), + done.getProvider() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java new file mode 100644 index 00000000..daa67302 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderPrepareResponse.java @@ -0,0 +1,17 @@ +package starlight.adapter.order.webapi.dto.response; + +import starlight.domain.order.order.Orders; + +public record OrderPrepareResponse( + String orderCode, + Long amount, + String status +) { + public static OrderPrepareResponse from(Orders order) { + return new OrderPrepareResponse( + order.getOrderCode(), + order.getPrice(), + order.getStatus().name() + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java b/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java new file mode 100644 index 00000000..325321bf --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/WalletCheckResponse.java @@ -0,0 +1,11 @@ +package starlight.adapter.order.webapi.dto.response; + +public record WalletCheckResponse( + Boolean hasCredit +) { + public static WalletCheckResponse of(Boolean hasCredit) { + return new WalletCheckResponse( + hasCredit + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java new file mode 100644 index 00000000..e055482b --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepository.java @@ -0,0 +1,7 @@ +package starlight.adapter.usage.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.order.wallet.UsageHistory; + +public interface UsageHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java new file mode 100644 index 00000000..803689b9 --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java @@ -0,0 +1,18 @@ +package starlight.adapter.usage.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.usage.provided.UsageHistoryQuery; +import starlight.domain.order.wallet.UsageHistory; + +@Repository +@RequiredArgsConstructor +public class UsageHistoryRepositoryJpa implements UsageHistoryQuery { + + private final UsageHistoryRepository repository; + + @Override + public UsageHistory save(UsageHistory usageHistory){ + return repository.save(usageHistory); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java new file mode 100644 index 00000000..5f1352a7 --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepository.java @@ -0,0 +1,11 @@ +package starlight.adapter.usage.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.order.wallet.UsageWallet; + +import java.util.Optional; + +public interface UsageWalletRepository extends JpaRepository { + + Optional findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java new file mode 100644 index 00000000..c8bdf4c6 --- /dev/null +++ b/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java @@ -0,0 +1,25 @@ +package starlight.adapter.usage.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.usage.provided.UsageWalletQuery; +import starlight.domain.order.wallet.UsageWallet; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UsageWalletRepositoryJpa implements UsageWalletQuery { + + private final UsageWalletRepository repository; + + @Override + public Optional findByUserId(Long userId){ + return repository.findByUserId(userId); + } + + @Override + public UsageWallet save(UsageWallet usageWallet){ + return repository.save(usageWallet); + } +} diff --git a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java new file mode 100644 index 00000000..0db896a8 --- /dev/null +++ b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java @@ -0,0 +1,129 @@ +package starlight.application.aireport; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.adapter.ai.util.AiReportResponseParser; +import starlight.application.aireport.provided.AiReportService; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.application.aireport.required.AiReportGrader; +import starlight.application.aireport.required.AiReportQuery; +import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.infrastructure.provided.OcrProvider; +import starlight.domain.aireport.entity.AiReport; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AiReportServiceImpl implements AiReportService { + + private final BusinessPlanQuery businessPlanQuery; + private final BusinessPlanService businessPlanService; + private final AiReportQuery aiReportQuery; + private final AiReportGrader aiReportGrader; + private final ObjectMapper objectMapper; + private final OcrProvider ocrProvider; + private final AiReportResponseParser responseParser; + private final BusinessPlanContentExtractor contentExtractor; + + @Override + public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { + + BusinessPlan plan = businessPlanQuery.getOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); + checkBusinessPlanWritingCompleted(plan); + + AiReportResponse gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan)); + + String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + + AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); + + return responseParser.toResponse(aiReportQuery.save(aiReport)); + } + + @Override + public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { + + BusinessPlanResponse.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf( + title, + pdfUrl, + memberId + ); + Long businessPlanId = businessPlanResult.businessPlanId(); + BusinessPlan plan = businessPlanQuery.getOrThrow(businessPlanId); + + String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); + + AiReportResponse gradingResult = aiReportGrader.gradeContent(pdfText); + + String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + + AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); + + return responseParser.toResponse(aiReportQuery.save(aiReport)); + } + + @Override + @Transactional(readOnly = true) + public AiReportResponse getAiReport(Long planId, Long memberId) { + BusinessPlan plan = businessPlanQuery.getOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); + + AiReport aiReport = aiReportQuery.findByBusinessPlanId(planId) + .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); + + return responseParser.toResponse(aiReport); + } + + private String getRawJsonAiReportResponseFromGradingResult(AiReportResponse gradingResult) { + JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); + String rawJsonString; + try { + rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to convert JsonNode to string", e); + } + return rawJsonString; + } + + private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) { + Optional existingReport = aiReportQuery.findByBusinessPlanId(plan.getId()); + + AiReport aiReport; + if (existingReport.isPresent()) { + aiReport = existingReport.get(); + aiReport.update(rawJsonString); + } else { + aiReport = AiReport.create(plan.getId(), rawJsonString); + } + plan.updateStatus(PlanStatus.AI_REVIEWED); + businessPlanQuery.save(plan); + + return aiReport; + } + + private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) { + if (!plan.isOwnedBy(memberId)) { + throw new AiReportException(AiReportErrorType.UNAUTHORIZED_ACCESS); + } + } + + private void checkBusinessPlanWritingCompleted(BusinessPlan plan) { + if (!plan.areWritingCompleted()) { + throw new AiReportException(AiReportErrorType.NOT_READY_FOR_AI_REPORT); + } + } +} diff --git a/src/main/java/starlight/application/aireport/provided/AiReportService.java b/src/main/java/starlight/application/aireport/provided/AiReportService.java new file mode 100644 index 00000000..6c618fd6 --- /dev/null +++ b/src/main/java/starlight/application/aireport/provided/AiReportService.java @@ -0,0 +1,11 @@ +package starlight.application.aireport.provided; + +import starlight.application.aireport.provided.dto.AiReportResponse; + +public interface AiReportService { + AiReportResponse gradeBusinessPlan(Long businessPlanId, Long memberId); + + AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId); + + AiReportResponse getAiReport(Long businessPlanId, Long memberId); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java new file mode 100644 index 00000000..93ec0c04 --- /dev/null +++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java @@ -0,0 +1,67 @@ +package starlight.application.aireport.provided.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import java.util.List; + +/** + * AI 리포트 응답 DTO + * LLM 채점 결과와 API 응답을 모두 담는 통합 DTO + */ +public record AiReportResponse( + Long id, // null 가능 (LLM 결과 파싱 시에는 null) + Long businessPlanId, // null 가능 (LLM 결과 파싱 시에는 null) + Integer totalScore, + Integer problemRecognitionScore, + Integer feasibilityScore, + Integer growthStrategyScore, + Integer teamCompetenceScore, + List sectionScores, + List strengths, + List weaknesses +) { + public record SectionScoreDetailResponse( + String sectionType, + @JsonRawValue String gradingListScores + ) {} + + public record StrengthWeakness( + String title, + String content + ) {} + + /** + * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null) + */ + public static AiReportResponse fromGradingResult( + Integer problemRecognitionScore, + Integer feasibilityScore, + Integer growthStrategyScore, + Integer teamCompetenceScore, + List sectionScores, + List strengths, + List weaknesses + ) { + Integer totalScore = sumTotalScore(problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore); + + return new AiReportResponse( + null, + null, + totalScore, + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses + ); + } + + private static Integer sumTotalScore(Integer problemRecognitionScore, Integer feasibilityScore, Integer growthStrategyScore, Integer teamCompetenceScore) { + return (problemRecognitionScore != null ? problemRecognitionScore : 0) + + (feasibilityScore != null ? feasibilityScore : 0) + + (growthStrategyScore != null ? growthStrategyScore : 0) + + (teamCompetenceScore != null ? teamCompetenceScore : 0); + } +} + diff --git a/src/main/java/starlight/application/aireport/required/AiReportGrader.java b/src/main/java/starlight/application/aireport/required/AiReportGrader.java new file mode 100644 index 00000000..0ba2d255 --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/AiReportGrader.java @@ -0,0 +1,7 @@ +package starlight.application.aireport.required; + +import starlight.application.aireport.provided.dto.AiReportResponse; + +public interface AiReportGrader { + AiReportResponse gradeContent(String content); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/required/AiReportQuery.java b/src/main/java/starlight/application/aireport/required/AiReportQuery.java new file mode 100644 index 00000000..8e18704a --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/AiReportQuery.java @@ -0,0 +1,11 @@ +package starlight.application.aireport.required; + +import starlight.domain.aireport.entity.AiReport; + +import java.util.Optional; + +public interface AiReportQuery { + AiReport save(AiReport aiReport); + Optional findByBusinessPlanId(Long businessPlanId); +} + diff --git a/src/main/java/starlight/application/auth/AuthServiceImpl.java b/src/main/java/starlight/application/auth/AuthServiceImpl.java new file mode 100644 index 00000000..bc7baa2b --- /dev/null +++ b/src/main/java/starlight/application/auth/AuthServiceImpl.java @@ -0,0 +1,118 @@ +package starlight.application.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.auth.webapi.dto.request.SignInRequest; +import starlight.adapter.auth.webapi.dto.response.MemberResponse; +import starlight.application.auth.provided.AuthService; +import starlight.application.auth.required.KeyValueMap; +import starlight.application.auth.required.TokenProvider; +import starlight.application.member.provided.CredentialService; +import starlight.application.member.provided.MemberService; +import starlight.domain.auth.exception.AuthErrorType; +import starlight.domain.auth.exception.AuthException; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.exception.MemberErrorType; +import starlight.domain.member.exception.MemberException; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private final MemberService memberService; + private final CredentialService credentialService; + private final TokenProvider tokenProvider; + private final KeyValueMap redisClient; + + @Value("${jwt.token.refresh-expiration-time}") + private Long refreshTokenExpirationTime; + + /** + * 회원가입 메서드 + * + * @param authRequest + * @return MemberResponse + */ + @Override + @Transactional + public MemberResponse signUp(AuthRequest authRequest) { + Credential credential = credentialService.createCredential(authRequest); + Member member = memberService.createUser(credential, authRequest); + + return MemberResponse.of(member); + } + + /** + * 로그인 메서드 + * + * @param signInRequest + * @return TokenResponse + */ + @Override + @Transactional + public TokenResponse signIn(SignInRequest signInRequest) { + Member member = memberService.getUserByEmail(signInRequest.email()); + credentialService.checkPassword(member, signInRequest.password()); + + TokenResponse tokenResponse = tokenProvider.createToken(member); + redisClient.setValue(member.getEmail(), tokenResponse.refreshToken(), refreshTokenExpirationTime); + + return tokenResponse; + } + + /** + * 로그아웃 메서드 + * + * @param refreshToken + * @param accessToken + */ + @Override + @Transactional + public void signOut(String refreshToken, String accessToken) { + if(refreshToken==null || accessToken==null) throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND); + if(!tokenProvider.validateToken(accessToken)) { + throw new AuthException(AuthErrorType.TOKEN_INVALID); + } + tokenProvider.invalidateTokens(refreshToken, accessToken); + } + + /** + * 토큰 재발급 메서드 + * + * @param token + * @param member + * @return tokenResponse + */ + @Override + public TokenResponse recreate(String token, Member member) { + if (token ==null) { + throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND); + } + if (member == null) { + throw new MemberException(MemberErrorType.MEMBER_NOT_FOUND); + } + + String refreshToken = token.substring(7); + boolean isValid = tokenProvider.validateToken(refreshToken); + + if (!isValid) { + throw new AuthException(AuthErrorType.TOKEN_INVALID); + } + + String email = tokenProvider.getEmail(refreshToken); + String redisRefreshToken = redisClient.getValue(email); + + if (refreshToken.isEmpty() || redisRefreshToken.isEmpty() || !redisRefreshToken.equals(refreshToken)) { + throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND); + } + + return tokenProvider.recreate(member, refreshToken); + } +} + diff --git a/src/main/java/starlight/application/auth/provided/AuthService.java b/src/main/java/starlight/application/auth/provided/AuthService.java new file mode 100644 index 00000000..1aa8056f --- /dev/null +++ b/src/main/java/starlight/application/auth/provided/AuthService.java @@ -0,0 +1,19 @@ +package starlight.application.auth.provided; + +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.auth.webapi.dto.request.SignInRequest; +import starlight.adapter.auth.webapi.dto.response.MemberResponse; +import starlight.domain.member.entity.Member; + +public interface AuthService { + + MemberResponse signUp(AuthRequest authRequest); + + TokenResponse signIn(SignInRequest signInRequest); + + void signOut(String refreshToken, String accessToken); + + TokenResponse recreate(String token, Member member); +} + diff --git a/src/main/java/starlight/application/auth/required/KeyValueMap.java b/src/main/java/starlight/application/auth/required/KeyValueMap.java new file mode 100644 index 00000000..4dc1b121 --- /dev/null +++ b/src/main/java/starlight/application/auth/required/KeyValueMap.java @@ -0,0 +1,13 @@ +package starlight.application.auth.required; + +public interface KeyValueMap { + + void setValue(String key, String value, Long timeout); + + String getValue(String key); + + void deleteValue(String key); + + boolean checkExistsValue(String key); +} + diff --git a/src/main/java/starlight/application/auth/required/TokenProvider.java b/src/main/java/starlight/application/auth/required/TokenProvider.java new file mode 100644 index 00000000..e84b5daf --- /dev/null +++ b/src/main/java/starlight/application/auth/required/TokenProvider.java @@ -0,0 +1,26 @@ +package starlight.application.auth.required; + +import jakarta.servlet.http.HttpServletRequest; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.domain.member.entity.Member; + +public interface TokenProvider { + + String createAccessToken(Member member); + + TokenResponse createToken(Member member); + + TokenResponse recreate(Member member, String refreshToken); + + boolean validateToken(String token); + + String getEmail(String token); + + Long getExpirationTime(String token); + + String resolveRefreshToken(HttpServletRequest request); + + String resolveAccessToken(HttpServletRequest request); + + void invalidateTokens(String refreshToken, String accessToken); +} diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java new file mode 100644 index 00000000..ba5247fa --- /dev/null +++ b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java @@ -0,0 +1,253 @@ +package starlight.application.businessplan; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; +import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.businessplan.util.PlainTextExtractUtils; +import starlight.application.businessplan.util.SubSectionSupportUtils; +import starlight.application.member.required.MemberQuery; +import starlight.domain.businessplan.entity.*; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.domain.member.entity.Member; +import starlight.shared.enumerate.SectionType; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.domain.businessplan.exception.BusinessPlanErrorType; +import starlight.domain.businessplan.exception.BusinessPlanException; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Transactional +public class BusinessPlanServiceImpl implements BusinessPlanService { + + private final BusinessPlanQuery businessPlanQuery; + private final MemberQuery memberQuery; + private final ChecklistGrader checklistGrader; + private final ObjectMapper objectMapper; + + @Override + public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { + Member member = memberQuery.getOrThrow(memberId); + + String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; + + BusinessPlan plan = BusinessPlan.create(planTitle, memberId); + + return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "Business plan created"); + } + + @Override + public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf( + title, + memberId, + pdfUrl + ); + + return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "PDF Business plan created"); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + return BusinessPlanResponse.Result.from(plan, "Business plan retrieved"); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + List subSectionDetailList = Arrays.stream(SubSectionType.values()) + .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type)) + .filter(Objects::nonNull) + .map(SubSectionResponse.Detail::from) + .toList(); + + return BusinessPlanResponse.Detail.from(plan, subSectionDetailList); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { + Page page = businessPlanQuery.findPreviewPage(memberId, pageable); + List content = page.getContent().stream() + .map(BusinessPlanResponse.Preview::from) + .toList(); + + return BusinessPlanResponse.PreviewPage.from(content, page); + } + + @Override + public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + plan.updateTitle(title); + + businessPlanQuery.save(plan); + + return plan.getTitle(); + } + + @Override + public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + BusinessPlanResponse.Result result = BusinessPlanResponse.Result.from(plan, "Business plan deleted"); + businessPlanQuery.delete(plan); + + return result; + } + + @Override + public SubSectionResponse.Result upsertSubSection( + Long planId, + JsonNode jsonNode, + List checks, + SubSectionType subSectionType, + Long memberId + ) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + BaseSection section = getSectionByPlanAndType(plan, sectionType); + SubSection subSection = section.getSubSectionByType(subSectionType); + + String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); + String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); + + String message; + + if (subSection == null) { + SubSection newSubSection = SubSection.create(subSectionType, content, rawJsonStr, checks); + section.putSubSection(newSubSection); + message = "Subsection created"; + } else { + subSection.update(content, rawJsonStr, checks); + message = "Subsection updated"; + } + + if (plan.areWritingCompleted()) { + plan.updateStatus(PlanStatus.WRITTEN_COMPLETED); + message = "Subsection writing completed"; + } + + BusinessPlan savedPlan = businessPlanQuery.save(plan); + SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType) + .getSubSectionByType(subSectionType); + + return SubSectionResponse.Result.from(persistedSubSection, message); + } + + @Override + @Transactional(readOnly = true) + public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType); + if (subSection == null) { + throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); + } + + return SubSectionResponse.Detail.from(subSection); + } + + @Override + public List checkAndUpdateSubSection( + Long planId, + JsonNode jsonNode, + SubSectionType subSectionType, + Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType); + if (subSection == null) { + throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); + } + + String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); + + List checks = checklistGrader.check(subSectionType, content); + + SubSectionSupportUtils.requireSize(checks, SubSection.getCHECKLIST_SIZE()); + String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); + + subSection.update(content, rawJsonStr, checks); + + businessPlanQuery.save(plan); + + return checks; + } + + @Override + public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + BaseSection section = getSectionByPlanAndType(plan, sectionType); + SubSection target = section.getSubSectionByType(subSectionType); + if (target == null) { + throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); + } + SubSectionResponse.Result result = SubSectionResponse.Result.from(target, "Subsection deleted"); + section.removeSubSection(subSectionType); + + businessPlanQuery.save(plan); + + return result; + } + + private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List checks) { + + ObjectNode updatedJsonNode = (ObjectNode) objectMapper.valueToTree(jsonNode); + + ArrayNode checkListArray; + if (updatedJsonNode.has("checks") && updatedJsonNode.get("checks").isArray()) { + checkListArray = (ArrayNode) updatedJsonNode.get("checks"); + checkListArray.removeAll(); + + for (Boolean check : checks) { + checkListArray.add(check); + } + } + + return SubSectionSupportUtils.serializeJsonNodeSafely(objectMapper, updatedJsonNode); + } + + private BusinessPlan getOwnedBusinessPlanOrThrow(Long planId, Long memberId) { + BusinessPlan businessPlan = businessPlanQuery.getOrThrow(planId); + if (!businessPlan.isOwnedBy(memberId)) { + throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); + } + return businessPlan; + } + + private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type) { + return switch (type) { + case OVERVIEW -> plan.getOverview(); + case PROBLEM_RECOGNITION -> plan.getProblemRecognition(); + case FEASIBILITY -> plan.getFeasibility(); + case GROWTH_STRATEGY -> plan.getGrowthTactic(); + case TEAM_COMPETENCE -> plan.getTeamCompetence(); + default -> throw new IllegalArgumentException("Unsupported section: " + type); + }; + } +} diff --git a/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java new file mode 100644 index 00000000..79f5d48f --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java @@ -0,0 +1,38 @@ +package starlight.application.businessplan.provided; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.data.domain.Pageable; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface BusinessPlanService { + + BusinessPlanResponse.Result createBusinessPlan(Long memberId); + + BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); + + BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId); + + BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId); + + BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable); + + String updateBusinessPlanTitle(Long planId, String title, Long memberId); + + BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId); + + SubSectionResponse.Result upsertSubSection(Long planId, JsonNode jsonNode, List checks, + SubSectionType subSectionType, Long memberId); + + SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId); + + List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, + Long memberId); + + SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId); + + +} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java new file mode 100644 index 00000000..6f837466 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java @@ -0,0 +1,93 @@ +package starlight.application.businessplan.provided.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.data.domain.Page; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record BusinessPlanResponse() { + + public record Result( + Long businessPlanId, + String title, + PlanStatus planStatus, + String message + ) { + public static Result from(BusinessPlan businessPlan, String message) { + return new Result( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.getPlanStatus(), + message + ); + } + } + + public record Detail( + Long businessPlanId, + String title, + PlanStatus planStatus, + List subSectionDetailList + ) { + public static Detail from( + BusinessPlan businessPlan, + List subSectionDetailList + ) { + return new Detail( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.getPlanStatus(), + subSectionDetailList + ); + } + } + + public record Preview( + Long businessPlanId, + String title, + String pdfUrl, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime lastSavedAt, + PlanStatus planStatus + ) { + public static Preview from(BusinessPlan businessPlan) { + LocalDateTime lastSavedAt = businessPlan.getModifiedAt() != null + ? businessPlan.getModifiedAt() + : businessPlan.getCreatedAt(); + + return new Preview( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.isPdfBased() ? businessPlan.getPdfUrl() : null, + lastSavedAt, + businessPlan.getPlanStatus() + ); + } + } + + public record PreviewPage( + List content, + int page, + int size, + int totalPages, + long totalElements, + int numberOfElements, + boolean first, + boolean last + ) { + public static PreviewPage from(List content, Page page) { + return new BusinessPlanResponse.PreviewPage( + content, + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumberOfElements(), + page.isFirst(), + page.isLast() + ); + } + } +} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java new file mode 100644 index 00000000..019721d6 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java @@ -0,0 +1,39 @@ +package starlight.application.businessplan.provided.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import starlight.domain.businessplan.entity.SubSection; +import starlight.domain.businessplan.enumerate.SubSectionType; + +public record SubSectionResponse() { + + public record Result( + SubSectionType subSectionType, + Long subSectionId, + String message + ) { + public static Result from( + SubSection subSection, + String message + ) { + return new Result( + subSection.getSubSectionType(), + subSection.getId(), + message + ); + } + } + + public record Detail( + SubSectionType subSectionType, + Long subSectionId, + JsonNode content + ) { + public static Detail from(SubSection subSection) { + return new Detail( + subSection.getSubSectionType(), + subSection.getId(), + subSection.getRawJson().asTree() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java new file mode 100644 index 00000000..1fe30fa9 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java @@ -0,0 +1,16 @@ +package starlight.application.businessplan.required; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import starlight.domain.businessplan.entity.BusinessPlan; + +public interface BusinessPlanQuery { + + BusinessPlan getOrThrow(Long id); + + BusinessPlan save(BusinessPlan businessPlan); + + void delete(BusinessPlan businessPlan); + + Page findPreviewPage(Long memberId, Pageable pageable); +} diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java new file mode 100644 index 00000000..33049e2a --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java @@ -0,0 +1,20 @@ +package starlight.application.businessplan.required; + +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface ChecklistGrader { + + /** + * 서브섹션 내용을 체크리스트 기준에 따라 체크합니다. + * + * @param subSectionType 서브섹션 타입 + * @param content 서브섹션 내용 + * @return 체크리스트 결과 + */ + List check( + SubSectionType subSectionType, + String content + ); +} diff --git a/src/main/java/starlight/application/businessplan/required/SpellChecker.java b/src/main/java/starlight/application/businessplan/required/SpellChecker.java new file mode 100644 index 00000000..347abd57 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/SpellChecker.java @@ -0,0 +1,12 @@ +package starlight.application.businessplan.required; + +import starlight.adapter.businessplan.spellcheck.dto.Finding; + +import java.util.List; + +public interface SpellChecker { + + List check(String sentence); + + String applyTopSuggestions(String original, List findings); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java new file mode 100644 index 00000000..5f3e2eed --- /dev/null +++ b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java @@ -0,0 +1,92 @@ +package starlight.application.businessplan.util; + +import org.springframework.stereotype.Component; +import starlight.domain.businessplan.entity.BaseSection; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.entity.SubSection; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.shared.enumerate.SectionType; + +import java.util.ArrayList; +import java.util.List; + +/** + * BusinessPlan에서 LLM 채점을 위한 텍스트 컨텐츠를 추출하는 컴포넌트 + */ +@Component +public class BusinessPlanContentExtractor { + + /** + * BusinessPlan에서 모든 섹션의 컨텐츠를 추출하여 하나의 문자열로 반환 + */ + public String extractContent(BusinessPlan businessPlan) { + StringBuilder promptBuilder = new StringBuilder(); + promptBuilder.append("다음 사업계획서 내용을 채점해주세요:\n\n"); + + List sections = new ArrayList<>(); + + // 문제 인식 섹션 + String problemRecognition = extractSectionContent( + businessPlan.getProblemRecognition(), + SectionType.PROBLEM_RECOGNITION, + "문제 인식"); + if (!problemRecognition.isBlank()) { + sections.add(problemRecognition); + } + + // 실현 가능성 섹션 + String feasibility = extractSectionContent( + businessPlan.getFeasibility(), + SectionType.FEASIBILITY, + "실현 가능성"); + if (!feasibility.isBlank()) { + sections.add(feasibility); + } + + // 성장 전략 섹션 + String growthStrategy = extractSectionContent( + businessPlan.getGrowthTactic(), + SectionType.GROWTH_STRATEGY, + "성장 전략"); + if (!growthStrategy.isBlank()) { + sections.add(growthStrategy); + } + + // 팀 역량 섹션 + String teamCompetence = extractSectionContent( + businessPlan.getTeamCompetence(), + SectionType.TEAM_COMPETENCE, + "팀 역량"); + if (!teamCompetence.isBlank()) { + sections.add(teamCompetence); + } + + promptBuilder.append(String.join("\n\n", sections)); + return promptBuilder.toString(); + } + + /** + * 특정 섹션의 컨텐츠를 추출 + */ + private String extractSectionContent(BaseSection section, SectionType sectionType, String sectionTitle) { + if (section == null) { + return ""; + } + + StringBuilder sectionBuilder = new StringBuilder(); + sectionBuilder.append("## ").append(sectionTitle).append("\n"); + + for (SubSectionType subSectionType : SubSectionType.values()) { + if (subSectionType.getSectionType() == sectionType) { + SubSection subSection = section.getSubSectionByType(subSectionType); + if (subSection != null && subSection.getContent() != null && !subSection.getContent().isBlank()) { + sectionBuilder.append("### ").append(subSectionType.getDescription()).append("\n"); + sectionBuilder.append(subSection.getContent()).append("\n"); + } + } + } + + return sectionBuilder.toString(); + } +} + diff --git a/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java b/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java new file mode 100644 index 00000000..80011df2 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/util/PlainTextExtractUtils.java @@ -0,0 +1,299 @@ +package starlight.application.businessplan.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +/** + * Content(JSON) → 줄글 변환 (고정 포맷) + * - text : value + * - image : "[사진] {caption}" (캡션 없으면 "[사진]") + * - table : "col1: v1, col2: v2, ..." (행마다 한 줄) + * + * 지원 입력: + * (1) {"content":[ ... ]} + * (2) {"blocks":[ {"content":[ ... ]}, ... ]} + */ +public final class PlainTextExtractUtils { + + private PlainTextExtractUtils() { + } + + // 고정 포맷 상수 + private static final String IMAGE_TOKEN = "[사진]"; + private static final String TABLE_PAIR_SEPARATOR = ": "; + private static final String TABLE_FIELD_SEPARATOR = ", "; + + /** raw JSON 문자열 → 줄글 */ + public static String extractPlainText(ObjectMapper objectMapper, String jsonString) { + try { + JsonNode rootNode = objectMapper.readTree(jsonString); + return extractPlainText(rootNode); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JSON", e); + } + } + + /** DTO → 줄글 */ + public static String extractPlainText(ObjectMapper objectMapper, Object dtoObject) { + try { + JsonNode rootNode = objectMapper.valueToTree(dtoObject); + return extractPlainText(rootNode); + } catch (Exception e) { + throw new IllegalArgumentException("Cannot serialize DTO", e); + } + } + + /** 핵심 처리 */ + static String extractPlainText(JsonNode rootNode) { + List outputLines = new ArrayList<>(); + List contentItems = findContentItems(rootNode); + + for (JsonNode contentItem : contentItems) { + String type = contentItem.path("type").asText(""); + + switch (type) { + case "text": + Optional textLine = extractText(contentItem); + textLine.ifPresent(outputLines::add); + break; + + case "image": + Optional imageLine = extractImage(contentItem); + imageLine.ifPresent(outputLines::add); + break; + + case "table": + List tableLines = extractTable_new(contentItem); + outputLines.addAll(tableLines); + break; + + default: + break; + } + } + return joinNonBlank(outputLines, "\n").trim(); + } + + /** content 아이템 전부 추출(순서 보존) */ + static List findContentItems(JsonNode rootNode) { + List contentItems = new ArrayList<>(); + + // case (1): content 바로 있는 경우 + if (rootNode.has("content") && rootNode.path("content").isArray()) { + addAll(contentItems, (ArrayNode) rootNode.path("content")); + return contentItems; + } + + // case (2): blocks 내부 content들 + if (rootNode.has("blocks") && rootNode.path("blocks").isArray()) { + for (JsonNode blockNode : rootNode.path("blocks")) { + if (blockNode.has("content") && blockNode.path("content").isArray()) { + addAll(contentItems, (ArrayNode) blockNode.path("content")); + } + } + } + return contentItems; + } + + /** 텍스트 항목 */ + static Optional extractText(JsonNode contentItem) { + String value = contentItem.path("value").asText(""); + return value.isBlank() ? Optional.empty() : Optional.of(value); + } + + /** 이미지 항목: "[사진]" + (있으면 공백+캡션) */ + static Optional extractImage(JsonNode contentItem) { + String caption = contentItem.hasNonNull("caption") ? contentItem.path("caption").asText().trim() : ""; + if (caption.isBlank()) { + return Optional.of(IMAGE_TOKEN); + } + return Optional.of(IMAGE_TOKEN + " " + caption); + } + + static List extractTable_new(JsonNode contentItem) { + List tableLines = new ArrayList<>(); + JsonNode columnArrayNode = contentItem.path("columns"); + JsonNode rowArrayNode = contentItem.path("rows"); + if (!columnArrayNode.isArray() || !rowArrayNode.isArray()) { + return tableLines; + } + + int columnCount = columnArrayNode.size(); + int rowCount = rowArrayNode.size(); + + if (rowCount == 0) { + return tableLines; + } + + // 컬럼 개수 표시 + tableLines.add("[" + columnCount + " columns]"); + + // rowspan과 colspan을 고려하여 실제 테이블 그리드 구성 + // grid[row][col] = 해당 위치의 셀 텍스트 (null이면 rowspan으로 차지된 위치) + String[][] grid = new String[rowCount][columnCount]; + boolean[][] isOccupied = new boolean[rowCount][columnCount]; + + // 각 행을 순회하며 셀 배치 + for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { + JsonNode rowNode = rowArrayNode.get(rowIndex); + if (!rowNode.isArray()) { + continue; + } + + int colIndex = 0; + for (JsonNode cellNode : rowNode) { + // rowspan으로 차지된 위치는 건너뛰기 + while (colIndex < columnCount && isOccupied[rowIndex][colIndex]) { + colIndex++; + } + + if (colIndex >= columnCount) { + break; + } + + // 셀 내용 추출 + String cellText = extractCellContent(cellNode); + + // rowspan과 colspan 값 가져오기 (기본값 1) + int rowspan = resolveSpanValue(cellNode, "rowspan", "rowSpan"); + int colspan = resolveSpanValue(cellNode, "colspan", "colSpan"); + + // colspan 범위 확인 + if (colIndex + colspan > columnCount) { + colspan = columnCount - colIndex; + } + + // 셀을 그리드에 배치 + for (int r = 0; r < rowspan && rowIndex + r < rowCount; r++) { + for (int c = 0; c < colspan && colIndex + c < columnCount; c++) { + if (r == 0 && c == 0) { + // 첫 번째 위치에만 텍스트 저장 + grid[rowIndex + r][colIndex + c] = cellText; + } + // 모든 위치를 차지된 것으로 표시 + isOccupied[rowIndex + r][colIndex + c] = true; + } + } + + colIndex += colspan; + } + } + + // 그리드를 순회하며 각 행의 텍스트 생성 + for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { + List rowValues = new ArrayList<>(); + for (int colIndex = 0; colIndex < columnCount; colIndex++) { + String cellText = grid[rowIndex][colIndex]; + if (cellText == null) { + // rowspan으로 차지된 위치는 빈 문자열 + cellText = ""; + } + rowValues.add("\"" + cellText + "\""); + } + tableLines.add("[" + String.join(", ", rowValues) + "]"); + } + + return tableLines; + } + + /** 셀 내부의 content (BasicContent 리스트)를 텍스트로 추출 */ + static String extractCellContent(JsonNode cellNode) { + if (!cellNode.has("content") || !cellNode.path("content").isArray()) { + return ""; + } + + List cellParts = new ArrayList<>(); + for (JsonNode contentItem : cellNode.path("content")) { + String type = contentItem.path("type").asText(""); + switch (type) { + case "text": + String textValue = contentItem.path("value").asText(""); + if (!textValue.isBlank()) { + cellParts.add(textValue); + } + break; + case "image": + Optional imageText = extractImage(contentItem); + imageText.ifPresent(cellParts::add); + break; + default: + break; + } + } + + return String.join(" ", cellParts); + } + + /** 테이블 항목: 각 행을 "col1: v1, col2: v2 ..." */ + static List extractTable(JsonNode contentItem) { + List tableLines = new ArrayList<>(); + JsonNode columnArrayNode = contentItem.path("columns"); + JsonNode rowArrayNode = contentItem.path("rows"); + if (!columnArrayNode.isArray() || !rowArrayNode.isArray()) { + return tableLines; + } + + for (JsonNode rowNode : rowArrayNode) { + String line = mapRow(columnArrayNode, rowNode); + if (!line.isBlank()) { + tableLines.add(line); + } + } + + return tableLines; + } + + /** 한 행을 "col: val, col: val" 형태로 매핑 */ + static String mapRow(JsonNode columnArrayNode, JsonNode rowArrayNode) { + StringBuilder rowBuilder = new StringBuilder(); + int columnIndex = 0; + Iterator columnIterator = columnArrayNode.elements(); + + while (columnIterator.hasNext()) { + String columnName = columnIterator.next().asText(""); + String cellValue = rowArrayNode.has(columnIndex) ? rowArrayNode.get(columnIndex).asText("") : ""; + if (!columnName.isBlank()) { + if (rowBuilder.length() > 0) { + rowBuilder.append(TABLE_FIELD_SEPARATOR); + } + rowBuilder.append(columnName).append(TABLE_PAIR_SEPARATOR).append(cellValue); + } + columnIndex++; + } + return rowBuilder.toString(); + } + + /** ArrayNode → List에 추가 */ + static void addAll(List targetList, ArrayNode sourceArrayNode) { + for (JsonNode node : sourceArrayNode) { + targetList.add(node); + } + } + + /** 공백/빈 문자열 제외 후 결합 */ + static String joinNonBlank(List partsList, String separator) { + String result = ""; + for (String part : partsList) { + if (part != null && !part.isBlank()) { + result = result.isEmpty() ? part : result + separator + part; + } + } + return result; + } + + private static int resolveSpanValue(JsonNode cellNode, String lowerKey, String camelKey) { + if (cellNode.has(lowerKey)) { + return Math.max(1, cellNode.path(lowerKey).asInt(1)); + } + if (cellNode.has(camelKey)) { + return Math.max(1, cellNode.path(camelKey).asInt(1)); + } + return 1; + } +} diff --git a/src/main/java/starlight/application/businessplan/util/SubSectionSupportUtils.java b/src/main/java/starlight/application/businessplan/util/SubSectionSupportUtils.java new file mode 100644 index 00000000..c171025c --- /dev/null +++ b/src/main/java/starlight/application/businessplan/util/SubSectionSupportUtils.java @@ -0,0 +1,46 @@ +package starlight.application.businessplan.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import starlight.domain.businessplan.exception.BusinessPlanErrorType; +import starlight.domain.businessplan.exception.BusinessPlanException; + +import java.util.List; + +public final class SubSectionSupportUtils { + + private SubSectionSupportUtils() {} + + /** + * JsonNode를 문자열로 변환하며 안정성 검사 + * + * @param objectMapper JSON 처리를 위한 ObjectMapper + * @param node 변환할 JsonNode + * @return JSON 문자열 + * @throws BusinessPlanException 변환에 실패하거나 node가 null인 경우 + */ + public static String serializeJsonNodeSafely(ObjectMapper objectMapper, JsonNode node) { + if (node == null) { + throw new BusinessPlanException(BusinessPlanErrorType.REQUEST_EMPTY_RAW_JSON); + } + try { + return objectMapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new BusinessPlanException(BusinessPlanErrorType.RAW_JSON_SERIALIZATION_FAILURE); + } + } + + /** + + * 리스트가 지정된 크기를 가지는지 검증합니다. + + * + + * @param checks 검증할 Boolean 리스트 + + * @param expectedSize 기대되는 리스트 크기 + + * @throws BusinessPlanException 리스트가 null이거나 크기가 일치하지 않는 경우 + + */ + public static void requireSize(List checks, int expectedSize) { + if (checks == null || checks.size() != expectedSize) { + throw new BusinessPlanException(BusinessPlanErrorType.CHECKS_LIST_SIZE_INVALID); + } + } +} diff --git a/src/main/java/starlight/application/expert/ExpertQueryService.java b/src/main/java/starlight/application/expert/ExpertQueryService.java new file mode 100644 index 00000000..c21c258e --- /dev/null +++ b/src/main/java/starlight/application/expert/ExpertQueryService.java @@ -0,0 +1,47 @@ +package starlight.application.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.expert.provided.ExpertFinder; +import starlight.application.expert.required.ExpertQuery; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExpertQueryService implements ExpertFinder { + + private final ExpertQuery expertQuery; + + @Override + public Expert findById(Long id) { + return expertQuery.findById(id); + } + + @Override + public Expert findByIdWithDetails(Long id) { + return expertQuery.findByIdWithDetails(id); + } + + @Override + public List loadAll() { + return expertQuery.findAllWithDetails(); + } + + @Override + public List findByAllCategories(Collection categories) { + return expertQuery.findByAllCategories(categories); + } + + @Override + public Map findByIds(Set expertIds) { + return expertQuery.findExpertMapByIds(expertIds); + } +} diff --git a/src/main/java/starlight/application/expert/provided/ExpertFinder.java b/src/main/java/starlight/application/expert/provided/ExpertFinder.java new file mode 100644 index 00000000..5fa1a25a --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/ExpertFinder.java @@ -0,0 +1,21 @@ +package starlight.application.expert.provided; + +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public interface ExpertFinder { + Expert findById(Long id); + + Expert findByIdWithDetails(Long id); + + List loadAll(); + + List findByAllCategories(Collection categories); + + Map findByIds(Set expertIds); +} diff --git a/src/main/java/starlight/application/expert/required/ExpertQuery.java b/src/main/java/starlight/application/expert/required/ExpertQuery.java new file mode 100644 index 00000000..925dcd7a --- /dev/null +++ b/src/main/java/starlight/application/expert/required/ExpertQuery.java @@ -0,0 +1,22 @@ +package starlight.application.expert.required; + +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public interface ExpertQuery { + + Expert findById(Long id); + + Expert findByIdWithDetails(Long id); + + Map findExpertMapByIds(Set expertIds); + + List findAllWithDetails(); + + List findByAllCategories(Collection categories); +} diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java new file mode 100644 index 00000000..2f6e3e6e --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java @@ -0,0 +1,127 @@ +package starlight.application.expertApplication; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.expert.required.ExpertQuery; +import starlight.application.expertApplication.event.FeedbackRequestDto; +import starlight.application.expertApplication.provided.ExpertApplicationService; +import starlight.application.expertApplication.required.ExpertApplicationQuery; +import starlight.application.expertReport.provided.ExpertReportService; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expertApplication.entity.ExpertApplication; +import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; +import starlight.domain.expertApplication.exception.ExpertApplicationException; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ExpertApplicationServiceImpl implements ExpertApplicationService { + + private final ExpertQuery expertQuery; + private final BusinessPlanQuery planQuery; + private final ExpertApplicationQuery applicationQuery; + private final ApplicationEventPublisher eventPublisher; + private final ExpertReportService expertReportService; + + private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB + private static final String ALLOWED_CONTENT_TYPE = "application/pdf"; + + @Value("${feedback-token.expiration-date}") + private Long FEEDBACK_DEADLINE_DAYS = 7L; + + @Override + @Transactional + public void requestFeedback(Long expertId, Long planId, MultipartFile file, String menteeName) { + try { + validateFile(file); + + BusinessPlan plan = planQuery.getOrThrow(planId); + Expert expert = expertQuery.findById(expertId); + + plan.updateStatus(PlanStatus.EXPERT_MATCHED); + + registerApplicationRecord(expertId, planId); + + publishEmailEvent(expert, plan, file, menteeName); + } catch (Exception e) { + log.error("Failed to request Feedback. planId={}, expertId={}", planId, expertId, e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_FEEDBACK_REQUEST_FAILED); + } + } + + private void validateFile(MultipartFile file) { + if (file.isEmpty()) { + throw new ExpertApplicationException(ExpertApplicationErrorType.EMPTY_FILE); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_SIZE_EXCEEDED); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.equals(ALLOWED_CONTENT_TYPE)) { + throw new ExpertApplicationException(ExpertApplicationErrorType.UNSUPPORTED_FILE_TYPE); + } + } + + public void registerApplicationRecord(Long expertId, Long planId) { + if (applicationQuery.existsByExpertIdAndBusinessPlanId(expertId, planId)) { + throw new ExpertApplicationException(ExpertApplicationErrorType.APPLICATION_ALREADY_EXISTS); + } + + ExpertApplication application = ExpertApplication.create(planId, expertId); + applicationQuery.save(application); + } + + private String generateFilename(MultipartFile file, BusinessPlan plan, String menteeName) { + String originalFilename = file.getOriginalFilename(); + + if (originalFilename != null && !originalFilename.isBlank()) { + return originalFilename; + } + + return String.format("[사업계획서]%s_%s.pdf", plan.getTitle(), menteeName); + } + + protected void publishEmailEvent(Expert expert, BusinessPlan plan, MultipartFile file, String menteeName) { + try { + byte[] fileBytes = file.getBytes(); + String filename = generateFilename(file, plan, menteeName); + String feedbackUrl = buildFeedbackRequestUrl(expert.getId(), plan.getId()); + + FeedbackRequestDto event = FeedbackRequestDto.of( + expert.getEmail(), + expert.getName(), + menteeName, + plan.getTitle(), + LocalDate.now().plusDays(FEEDBACK_DEADLINE_DAYS).format(DateTimeFormatter.ISO_DATE), + feedbackUrl, + fileBytes, + filename + ); + + log.info("[EMAIL] publishing FeedbackRequestEvent expertId={}, planId={}", expert.getId(), plan.getId()); + + eventPublisher.publishEvent(event); + } catch (IOException e) { + log.error("Failed to read file. planId={}, expertId={}", plan.getId(), expert.getId(), e); + throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_READ_ERROR); + } + } + + private String buildFeedbackRequestUrl(Long expertId, Long planId) { + return expertReportService.createExpertReportLink(expertId, planId); + } +} diff --git a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java new file mode 100644 index 00000000..2226a90c --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java @@ -0,0 +1,29 @@ +package starlight.application.expertApplication.event; + +public record FeedbackRequestDto( + String mentorEmail, + + String mentorName, + + String menteeName, + + String businessPlanTitle, + + String feedbackDeadline, + + String feedbackUrl, + + byte[] attachedFile, + + String filename +) { + public static FeedbackRequestDto of( + String mentorEmail, String mentorName, String menteeName, String businessPlanTitle, + String feedbackDeadline, String feedbackUrl, byte[] attachedFile, String filename + ) { + return new FeedbackRequestDto( + mentorEmail, mentorName, menteeName, businessPlanTitle, + feedbackDeadline, feedbackUrl, attachedFile, filename + ); + } +} diff --git a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java new file mode 100644 index 00000000..fff777a7 --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java @@ -0,0 +1,53 @@ +package starlight.application.expertApplication.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import starlight.application.expertApplication.required.EmailSender; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FeedbackRequestEventListener { + + private final EmailSender emailSender; + + @Async("emailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2), + retryFor = {Exception.class} + ) + public void handleFeedbackRequestEvent(FeedbackRequestDto event) { + log.info("[EMAIL] listener triggered menteeName={}, businessPlanTitle={}", event.menteeName(), event.businessPlanTitle()); + try { + emailSender.sendFeedbackRequestMail(event); + + log.info("[EMAIL] sending via JavaMailSender to={} subject={}", event.mentorEmail(), event.menteeName()); + + } catch (Exception e) { + log.error("[EMAIL] Failed to send feedback request email after retries. menteeName={}, businessPlanTitle={}", + event.menteeName(), event.businessPlanTitle(), e); + + throw e; + } + } + + @Recover + public void recoverEmailSend(Exception e, FeedbackRequestDto event) { + log.error("[EMAIL FINAL FAILURE] ... menteeName={}, businessPlanTitle={}", + event.menteeName(), event.businessPlanTitle(), e); + + // TODO: 실패 처리 전략 + // 1. Dead Letter Queue에 저장 + // 2. 관리자에게 알림 전송 + // 3. DB에 실패 기록 저장 + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java b/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java new file mode 100644 index 00000000..17e63974 --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java @@ -0,0 +1,10 @@ +package starlight.application.expertApplication.provided; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +public interface ExpertApplicationService { + + void requestFeedback(Long expertId, Long planId, MultipartFile file, String menteeName) throws IOException; +} diff --git a/src/main/java/starlight/application/expertApplication/required/EmailSender.java b/src/main/java/starlight/application/expertApplication/required/EmailSender.java new file mode 100644 index 00000000..87a97d5e --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/required/EmailSender.java @@ -0,0 +1,8 @@ +package starlight.application.expertApplication.required; + +import starlight.application.expertApplication.event.FeedbackRequestDto; + +public interface EmailSender { + + void sendFeedbackRequestMail(FeedbackRequestDto dto); +} diff --git a/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java new file mode 100644 index 00000000..33ad33ed --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java @@ -0,0 +1,13 @@ +package starlight.application.expertApplication.required; + +import starlight.domain.expertApplication.entity.ExpertApplication; + +import java.util.List; + +public interface ExpertApplicationQuery { + Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId); + + List findRequestedExpertIds(Long businessPlanId); + + ExpertApplication save(ExpertApplication application); +} diff --git a/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java b/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java new file mode 100644 index 00000000..44295a5d --- /dev/null +++ b/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java @@ -0,0 +1,128 @@ +package starlight.application.expertReport; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.expert.provided.ExpertFinder; +import starlight.application.expertReport.provided.ExpertReportService; +import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto; +import starlight.application.expertReport.required.ExpertReportQuery; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expertReport.entity.ExpertReport; +import starlight.domain.expertReport.entity.ExpertReportDetail; +import starlight.domain.expertReport.enumerate.SaveType; + +import java.security.SecureRandom; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class ExpertReportServiceImpl implements ExpertReportService { + + @Value("${feedback-token.token-length}") + private int tokenLength; + + @Value("${feedback-token.charset}") + private String base62Chars; + + @Value("${feedback-token.base-url}") + private String feedbackBaseUrl; + + private final ExpertReportQuery expertReportQuery; + private final ExpertFinder expertFinder; + private final BusinessPlanQuery businessPlanQuery; + private final SecureRandom secureRandom = new SecureRandom(); + + @Override + public String createExpertReportLink( + Long expertId, + Long businessPlanId + ) { + String token = generateToken(); + + ExpertReport report = ExpertReport.create(expertId, businessPlanId, token); + expertReportQuery.save(report); + + return feedbackBaseUrl + token; + } + + @Override + public ExpertReport saveReport( + String token, + String overallComment, + List details, + SaveType saveType + ) { + ExpertReport report = expertReportQuery.findByTokenWithDetails(token); + + report.updateOverallComment(overallComment); + report.updateDetails(details); + + switch (saveType) { + case TEMPORARY -> { + report.temporarySave(); + } + case FINAL -> { + report.submit(); + BusinessPlan plan = businessPlanQuery.getOrThrow(report.getBusinessPlanId()); + plan.updateStatus(PlanStatus.FINALIZED); + } + + } + + return expertReportQuery.save(report); + } + + @Override + public ExpertReportWithExpertDto getExpertReportWithExpert(String token) { + ExpertReport report = expertReportQuery.findByTokenWithDetails(token); + report.incrementViewCount(); + + Expert expert = expertFinder.findByIdWithDetails(report.getExpertId()); + + return ExpertReportWithExpertDto.of(report, expert); + } + + @Override + @Transactional(readOnly = true) + public List getExpertReportsWithExpertByBusinessPlanId(Long businessPlanId) { + List reports = expertReportQuery.findAllByBusinessPlanId(businessPlanId); + + Set expertIds = reports.stream() + .map(ExpertReport::getExpertId) + .collect(Collectors.toSet()); + + Map expertsMap = expertFinder.findByIds(expertIds); + + return reports.stream() + .map(report -> { + Expert expert = expertsMap.get(report.getExpertId()); + return ExpertReportWithExpertDto.of(report, expert); + }) + .toList(); + } + + private String generateToken() { + StringBuilder token = new StringBuilder(tokenLength); + + do { + token.setLength(0); + for (int i = 0; i < tokenLength; i++) { + token.append(base62Chars.charAt( + secureRandom.nextInt(base62Chars.length()) + )); + } + } while (expertReportQuery.existsByToken(token.toString())); + + return token.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/expertReport/provided/ExpertReportService.java b/src/main/java/starlight/application/expertReport/provided/ExpertReportService.java new file mode 100644 index 00000000..1a6242e4 --- /dev/null +++ b/src/main/java/starlight/application/expertReport/provided/ExpertReportService.java @@ -0,0 +1,19 @@ +package starlight.application.expertReport.provided; + +import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto; +import starlight.domain.expertReport.entity.ExpertReport; +import starlight.domain.expertReport.entity.ExpertReportDetail; +import starlight.domain.expertReport.enumerate.SaveType; + +import java.util.List; + +public interface ExpertReportService{ + + String createExpertReportLink(Long expertId, Long businessPlanId); + + ExpertReport saveReport(String token, String overallComment, List details, SaveType saveType); + + ExpertReportWithExpertDto getExpertReportWithExpert(String token); + + List getExpertReportsWithExpertByBusinessPlanId(Long businessPlanId); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertDto.java b/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertDto.java new file mode 100644 index 00000000..8983f3b3 --- /dev/null +++ b/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertDto.java @@ -0,0 +1,14 @@ +package starlight.application.expertReport.provided.dto; + +import starlight.domain.expert.entity.Expert; +import starlight.domain.expertReport.entity.ExpertReport; + +public record ExpertReportWithExpertDto( + ExpertReport report, + + Expert expert +) { + public static ExpertReportWithExpertDto of(ExpertReport report, Expert expert) { + return new ExpertReportWithExpertDto(report, expert); + } +} diff --git a/src/main/java/starlight/application/expertReport/required/ExpertReportQuery.java b/src/main/java/starlight/application/expertReport/required/ExpertReportQuery.java new file mode 100644 index 00000000..6611e5dd --- /dev/null +++ b/src/main/java/starlight/application/expertReport/required/ExpertReportQuery.java @@ -0,0 +1,20 @@ +package starlight.application.expertReport.required; + +import starlight.domain.expertReport.entity.ExpertReport; + +import java.util.List; + +public interface ExpertReportQuery { + + ExpertReport getOrThrow(Long id); + + ExpertReport save(ExpertReport expertReport); + + void delete(ExpertReport expertReport); + + boolean existsByToken(String token); + + ExpertReport findByTokenWithDetails(String token); + + List findAllByBusinessPlanId(Long businessPlanId); +} diff --git a/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java b/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java new file mode 100644 index 00000000..58b78684 --- /dev/null +++ b/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java @@ -0,0 +1,12 @@ +package starlight.application.infrastructure.provided; + +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface LlmGenerator { + + List generateChecklistArray(SubSectionType subSectionType, String content, List criteria, List detailedCriteria); + + String generateReport(String content); +} diff --git a/src/main/java/starlight/application/infrastructure/provided/OcrProvider.java b/src/main/java/starlight/application/infrastructure/provided/OcrProvider.java new file mode 100644 index 00000000..e3a25fbd --- /dev/null +++ b/src/main/java/starlight/application/infrastructure/provided/OcrProvider.java @@ -0,0 +1,10 @@ +package starlight.application.infrastructure.provided; + +import starlight.shared.dto.infrastructure.OcrResponse; + +public interface OcrProvider { + + OcrResponse ocrPdfByUrl(String pdfUrl) ; + + String ocrPdfTextByUrl(String pdfUrl); +} diff --git a/src/main/java/starlight/application/infrastructure/provided/PresignedUrlProvider.java b/src/main/java/starlight/application/infrastructure/provided/PresignedUrlProvider.java new file mode 100644 index 00000000..78f060e7 --- /dev/null +++ b/src/main/java/starlight/application/infrastructure/provided/PresignedUrlProvider.java @@ -0,0 +1,10 @@ +package starlight.application.infrastructure.provided; + +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +public interface PresignedUrlProvider { + + PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName); + + String makePublic(String objectUrl); +} diff --git a/src/main/java/starlight/application/member/CredentialServiceImpl.java b/src/main/java/starlight/application/member/CredentialServiceImpl.java new file mode 100644 index 00000000..c3d1d069 --- /dev/null +++ b/src/main/java/starlight/application/member/CredentialServiceImpl.java @@ -0,0 +1,46 @@ +package starlight.application.member; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.application.member.provided.CredentialService; +import starlight.adapter.member.persistence.CredentialRepository; +import starlight.domain.auth.exception.AuthErrorType; +import starlight.domain.auth.exception.AuthException; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; + +@Service +@RequiredArgsConstructor +public class CredentialServiceImpl implements CredentialService { + + private final PasswordEncoder passwordEncoder; + private final CredentialRepository credentialRepository; + + /** + * Credential을 생성하고 저장하는 메서드 + * @param authRequest + * @return Credential + */ + public Credential createCredential(AuthRequest authRequest) { + + String hashedPassword = passwordEncoder.encode(authRequest.password()); + Credential credential = Credential.create(hashedPassword); + + return credentialRepository.save(credential); + } + + /** + * 비밀번호를 확인하는 메서드 + * @param member + * @param password + */ + public void checkPassword(Member member, String password) { + Credential credential = member.getCredential(); + + if(!passwordEncoder.matches(password, credential.getPassword())) { + throw new AuthException(AuthErrorType.PASSWORD_MISMATCH); + } + } +} diff --git a/src/main/java/starlight/application/member/MemberServiceImpl.java b/src/main/java/starlight/application/member/MemberServiceImpl.java new file mode 100644 index 00000000..8e0c8f97 --- /dev/null +++ b/src/main/java/starlight/application/member/MemberServiceImpl.java @@ -0,0 +1,43 @@ +package starlight.application.member; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.application.member.provided.MemberService; +import starlight.adapter.member.persistence.MemberRepository; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.exception.MemberErrorType; +import starlight.domain.member.exception.MemberException; + +@Service +@RequiredArgsConstructor +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + + /** + * Credential을 생성하고 저장하는 메서드 + * @param credential + * @param authRequest + * @return Member + */ + public Member createUser(Credential credential, AuthRequest authRequest) { + memberRepository.findByEmail(authRequest.email()).ifPresent(existingUser -> { + throw new MemberException(MemberErrorType.MEMBER_ALREADY_EXISTS); + }); + Member member = authRequest.toMember(credential); + return memberRepository.save(member); + } + + /** + * 이메일로 사용자를 조회하는 메서드 + * @param email + * @return Member + */ + public Member getUserByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND)); + } +} + diff --git a/src/main/java/starlight/application/member/provided/CredentialService.java b/src/main/java/starlight/application/member/provided/CredentialService.java new file mode 100644 index 00000000..0f2c0792 --- /dev/null +++ b/src/main/java/starlight/application/member/provided/CredentialService.java @@ -0,0 +1,17 @@ +package starlight.application.member.provided; + +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; + +public interface CredentialService { + + Credential createCredential(AuthRequest authRequest); + + /** + * 비밀번호를 확인하는 메서드 + * @param member + * @param password + */ + void checkPassword(Member member, String password); +} diff --git a/src/main/java/starlight/application/member/provided/MemberService.java b/src/main/java/starlight/application/member/provided/MemberService.java new file mode 100644 index 00000000..4b19773f --- /dev/null +++ b/src/main/java/starlight/application/member/provided/MemberService.java @@ -0,0 +1,12 @@ +package starlight.application.member.provided; + +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; + +public interface MemberService { + + Member createUser(Credential credential, AuthRequest authRequest); + + Member getUserByEmail(String email); +} diff --git a/src/main/java/starlight/application/member/required/MemberQuery.java b/src/main/java/starlight/application/member/required/MemberQuery.java new file mode 100644 index 00000000..02691c80 --- /dev/null +++ b/src/main/java/starlight/application/member/required/MemberQuery.java @@ -0,0 +1,8 @@ +package starlight.application.member.required; + +import starlight.domain.member.entity.Member; + +public interface MemberQuery { + + Member getOrThrow(Long id); +} diff --git a/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java b/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java new file mode 100644 index 00000000..489cb499 --- /dev/null +++ b/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java @@ -0,0 +1,167 @@ +package starlight.application.order; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import starlight.adapter.order.toss.TossClient; +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.adapter.order.webapi.dto.request.OrderCancelRequest; +import starlight.application.order.provided.OrderPaymentService; +import starlight.application.order.provided.OrdersQuery; +import starlight.application.order.provided.dto.PaymentHistoryItemDto; +import starlight.application.usage.provided.UsageCreditPort; +import starlight.domain.order.enumerate.OrderStatus; +import starlight.domain.order.enumerate.UsageProductType; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.order.Orders; +import starlight.domain.order.order.PaymentRecords; +import starlight.domain.order.order.vo.Money; +import starlight.domain.order.order.vo.OrderCode; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class OrderPaymentServiceImpl implements OrderPaymentService { + + private final TossClient tossClient; + private final OrdersQuery ordersQuery; + private final UsageCreditPort usageCreditPort; + + /** + * 결제 전 주문 준비 + * - orderCode로 주문이 있으면 재사용(검증 후 결제 시도 추가) + * - 없으면 새 주문 생성 후 첫 결제 시도 추가 + * + * @param orderCodeStr 프론트에서 생성한 주문번호 + * @param buyerId 구매자 ID + * @param productCode 결제 금액 + * @return Orders 준비된 주문 + */ + @Override + public Orders prepare(String orderCodeStr, Long buyerId, String productCode) { + UsageProductType product = UsageProductType.fromCode(productCode); + Money money = Money.krw(product.getPrice()); + OrderCode orderCode = OrderCode.of(orderCodeStr); + + return ordersQuery.findByOrderCode(orderCodeStr) + .map(existing -> { + existing.validateSameBuyer(buyerId); + existing.validateSameProduct(product); + existing.addPaymentAttempt(money); + return existing; + }) + .orElseGet(() -> { + Orders newOrder = Orders.newUsageOrder(orderCode, buyerId, money, product); + newOrder.addPaymentAttempt(money); + return ordersQuery.save(newOrder); + }); + } + + /** + * 결제 승인 (Confirm) + * 토스 리다이렉트 성공 후 호출 + * + * @param orderCodeStr 주문번호 + * @param paymentKey 토스 결제키 + * @return Orders 승인된 주문 + */ + @Override + public Orders confirm(String orderCodeStr, String paymentKey, Long buyerId) { + + Orders order = ordersQuery.getByOrderCodeOrThrow(orderCodeStr); + + UsageProductType product = UsageProductType.fromCode(order.getUsageProductCode()); + long expectedAmount = product.getPrice(); + + if (!Objects.equals(order.getPrice(), expectedAmount)) { + throw new OrderException(OrderErrorType.PAYMENT_AMOUNT_MISMATCH); + } + + PaymentRecords payment = order.getLatestRequestedOrThrow(); + + TossClientResponse.Confirm response = tossClient.confirm( + orderCodeStr, paymentKey, expectedAmount + ); + + String provider = response.providerOrNull(); + String receiptUrl = response.receiptUrlOrNull(); + Instant approvedAt = response.approvedAtOrNow(); + + payment.markDone( + response.paymentKey(), response.method(), provider, receiptUrl, approvedAt + ); + order.markPaid(); + + usageCreditPort.chargeForOrder( + order.getBuyerId(), + order.getId(), + product.getUsageCount() + ); + + return ordersQuery.save(order); + } + + /** + * 결제 취소 + * + * @param request 취소 요청 + * @return TossClientResponse.Cancel 취소 응답 + */ + @Override + public TossClientResponse.Cancel cancel(OrderCancelRequest request) { + + Orders order = ordersQuery.getByOrderCodeOrThrow(request.orderCode()); + + PaymentRecords payment = order.getLatestDoneOrThrow(); + payment.ensureHasPaymentKey(); + + TossClientResponse.Cancel response = tossClient.cancel( + payment.getPaymentKey(), request.reason() + ); + + payment.markCanceled(); + order.cancel(); + + ordersQuery.save(order); + + return response; + } + + public List getPaymentHistory(Long buyerId) { + // 1) 이 회원(buyer)의 주문 전체를 최신순으로 가져오기 + List orders = ordersQuery.findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(buyerId); + + return orders.stream() + // 결제완료(PAID) 주문만 + .filter(o -> o.getStatus() == OrderStatus.PAID) + .map(order -> { + // 2) 해당 주문의 가장 최근 DONE 결제 + PaymentRecords payment = order.getLatestDoneOrThrow(); + + // 3) 상품명 매핑 + UsageProductType productType = UsageProductType.fromCode(order.getUsageProductCode()); + String productName = productType.getDescription(); + + // 4) 결제일 (approvedAt 없으면 createdAt) + Instant paidAt = payment.getApprovedAt() != null + ? payment.getApprovedAt() + : payment.getCreatedAt(); + + return PaymentHistoryItemDto.of( + productName, + payment.getMethod(), + payment.getPrice(), + paidAt, + payment.getReceiptUrl() + ); + }) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/order/provided/OrderPaymentService.java b/src/main/java/starlight/application/order/provided/OrderPaymentService.java new file mode 100644 index 00000000..ff293f2a --- /dev/null +++ b/src/main/java/starlight/application/order/provided/OrderPaymentService.java @@ -0,0 +1,19 @@ +package starlight.application.order.provided; + +import starlight.application.order.provided.dto.TossClientResponse; +import starlight.adapter.order.webapi.dto.request.OrderCancelRequest; +import starlight.application.order.provided.dto.PaymentHistoryItemDto; +import starlight.domain.order.order.Orders; + +import java.util.List; + +public interface OrderPaymentService{ + + Orders prepare(String orderCodeStr, Long buyerId, String productCode); + + Orders confirm(String orderCodeStr, String paymentKey, Long buyerId); + + TossClientResponse.Cancel cancel(OrderCancelRequest request); + + List getPaymentHistory(Long buyerId); +} diff --git a/src/main/java/starlight/application/order/provided/OrdersQuery.java b/src/main/java/starlight/application/order/provided/OrdersQuery.java new file mode 100644 index 00000000..16b5dfc3 --- /dev/null +++ b/src/main/java/starlight/application/order/provided/OrdersQuery.java @@ -0,0 +1,17 @@ +package starlight.application.order.provided; + +import starlight.domain.order.order.Orders; + +import java.util.List; +import java.util.Optional; + +public interface OrdersQuery { + + Optional findByOrderCode(String orderCode); + + List findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(Long buyerId); + + Orders getByOrderCodeOrThrow(String orderCode); + + Orders save(Orders order); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java b/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java new file mode 100644 index 00000000..1e0f3e71 --- /dev/null +++ b/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java @@ -0,0 +1,27 @@ +package starlight.application.order.provided.dto; + +import java.time.Instant; + +public record PaymentHistoryItemDto( + String productName, + String paymentMethod, + Long price, + Instant paidAt, + String receiptUrl +) { + public static PaymentHistoryItemDto of( + String productName, + String paymentMethod, + Long price, + Instant paidAt, + String receiptUrl + ) { + return new PaymentHistoryItemDto( + productName, + paymentMethod, + price, + paidAt, + receiptUrl + ); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/order/provided/dto/TossClientResponse.java b/src/main/java/starlight/application/order/provided/dto/TossClientResponse.java new file mode 100644 index 00000000..97a4c932 --- /dev/null +++ b/src/main/java/starlight/application/order/provided/dto/TossClientResponse.java @@ -0,0 +1,98 @@ +package starlight.application.order.provided.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.extern.slf4j.Slf4j; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; + +@Slf4j +public record TossClientResponse ( +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Cancel( + // 기본 정보 + String mId, // 가맹점 ID + String lastTransactionKey, // 마지막 거래 키 + String paymentKey, // 결제 키 + String orderId, // ORD20251113-3PNGMYUX + String orderName, // starlight 크레딧 5,000원 + Integer taxExemptionAmount, // 비과세 금액 + String status, // (String) CANCELED + String requestedAt, // 요청 시각 + String approvedAt, // 승인 시각 + + // 취소 내역 + List cancels, + + // 결제 정보 + String secret, // 가맹점 시크릿 코드 (ps_kYG57Eba3GKvEgqK6GnE3pWDOxmA) + String type, // 결제 타입 ("NORMAL") + EasyPay easyPay, + + // 영수증 + Receipt receipt, + + // 금액 정보 + String currency, // "KRW" + Integer totalAmount, // 5000 + Integer balanceAmount, // 0 + Integer suppliedAmount, // 5000 + Integer vat, // 455 + Integer taxFreeAmount, // 0 + String method // "간편결제" + ) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CancelDetail( + String cancelReason, // "user_request" + String canceledAt, // "2025-11-13T19:09:04+09:00" + String cancelStatus, // "DONE" + Integer cancelAmount // 5000 + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record EasyPay( + String provider, // "카카오페이" + Integer amount, // 5000 + Integer discountAmount // 0 + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Receipt( + String url // 영수증 URL + ) {} + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Confirm( + String paymentKey, + String orderId, + String status, // e.g., DONE + String method, // CARD/EASY_PAY... + Long totalAmount, + OffsetDateTime approvedAt, + EasyPay easyPay, + Receipt receipt + ) { + public record EasyPay(String provider) {} + public record Receipt(String url) {} + + public String providerOrNull() { + return (easyPay != null) ? easyPay.provider() : null; + } + + public String receiptUrlOrNull() { + return (receipt != null) ? receipt.url() : null; + } + + public Instant approvedAtOrNow() { + if (approvedAt == null) { + log.warn("승인 시각이 누락되었습니다, orderId: {}", orderId); + return Instant.now(); + } + return approvedAt.toInstant(); + } + } +} diff --git a/src/main/java/starlight/application/usage/UsageCreditService.java b/src/main/java/starlight/application/usage/UsageCreditService.java new file mode 100644 index 00000000..5b888b72 --- /dev/null +++ b/src/main/java/starlight/application/usage/UsageCreditService.java @@ -0,0 +1,53 @@ +package starlight.application.usage; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import starlight.application.usage.provided.UsageCreditPort; +import starlight.application.usage.provided.UsageHistoryQuery; +import starlight.application.usage.provided.UsageWalletQuery; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.wallet.UsageHistory; +import starlight.domain.order.wallet.UsageWallet; + +@Service +@RequiredArgsConstructor +@Transactional +public class UsageCreditService implements UsageCreditPort { + + private final UsageWalletQuery usageWalletQuery; + private final UsageHistoryQuery usageHistoryQuery; + + /** + * 주문 결제가 완료되었을 때 사용권(지갑)을 충전한다. + * + * @param userId 주문자 ID + * @param orderId 주문 PK (UsageHistory 연동용) + * @param usageCount 몇 회권인지 (1회 / 2회 등) + */ + @Override + public void chargeForOrder(Long userId, Long orderId, int usageCount) { + if (userId == null || usageCount <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + + // 지갑 조회 or 생성 + UsageWallet wallet = usageWalletQuery.findByUserId(userId) + .orElseGet(() -> usageWalletQuery.save(UsageWallet.init(userId))); + + // 사용권 충전 + wallet.chargeAiReport(usageCount); + usageWalletQuery.save(wallet); + + // 이력 기록 + usageHistoryQuery.save( + UsageHistory.charged( + userId, + usageCount, + wallet.getAiReportRemainingCount(), + orderId + ) + ); + } +} diff --git a/src/main/java/starlight/application/usage/provided/UsageCreditPort.java b/src/main/java/starlight/application/usage/provided/UsageCreditPort.java new file mode 100644 index 00000000..724eeb4c --- /dev/null +++ b/src/main/java/starlight/application/usage/provided/UsageCreditPort.java @@ -0,0 +1,6 @@ +package starlight.application.usage.provided; + +public interface UsageCreditPort { + + void chargeForOrder(Long userId, Long orderId, int usageCount); +} diff --git a/src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java b/src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java new file mode 100644 index 00000000..2f9c10af --- /dev/null +++ b/src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java @@ -0,0 +1,8 @@ +package starlight.application.usage.provided; + +import starlight.domain.order.wallet.UsageHistory; + +public interface UsageHistoryQuery { + + UsageHistory save(UsageHistory usageHistory); +} diff --git a/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java b/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java new file mode 100644 index 00000000..37f99ee9 --- /dev/null +++ b/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java @@ -0,0 +1,12 @@ +package starlight.application.usage.provided; + +import starlight.domain.order.wallet.UsageWallet; + +import java.util.Optional; + +public interface UsageWalletQuery { + + Optional findByUserId(Long userId); + + UsageWallet save(UsageWallet usageWallet); +} diff --git a/src/main/java/starlight/bootstrap/AsyncConfig.java b/src/main/java/starlight/bootstrap/AsyncConfig.java new file mode 100644 index 00000000..fee3c177 --- /dev/null +++ b/src/main/java/starlight/bootstrap/AsyncConfig.java @@ -0,0 +1,43 @@ +package starlight.bootstrap; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +@Slf4j +@Configuration +@EnableAsync +@EnableRetry +public class AsyncConfig implements AsyncConfigurer { + + @Bean(name = "emailTaskExecutor") + public Executor emailTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(3); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("email-async-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return (ex, method, params) -> { + log.error("Async execution exception in method: {}", method.getName(), ex); + log.error("Exception message: {}", ex.getMessage()); + // TODO: 모니터링 시스템에 알림 전송 + }; + } +} diff --git a/src/main/java/starlight/bootstrap/MailConfig.java b/src/main/java/starlight/bootstrap/MailConfig.java new file mode 100644 index 00000000..027954bc --- /dev/null +++ b/src/main/java/starlight/bootstrap/MailConfig.java @@ -0,0 +1,46 @@ +package starlight.bootstrap; + +import org.springframework.boot.autoconfigure.mail.MailProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +@EnableConfigurationProperties(MailProperties.class) +public class MailConfig { + + @Bean + public JavaMailSender javaMailSender(MailProperties mailProperties) { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(mailProperties.getHost()); + sender.setPort(mailProperties.getPort()); + sender.setUsername(mailProperties.getUsername()); + sender.setPassword(mailProperties.getPassword()); + sender.setJavaMailProperties(buildJavaMailProps(mailProperties)); // ⬅ 추출된 메서드 사용 + return sender; + } + + private Properties buildJavaMailProps(MailProperties mailProperties) { + Properties props = new Properties(); + props.put("mail.transport.protocol", "smtp"); + props.put("mail.smtp.auth", "true"); + + if (mailProperties.getPort() == 465) { + props.put("mail.smtp.ssl.enable", "true"); + } else { + props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.smtp.starttls.required", "true"); + } + + props.put("mail.smtp.connectiontimeout", "10000"); + props.put("mail.smtp.timeout", "10000"); + props.put("mail.smtp.writetimeout", "10000"); + + props.putAll(mailProperties.getProperties()); + return props; + } +} diff --git a/src/main/java/starlight/bootstrap/ObjectMapperConfig.java b/src/main/java/starlight/bootstrap/ObjectMapperConfig.java new file mode 100644 index 00000000..c2963fa4 --- /dev/null +++ b/src/main/java/starlight/bootstrap/ObjectMapperConfig.java @@ -0,0 +1,19 @@ +package starlight.bootstrap; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return mapper; + } +} \ No newline at end of file diff --git a/src/main/java/starlight/bootstrap/ObjectStorageConfig.java b/src/main/java/starlight/bootstrap/ObjectStorageConfig.java new file mode 100644 index 00000000..01fcaabd --- /dev/null +++ b/src/main/java/starlight/bootstrap/ObjectStorageConfig.java @@ -0,0 +1,48 @@ +package starlight.bootstrap; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +@Configuration +public class ObjectStorageConfig { + + @Value("${cloud.ncp.object-storage.end-point}") + private String endpoint; + @Value("${cloud.ncp.object-storage.region-name}") + private String region; + @Value("${cloud.ncp.object-storage.credentials.access-key}") + private String accessKey; + @Value("${cloud.ncp.object-storage.credentials.secret-key}") + private String secretKey; + + @Bean + public S3Client ncpS3Client() { + return S3Client.builder() + .region(Region.of("kr-standard")) + .endpointOverride(URI.create(endpoint)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of("kr-standard")) + .endpointOverride(URI.create(endpoint)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/bootstrap/RedisConfig.java b/src/main/java/starlight/bootstrap/RedisConfig.java new file mode 100644 index 00000000..ba1565ae --- /dev/null +++ b/src/main/java/starlight/bootstrap/RedisConfig.java @@ -0,0 +1,37 @@ +package starlight.bootstrap; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private Integer port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/starlight/bootstrap/RestClientConfig.java b/src/main/java/starlight/bootstrap/RestClientConfig.java new file mode 100644 index 00000000..047d4ff5 --- /dev/null +++ b/src/main/java/starlight/bootstrap/RestClientConfig.java @@ -0,0 +1,100 @@ +package starlight.bootstrap; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.UUID; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient spellCheckClient() { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofSeconds(12)); + + return RestClient.builder() + .baseUrl("https://dic.daum.net") + .requestFactory(factory) + .defaultHeader("User-Agent", "Mozilla/5.0") + .defaultHeader("Accept-Language", "ko,en;q=0.9") + .build(); + } + + @Bean(name = "clovaOcrRestClient") + public RestClient clovaOcrRestClient( + @Value("${cloud.ncp.ocr.endpoint}") String endpoint, + @Value("${cloud.ncp.ocr.secret}") String secret + ) { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofSeconds(120)); + + return RestClient.builder() + .baseUrl(endpoint) + .requestFactory(factory) + .defaultHeader("Content-Type", "application/json") + .defaultHeader("X-OCR-SECRET", secret) + .build(); + } + + /** + * (옵션) 외부 PDF를 다운로드할 때 쓰는 경량 클라이언트 + * - 일반적인 타임아웃 + * - UA만 지정 (일부 서버 호환) + * 필요 없으면 이 빈은 제거해도 됨. + */ + @Bean(name = "downloadClient") + public RestClient downloadClient() { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofSeconds(60)); + + return RestClient.builder() + .requestFactory(factory) + .defaultHeader("User-Agent", "Mozilla/5.0") + .build(); + } + + @Bean(name = "clovaClient") + public RestClient clovaStudioClient( + @Value("${cloud.ncp.studio.host}") String clovaHost, + @Value("${cloud.ncp.studio.api-key}") String apiKey, + @Value("${cloud.ncp.studio.model}") String model + ) { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofSeconds(60)); + + return RestClient.builder() + .baseUrl(String.format("%s/%s", clovaHost, model)) + .requestFactory(factory) + .defaultRequest(request -> { + request.header("X-NCP-CLOVASTUDIO-REQUEST-ID", UUID.randomUUID().toString()); + }) + .defaultHeader("Authorization", "Bearer " + apiKey) // Bearer only + .defaultHeader("Content-Type", "application/json") + .build(); + } + + @Bean(name = "tossRestClient") + public RestClient tossRestClient( + @Value("${toss.secretKey}") String secretKey, + @Value("${toss.baseUrl}") String baseUrl + ) { + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofSeconds(20)); // 필요 시 조정 + + String basic = java.util.Base64.getEncoder() + .encodeToString((secretKey + ":").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + return RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(factory) + .defaultHeader("Authorization", "Basic " + basic) + .defaultHeader("Content-Type", "application/json") + .defaultHeader("Accept", "application/json") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java new file mode 100644 index 00000000..bd76996f --- /dev/null +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -0,0 +1,127 @@ +package starlight.bootstrap; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import starlight.adapter.auth.security.filter.ExceptionFilter; +import starlight.adapter.auth.security.filter.JwtFilter; +import starlight.adapter.auth.security.handler.JwtAccessDeniedHandler; +import starlight.adapter.auth.security.handler.JwtAuthenticationHandler; +import starlight.adapter.auth.security.oauth2.CustomOAuth2UserService; +import starlight.adapter.auth.security.oauth2.OAuth2SuccessHandler; + +import java.util.List; + +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Value("${cors.origin.server}") String ServerBaseUrl; + @Value("${cors.origin.client}") String clientBaseUrl; + + private final JwtFilter jwtFilter; + private final ExceptionFilter exceptionFilter; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationHandler jwtAuthenticationEntryPoint; + private final CustomOAuth2UserService oAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable); + + http.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable); + + http.exceptionHandling((exceptionHandling) -> + exceptionHandling + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + ); + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(exceptionFilter, JwtFilter.class); + + http.authorizeHttpRequests((authorize) -> + authorize + .requestMatchers("/error/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/", "/index.html", "/ops.html", "/payment.html", "/api/payment/**").permitAll() + .requestMatchers("/v1/auth/**","/v1/user/**", "/v1/experts").permitAll() + .requestMatchers("/login/**", "/oauth2/**", "/login/oauth2/**", "/public/**").permitAll() + .requestMatchers("/v3/api-docs/**", "/v1/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**").permitAll() + + // Expert Application + .requestMatchers(HttpMethod.GET, "/v1/expert-reports/*").permitAll() + .requestMatchers(HttpMethod.POST, "/v1/expert-reports/*").permitAll() + + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .anyRequest().authenticated()) + .oauth2Login(oauth -> oauth + .loginPage("/login/oauth2/code/kakao") + .userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler((request, response, exception) -> { + log.warn("OAuth2 login failed: {}", exception.getMessage(), exception); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + }) + ); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(List.of( + clientBaseUrl, + ServerBaseUrl + )); + + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of("Authorization", "Set-Cookie")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public LogoutSuccessHandler logoutSuccessHandler() { + return new HttpStatusReturningLogoutSuccessHandler(); + } +} diff --git a/src/main/java/starlight/bootstrap/SwaggerConfig.java b/src/main/java/starlight/bootstrap/SwaggerConfig.java new file mode 100644 index 00000000..366f0f7d --- /dev/null +++ b/src/main/java/starlight/bootstrap/SwaggerConfig.java @@ -0,0 +1,38 @@ +package starlight.bootstrap; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Collections; + +@OpenAPIDefinition( + info = @Info(title = "StarLight 명세서", description = "StarLight API 명세서", version = "v1" + ), + servers = { + @Server(url = "${cors.origin.server}",description = "서버 URL") + } +) + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") + .in(SecurityScheme.In.HEADER).name("Authorization"); + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) + .security(Collections.singletonList(securityRequirement)); + } +} diff --git a/src/main/java/starlight/domain/aireport/entity/AiReport.java b/src/main/java/starlight/domain/aireport/entity/AiReport.java new file mode 100644 index 00000000..234053c8 --- /dev/null +++ b/src/main/java/starlight/domain/aireport/entity/AiReport.java @@ -0,0 +1,39 @@ +package starlight.domain.aireport.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.shared.valueobject.RawJson; +import starlight.shared.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AiReport extends AbstractEntity { + + @Column(name = "business_plan_id", nullable = false) + private Long businessPlanId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "raw_json", columnDefinition = "TEXT", nullable = false)) + private RawJson rawJson; + + public static AiReport create(Long businessPlanId, String rawJson) { + Assert.notNull(businessPlanId, "businessPlanId must not be null"); + Assert.notNull(rawJson, "rawJson은 null일 수 없습니다."); + + AiReport report = new AiReport(); + report.businessPlanId = businessPlanId; + report.rawJson = RawJson.create(rawJson); + + return report; + } + + public void update(String rawJson) { + Assert.notNull(rawJson, "rawJson은 null일 수 없습니다."); + + this.rawJson = RawJson.create(rawJson); + } +} diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java new file mode 100644 index 00000000..95b85996 --- /dev/null +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -0,0 +1,21 @@ +package starlight.domain.aireport.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum AiReportErrorType implements ErrorType { + + AI_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 AI 리포트가 존재하지 않습니다."), + NOT_READY_FOR_AI_REPORT(HttpStatus.BAD_REQUEST, "사업계획서가 작성 완료되지 않아 AI 리포트를 생성할 수 없습니다."), + UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), + AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."); + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportException.java b/src/main/java/starlight/domain/aireport/exception/AiReportException.java new file mode 100644 index 00000000..2b86898f --- /dev/null +++ b/src/main/java/starlight/domain/aireport/exception/AiReportException.java @@ -0,0 +1,11 @@ +package starlight.domain.aireport.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class AiReportException extends GlobalException { + + public AiReportException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/auth/exception/AuthErrorType.java b/src/main/java/starlight/domain/auth/exception/AuthErrorType.java new file mode 100644 index 00000000..42355426 --- /dev/null +++ b/src/main/java/starlight/domain/auth/exception/AuthErrorType.java @@ -0,0 +1,20 @@ +package starlight.domain.auth.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorType implements ErrorType { + + TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "토큰이 존재하지 않습니다."), + TOKEN_INVALID(HttpStatus.BAD_REQUEST, "토큰이 유효하지 않습니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/auth/exception/AuthException.java b/src/main/java/starlight/domain/auth/exception/AuthException.java new file mode 100644 index 00000000..fc14f367 --- /dev/null +++ b/src/main/java/starlight/domain/auth/exception/AuthException.java @@ -0,0 +1,11 @@ +package starlight.domain.auth.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class AuthException extends GlobalException { + + public AuthException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/businessplan/entity/BaseSection.java b/src/main/java/starlight/domain/businessplan/entity/BaseSection.java new file mode 100644 index 00000000..0d9990be --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/BaseSection.java @@ -0,0 +1,43 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.shared.BaseEntity; + +import java.util.Objects; + +@Getter +@MappedSuperclass +@NoArgsConstructor +public abstract class BaseSection extends BaseEntity { + + @Id + @Column(name = "business_plan_id") + protected Long id; + + @OneToOne + @MapsId + @JoinColumn(name = "business_plan_id", referencedColumnName = "id") + protected BusinessPlan businessPlan; + + public void attachBusinessPlan(BusinessPlan businessPlan) { + this.businessPlan = businessPlan; + } + + public void putSubSection(SubSection subSection) { + Objects.requireNonNull(subSection, "subSection must not be null"); + setSubSectionByType(subSection, subSection.getSubSectionType()); + } + + public void removeSubSection(SubSectionType type) { + setSubSectionByType(null, type); + } + + public abstract SubSection getSubSectionByType(SubSectionType type); + + protected abstract void setSubSectionByType(SubSection subSection, SubSectionType type); + + protected abstract boolean areAllSubSectionsCreated(); +} diff --git a/src/main/java/starlight/domain/businessplan/entity/BusinessPlan.java b/src/main/java/starlight/domain/businessplan/entity/BusinessPlan.java new file mode 100644 index 00000000..d58a9cf3 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/BusinessPlan.java @@ -0,0 +1,115 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.shared.AbstractEntity; + +@Getter +@Entity +@NoArgsConstructor +public class BusinessPlan extends AbstractEntity { + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private String title; + + @Column(length = 512) + private String pdfUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private PlanStatus planStatus; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "businessPlan") + private Overview overview; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "businessPlan") + private ProblemRecognition problemRecognition; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "businessPlan") + private Feasibility feasibility; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "businessPlan") + private GrowthTactic growthTactic; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "businessPlan") + private TeamCompetence teamCompetence; + + public static BusinessPlan create(String title, Long memberId) { + Assert.notNull(title, "title must not be null"); + Assert.notNull(memberId, "memberId must not be null"); + + BusinessPlan businessPlan = new BusinessPlan(); + businessPlan.title = title; + businessPlan.memberId = memberId; + businessPlan.planStatus = PlanStatus.STARTED; + + businessPlan.initializeSections(); + + return businessPlan; + } + + public static BusinessPlan createWithPdf(String title, Long memberId, String pdfUrl) { + Assert.notNull(title, "title must not be null"); + Assert.notNull(memberId, "memberId must not be null"); + Assert.notNull(pdfUrl, "pdfUrl must not be null"); + + BusinessPlan businessPlan = new BusinessPlan(); + businessPlan.title = title; + businessPlan.memberId = memberId; + businessPlan.pdfUrl = pdfUrl; + businessPlan.planStatus = PlanStatus.WRITTEN_COMPLETED; + + businessPlan.initializeSections(); + + return businessPlan; + } + + public boolean isOwnedBy(Long memberId) { + return this.memberId.equals(memberId); + } + + public boolean isPdfBased() { + return this.pdfUrl != null; + } + + public void updateTitle(String title) { + this.title = title; + } + + public void updateStatus(PlanStatus planStatus) { + this.planStatus = planStatus; + } + + // 모든 서브 섹션 생성 시에 작성 완료로 판단 + public boolean areWritingCompleted() { + return overview.areAllSubSectionsCreated() + && problemRecognition.areAllSubSectionsCreated() + && feasibility.areAllSubSectionsCreated() + && growthTactic.areAllSubSectionsCreated() + && teamCompetence.areAllSubSectionsCreated(); + } + + private void initializeSections() { + // 공유 기본키 매핑: 자식이 부모와 같은 PK를 사용 + this.overview = Overview.create(); + this.overview.attachBusinessPlan(this); + + this.problemRecognition = ProblemRecognition.create(); + this.problemRecognition.attachBusinessPlan(this); + + this.feasibility = Feasibility.create(); + this.feasibility.attachBusinessPlan(this); + + this.growthTactic = GrowthTactic.create(); + this.growthTactic.attachBusinessPlan(this); + + this.teamCompetence = TeamCompetence.create(); + this.teamCompetence.attachBusinessPlan(this); + } +} diff --git a/src/main/java/starlight/domain/businessplan/entity/Feasibility.java b/src/main/java/starlight/domain/businessplan/entity/Feasibility.java new file mode 100644 index 00000000..2229f945 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/Feasibility.java @@ -0,0 +1,47 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.businessplan.enumerate.SubSectionType; + +@Getter +@Entity +@NoArgsConstructor +public class Feasibility extends BaseSection { + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "feasibility_strategy_id", unique = true) + private SubSection feasibilityStrategy; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "feasibility_market_id", unique = true) + private SubSection feasibilityMarket; + + public static Feasibility create() { + return new Feasibility(); + } + + @Override + public SubSection getSubSectionByType(SubSectionType type) { + return switch (type) { + case FEASIBILITY_STRATEGY -> this.feasibilityStrategy; + case FEASIBILITY_MARKET -> this.feasibilityMarket; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + @Override + protected void setSubSectionByType(SubSection subSection, SubSectionType type) { + switch (type) { + case FEASIBILITY_STRATEGY -> this.feasibilityStrategy = subSection; + case FEASIBILITY_MARKET -> this.feasibilityMarket = subSection; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + @Override + protected boolean areAllSubSectionsCreated() { + return this.feasibilityMarket != null && this.feasibilityStrategy != null; + } +} diff --git a/src/main/java/starlight/domain/businessplan/entity/GrowthTactic.java b/src/main/java/starlight/domain/businessplan/entity/GrowthTactic.java new file mode 100644 index 00000000..aa62d1e9 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/GrowthTactic.java @@ -0,0 +1,53 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.businessplan.enumerate.SubSectionType; + +@Getter +@Entity +@NoArgsConstructor +public class GrowthTactic extends BaseSection{ + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "growth_model_id", unique = true) + private SubSection growthModel; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "growth_funding_id", unique = true) + private SubSection growthFunding; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "growth_entry_id", unique = true) + private SubSection growthEntry; + + public static GrowthTactic create() { + return new GrowthTactic(); + } + + @Override + public SubSection getSubSectionByType(SubSectionType type) { + return switch (type) { + case GROWTH_MODEL -> this.growthModel; + case GROWTH_FUNDING -> this.growthFunding; + case GROWTH_ENTRY -> this.growthEntry; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + @Override + protected void setSubSectionByType(SubSection subSection, SubSectionType type) { + switch (type) { + case GROWTH_MODEL -> this.growthModel = subSection; + case GROWTH_FUNDING -> this.growthFunding = subSection; + case GROWTH_ENTRY -> this.growthEntry = subSection; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + } + } + + @Override + protected boolean areAllSubSectionsCreated() { + return this.growthModel != null && this.growthFunding != null && this.growthEntry != null; + } +} diff --git a/src/main/java/starlight/domain/businessplan/entity/Overview.java b/src/main/java/starlight/domain/businessplan/entity/Overview.java new file mode 100644 index 00000000..9a3e4a34 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/Overview.java @@ -0,0 +1,42 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.businessplan.enumerate.SubSectionType; + +@Getter +@Entity +@NoArgsConstructor +public class Overview extends BaseSection { + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "overview_basic_id", unique = true) + private SubSection overviewBasic; + + public static Overview create() { + return new Overview(); + } + + @Override + public SubSection getSubSectionByType(SubSectionType type) { + return switch (type) { + case OVERVIEW_BASIC -> this.overviewBasic; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + @Override + protected void setSubSectionByType(SubSection subSection, SubSectionType type) { + switch (type) { + case OVERVIEW_BASIC -> this.overviewBasic = subSection; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + @Override + protected boolean areAllSubSectionsCreated() { + return this.overviewBasic != null; + } +} + diff --git a/src/main/java/starlight/domain/businessplan/entity/ProblemRecognition.java b/src/main/java/starlight/domain/businessplan/entity/ProblemRecognition.java new file mode 100644 index 00000000..6fc2e641 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/ProblemRecognition.java @@ -0,0 +1,53 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.businessplan.enumerate.SubSectionType; + +@Getter +@Entity +@NoArgsConstructor +public class ProblemRecognition extends BaseSection{ + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "problem_background_id", unique = true) + private SubSection problemBackground; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "problem_purpose_id", unique = true) + private SubSection problemPurpose; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "problem_market_id", unique = true) + private SubSection problemMarket; + + public static ProblemRecognition create() { + return new ProblemRecognition(); + } + + @Override + public SubSection getSubSectionByType(SubSectionType type) { + return switch (type) { + case PROBLEM_BACKGROUND -> this.problemBackground; + case PROBLEM_PURPOSE -> this.problemPurpose; + case PROBLEM_MARKET -> this.problemMarket; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + @Override + protected void setSubSectionByType(SubSection subSection, SubSectionType type) { + switch (type) { + case PROBLEM_BACKGROUND -> this.problemBackground = subSection; + case PROBLEM_PURPOSE -> this.problemPurpose = subSection; + case PROBLEM_MARKET -> this.problemMarket = subSection; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + } + } + + @Override + protected boolean areAllSubSectionsCreated() { + return this.problemBackground != null && this.problemPurpose != null && this.problemMarket != null; + } +} diff --git a/src/main/java/starlight/domain/businessplan/entity/SubSection.java b/src/main/java/starlight/domain/businessplan/entity/SubSection.java new file mode 100644 index 00000000..07347daf --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/SubSection.java @@ -0,0 +1,81 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.shared.valueobject.RawJson; +import starlight.shared.AbstractEntity; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SubSection extends AbstractEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private SubSectionType subSectionType; + + @Column(columnDefinition = "TEXT") + private String content; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "raw_json", columnDefinition = "TEXT", nullable = false)) + private RawJson rawJson; + + @Column(nullable = false) + private boolean checkFirst = false; + + @Column(nullable = false) + private boolean checkSecond = false; + + @Column(nullable = false) + private boolean checkThird = false; + + @Column(nullable = false) + private boolean checkFourth = false; + + @Column(nullable = false) + private boolean checkFifth = false; + + @Getter + private static final int CHECKLIST_SIZE = 5; + + public static SubSection create( + SubSectionType subSectionType, String content, String rawJson, List checks + ) { + SubSection subSection = new SubSection(); + subSection.subSectionType = subSectionType; + subSection.content = content; + subSection.rawJson = RawJson.create(rawJson); + subSection.applyChecks(checks); + return subSection; + } + + public void update(String content, String rawJson, List checks) { + Assert.notNull(content, "content은 null일 수 없습니다."); + Assert.notNull(rawJson, "rawJson은 null일 수 없습니다."); + Assert.notNull(checks, "checks 리스트는 null일 수 없습니다."); + Assert.isTrue(checks.size() == CHECKLIST_SIZE, "checks 리스트는 길이 5 여야 합니다."); + + this.content = content; + this.rawJson = RawJson.create(rawJson); + applyChecks(checks); + } + + private void applyChecks(List checks) { + this.checkFirst = Boolean.TRUE.equals(checks.get(0)); + this.checkSecond = Boolean.TRUE.equals(checks.get(1)); + this.checkThird = Boolean.TRUE.equals(checks.get(2)); + this.checkFourth = Boolean.TRUE.equals(checks.get(3)); + this.checkFifth = Boolean.TRUE.equals(checks.get(4)); + } + + public List getChecks() { + return List.of(checkFirst, checkSecond, checkThird, checkFourth, checkFifth); + } +} diff --git a/src/main/java/starlight/domain/businessplan/entity/TeamCompetence.java b/src/main/java/starlight/domain/businessplan/entity/TeamCompetence.java new file mode 100644 index 00000000..01bff94e --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/entity/TeamCompetence.java @@ -0,0 +1,47 @@ +package starlight.domain.businessplan.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.businessplan.enumerate.SubSectionType; + +@Getter +@Entity +@NoArgsConstructor +public class TeamCompetence extends BaseSection{ + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "team_founder_id", unique = true) + private SubSection teamFounder; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name = "team_members_id", unique = true) + private SubSection teamMembers; + + public static TeamCompetence create() { + return new TeamCompetence(); + } + + @Override + public SubSection getSubSectionByType(SubSectionType type) { + return switch (type) { + case TEAM_FOUNDER -> this.teamFounder; + case TEAM_MEMBERS -> this.teamMembers; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + @Override + protected void setSubSectionByType(SubSection subSection, SubSectionType type) { + switch (type) { + case TEAM_FOUNDER -> this.teamFounder = subSection; + case TEAM_MEMBERS -> this.teamMembers = subSection; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + } + } + + @Override + protected boolean areAllSubSectionsCreated() { + return this.teamFounder != null && this.teamMembers != null; + } +} diff --git a/src/main/java/starlight/domain/businessplan/enumerate/PlanStatus.java b/src/main/java/starlight/domain/businessplan/enumerate/PlanStatus.java new file mode 100644 index 00000000..b845f111 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/enumerate/PlanStatus.java @@ -0,0 +1,17 @@ +package starlight.domain.businessplan.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PlanStatus { + + STARTED("시작됨"), + WRITTEN_COMPLETED("작성 완료"), + AI_REVIEWED("AI 리뷰 완료"), + EXPERT_MATCHED("전문가 매칭 완료"), + FINALIZED("최종 완료"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/businessplan/enumerate/SubSectionType.java b/src/main/java/starlight/domain/businessplan/enumerate/SubSectionType.java new file mode 100644 index 00000000..4e09fb81 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/enumerate/SubSectionType.java @@ -0,0 +1,35 @@ +package starlight.domain.businessplan.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import starlight.shared.enumerate.SectionType; + +@Getter +@RequiredArgsConstructor +public enum SubSectionType { + + // 개요 (OVERVIEW) + OVERVIEW_BASIC("개요", SectionType.OVERVIEW, "overview_basic"), + + // 문제 인식 (PROBLEM_RECOGNITION) + PROBLEM_BACKGROUND("창업 배경 및 개발동기", SectionType.PROBLEM_RECOGNITION, "problem_background"), + PROBLEM_PURPOSE("창업아이템의 목적 및 필요성", SectionType.PROBLEM_RECOGNITION, "problem_purpose"), + PROBLEM_MARKET("창업아이템의 목표시장 분석", SectionType.PROBLEM_RECOGNITION, "problem_market"), + + // 실현 가능성 (FEASIBILITY) + FEASIBILITY_STRATEGY("사업화 전략", SectionType.FEASIBILITY, "feasibility_strategy"), + FEASIBILITY_MARKET("시장분석 및 경쟁력 확보 방안", SectionType.FEASIBILITY, "feasibility_market"), + + // 성장 전략 (GROWTH_STRATEGY) + GROWTH_MODEL("비즈니스 모델", SectionType.GROWTH_STRATEGY, "growth_model"), + GROWTH_FUNDING("자금조달 계획", SectionType.GROWTH_STRATEGY, "growth_funding"), + GROWTH_ENTRY("시장진입 및 성과창출 전략", SectionType.GROWTH_STRATEGY, "growth_entry"), + + // 팀 역량 (TEAM_COMPETENCE) + TEAM_FOUNDER("창업자의 역량", SectionType.TEAM_COMPETENCE, "team_founder"), + TEAM_MEMBERS("팀 역량", SectionType.TEAM_COMPETENCE, "team_members"); + + private final String description; + private final SectionType sectionType; + private final String tag; +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java new file mode 100644 index 00000000..2c228e67 --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java @@ -0,0 +1,25 @@ +package starlight.domain.businessplan.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum BusinessPlanErrorType implements ErrorType { + + REQUEST_EMPTY_RAW_JSON(HttpStatus.BAD_REQUEST, "rawJson은 null 이 될 수 없습니다."), + RAW_JSON_SERIALIZATION_FAILURE(HttpStatus.BAD_REQUEST, "rawJson 직렬화에 실패했습니다."), + CHECKS_LIST_SIZE_INVALID(HttpStatus.BAD_REQUEST, "checks 리스트는 길이 5 여야 합니다."), + BUSINESS_PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 사업계획서가 존재하지 않습니다."), + SUBSECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 서브 섹션이 존재하지 않습니다."), + UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), + SECTIONAL_CONTENT_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 해당 Section 내용이 존재합니다."), + SECTIONAL_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 Section 내용이 존재하지 않습니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/businessplan/exception/BusinessPlanException.java b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanException.java new file mode 100644 index 00000000..b924f38c --- /dev/null +++ b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanException.java @@ -0,0 +1,11 @@ +package starlight.domain.businessplan.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class BusinessPlanException extends GlobalException { + + public BusinessPlanException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java new file mode 100644 index 00000000..da0c009d --- /dev/null +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -0,0 +1,53 @@ +package starlight.domain.expert.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Min; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.expert.enumerate.TagCategory; +import starlight.shared.AbstractEntity; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Expert extends AbstractEntity { + + @Column(length = 320) + private String name; + + @Column + private Long workedPeriod; + + @Column + private String profileImageUrl; + + @Column(nullable = false, length = 320) + private String email; + + @Min(0) + @Column + private Integer mentoringPriceWon; + + @ElementCollection + @CollectionTable(name = "expert_careers", joinColumns = @JoinColumn(name = "expert_id")) + @Column(name = "career_text", length = 300, nullable = false) + @OrderColumn(name = "order_index") + private List careers = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "expert_tags", joinColumns = @JoinColumn(name = "expert_id")) + @Column(name = "tag", length = 40, nullable = false) + private Set tags = new LinkedHashSet<>(); + + @ElementCollection + @CollectionTable(name = "expert_categories", joinColumns = @JoinColumn(name = "expert_id")) + @Enumerated(EnumType.STRING) + @Column(name = "category", length = 40, nullable = false) + private Set categories = new LinkedHashSet<>(); +} diff --git a/src/main/java/starlight/domain/expert/enumerate/TagCategory.java b/src/main/java/starlight/domain/expert/enumerate/TagCategory.java new file mode 100644 index 00000000..d8432edc --- /dev/null +++ b/src/main/java/starlight/domain/expert/enumerate/TagCategory.java @@ -0,0 +1,17 @@ +package starlight.domain.expert.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TagCategory { + + MARKET_BM("시장성/BM"), + TEAM_CAPABILITY("팀 역량"), + PROBLEM_DEFINITION("문제 정의"), + GROWTH_STRATEGY("성장 전략"), + METRIC_DATA("지표/데이터"),; + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java new file mode 100644 index 00000000..e8179cd8 --- /dev/null +++ b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java @@ -0,0 +1,19 @@ +package starlight.domain.expert.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum ExpertErrorType implements ErrorType { + + EXPERT_QUERY_ERROR(HttpStatus.NOT_FOUND, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), + EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."); + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/expert/exception/ExpertException.java b/src/main/java/starlight/domain/expert/exception/ExpertException.java new file mode 100644 index 00000000..e48a267b --- /dev/null +++ b/src/main/java/starlight/domain/expert/exception/ExpertException.java @@ -0,0 +1,11 @@ +package starlight.domain.expert.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class ExpertException extends GlobalException { + + public ExpertException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/expertApplication/entity/ExpertApplication.java b/src/main/java/starlight/domain/expertApplication/entity/ExpertApplication.java new file mode 100644 index 00000000..f20086fb --- /dev/null +++ b/src/main/java/starlight/domain/expertApplication/entity/ExpertApplication.java @@ -0,0 +1,43 @@ +package starlight.domain.expertApplication.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.springframework.util.Assert; +import starlight.shared.AbstractEntity; + +@Getter +@ToString +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +@Entity +@Table( + uniqueConstraints = { + @UniqueConstraint( + name = "uk_app_active_one", + columnNames = {"business_plan_id", "expert_id"} + ) + } +) +public class ExpertApplication extends AbstractEntity { + + @Column(nullable = false) + private Long businessPlanId; + + @Column(nullable = false) + private Long expertId; + + public static ExpertApplication create(Long businessPlanId, Long expertId) { + Assert.notNull(businessPlanId, "businessPlanId must not be null"); + Assert.notNull(expertId, "expertId must not be null"); + + ExpertApplication expertApplication = new ExpertApplication(); + expertApplication.businessPlanId = businessPlanId; + expertApplication.expertId = expertId; + + return expertApplication; + } +} diff --git a/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationErrorType.java b/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationErrorType.java new file mode 100644 index 00000000..72c97a8a --- /dev/null +++ b/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationErrorType.java @@ -0,0 +1,32 @@ +package starlight.domain.expertApplication.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum ExpertApplicationErrorType implements ErrorType { + + // 전문가 신청 관련 오류 타입 정의 + EXPERT_APPLICATION_QUERY_ERROR(HttpStatus.NOT_FOUND, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), + EXPERT_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + APPLICATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신청한 전문가입니다."), + EXPERT_FEEDBACK_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 피드백 요청에 실패했습니다."), + + // 파일 처리 관련 오류 + EMPTY_FILE(HttpStatus.BAD_REQUEST, "업로드할 파일이 비어 있습니다."), + FILE_READ_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일을 읽는 중에 오류가 발생했습니다."), + FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기는 최대 20MB까지 업로드 가능합니다."), + UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원되지 않는 파일 형식입니다."), + + // 이메일 전송 관련 오류 + EMAIL_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송 중에 오류가 발생했습니다.") + ; + + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationException.java b/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationException.java new file mode 100644 index 00000000..d5b305c9 --- /dev/null +++ b/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationException.java @@ -0,0 +1,11 @@ +package starlight.domain.expertApplication.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class ExpertApplicationException extends GlobalException { + + public ExpertApplicationException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/expertReport/entity/ExpertReport.java b/src/main/java/starlight/domain/expertReport/entity/ExpertReport.java new file mode 100644 index 00000000..1a98d48a --- /dev/null +++ b/src/main/java/starlight/domain/expertReport/entity/ExpertReport.java @@ -0,0 +1,140 @@ +package starlight.domain.expertReport.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.domain.expertReport.enumerate.SubmitStatus; +import starlight.domain.expertReport.exception.ExpertReportErrorType; +import starlight.domain.expertReport.exception.ExpertReportException; +import starlight.shared.AbstractEntity; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + uniqueConstraints = { + @UniqueConstraint( + name = "uk_expert_report_business_plan_expert", + columnNames = {"business_plan_id", "expert_id"} + ) + } +) +public class ExpertReport extends AbstractEntity { + + @Column(nullable = false) + private Long expertId; + + @Column(nullable = false) + private Long businessPlanId; + + @Column(nullable = false) + private LocalDateTime expiredAt; + + @Column(unique = true, length = 20) + private String token; + + @Column + private int viewCount = 0; + + @Column(columnDefinition = "TEXT") + private String overallComment; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private SubmitStatus submitStatus = SubmitStatus.PENDING; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(nullable = false) + private List details = new ArrayList<>(); + + // 7일의 기한을 가지고 기한 내에만 수정가능하다. + // -> expiredAt, submitStatus 필드로 관리 + // PENDING, TEMPORARY_SAVED 상태에서만 수정 가능 + // PENDING, TEMPORARY_SAVED 상태에서 제출 가능하고, SUBMITTED 상태로 변경 + // SUBMITTED, EXPIRED 상태에서는 수정 불가 + // expiredAt가 현재 시간보다 이전이면 EXPIRED 상태로 변경 + + // 기한이 지나거나 제출하면 읽기 전용으로 바뀐다. + // -> expiredAt, submitStatus 필드로 관리 + // SUBMITTED, EXPIRED 상태에서는 읽기 전용 + // PENDING, TEMPORARY_SAVED 상태에서는 수정 가능 + // expiredAt가 현재 시간보다 이전이면 EXPIRED 상태로 변경 + + public static ExpertReport create(Long expertId, Long businessPlanId, String token) { + Assert.notNull(expertId, "expertId는 필수입니다"); + Assert.notNull(businessPlanId, "businessPlanId는 필수입니다"); + Assert.hasText(token, "token은 필수입니다"); + + ExpertReport expertReport = new ExpertReport(); + expertReport.expertId = expertId; + expertReport.businessPlanId = businessPlanId; + expertReport.expiredAt = LocalDateTime.now().plusDays(7); + expertReport.token = token; + return expertReport; + } + + public boolean isExpired() { + Assert.notNull(expiredAt, "expiredAt이 설정되지 않았습니다"); + + return LocalDateTime.now().isAfter(expiredAt); + } + + public void temporarySave() { + validateCanEdit(); + this.submitStatus = SubmitStatus.TEMPORARY_SAVED; + } + + public void submit() { + validateCanEdit(); + this.submitStatus = SubmitStatus.SUBMITTED; + } + + public void syncStatus() { + if ((submitStatus == SubmitStatus.PENDING || + submitStatus == SubmitStatus.TEMPORARY_SAVED) + && isExpired()) { + this.submitStatus = SubmitStatus.EXPIRED; + } + } + + public void validateCanEdit() { + syncStatus(); + + if (submitStatus == SubmitStatus.SUBMITTED) { + throw new ExpertReportException(ExpertReportErrorType.ALREADY_SUBMITTED); + } + + if (submitStatus == SubmitStatus.EXPIRED) { + throw new ExpertReportException(ExpertReportErrorType.REPORT_EXPIRED); + } + } + + public void updateOverallComment(String overallComment) { + validateCanEdit(); + this.overallComment = overallComment; + } + + public void updateDetails(List newDetails) { + Assert.notNull(newDetails, "details는 null일 수 없습니다"); + + validateCanEdit(); + this.details.clear(); + this.details.addAll(newDetails); + } + + public void incrementViewCount() { + this.viewCount++; + } + + public boolean canEdit() { + syncStatus(); + return submitStatus == SubmitStatus.PENDING || + submitStatus == SubmitStatus.TEMPORARY_SAVED; + } +} diff --git a/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java b/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java new file mode 100644 index 00000000..0a985304 --- /dev/null +++ b/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java @@ -0,0 +1,36 @@ +package starlight.domain.expertReport.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.domain.expertReport.enumerate.CommentType; +import starlight.shared.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExpertReportDetail extends AbstractEntity { + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private CommentType commentType; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + public static ExpertReportDetail create(CommentType commentType, String content) { + Assert.notNull(commentType, "commentType은 필수입니다"); + Assert.hasText(content, "content는 필수입니다"); + + ExpertReportDetail detail = new ExpertReportDetail(); + detail.commentType = commentType; + detail.content = content; + return detail; + } + + public void update(String content) { + this.content = content; + } +} diff --git a/src/main/java/starlight/domain/expertReport/enumerate/CommentType.java b/src/main/java/starlight/domain/expertReport/enumerate/CommentType.java new file mode 100644 index 00000000..2d6a5ffb --- /dev/null +++ b/src/main/java/starlight/domain/expertReport/enumerate/CommentType.java @@ -0,0 +1,14 @@ +package starlight.domain.expertReport.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CommentType { + + STRENGTH("강점"), + WEAKNESS("약점"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/expertReport/enumerate/SaveType.java b/src/main/java/starlight/domain/expertReport/enumerate/SaveType.java new file mode 100644 index 00000000..c4788d71 --- /dev/null +++ b/src/main/java/starlight/domain/expertReport/enumerate/SaveType.java @@ -0,0 +1,14 @@ +package starlight.domain.expertReport.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SaveType { + + TEMPORARY("임시 저장"), + FINAL("최종 제출"); + + private final String description; +} diff --git a/src/main/java/starlight/domain/expertReport/enumerate/SubmitStatus.java b/src/main/java/starlight/domain/expertReport/enumerate/SubmitStatus.java new file mode 100644 index 00000000..58747ecc --- /dev/null +++ b/src/main/java/starlight/domain/expertReport/enumerate/SubmitStatus.java @@ -0,0 +1,16 @@ +package starlight.domain.expertReport.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SubmitStatus { + + PENDING("평가 전"), + TEMPORARY_SAVED("임시 저장"), + SUBMITTED("제출 완료"), + EXPIRED("만료됨"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/expertReport/exception/ExpertReportErrorType.java b/src/main/java/starlight/domain/expertReport/exception/ExpertReportErrorType.java new file mode 100644 index 00000000..d05c677e --- /dev/null +++ b/src/main/java/starlight/domain/expertReport/exception/ExpertReportErrorType.java @@ -0,0 +1,21 @@ +package starlight.domain.expertReport.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum ExpertReportErrorType implements ErrorType { + + // 전문가 피드백 신청 관련 오류 + EXPERT_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + ALREADY_SUBMITTED(HttpStatus.BAD_REQUEST, "이미 전문가 피드백을 제출하였습니다."), + REPORT_EXPIRED(HttpStatus.BAD_REQUEST, "전문가 피드백 요청 기간이 만료되었습니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/expertReport/exception/ExpertReportException.java b/src/main/java/starlight/domain/expertReport/exception/ExpertReportException.java new file mode 100644 index 00000000..2d7089fe --- /dev/null +++ b/src/main/java/starlight/domain/expertReport/exception/ExpertReportException.java @@ -0,0 +1,11 @@ +package starlight.domain.expertReport.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class ExpertReportException extends GlobalException { + + public ExpertReportException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/member/entity/Credential.java b/src/main/java/starlight/domain/member/entity/Credential.java new file mode 100644 index 00000000..912590af --- /dev/null +++ b/src/main/java/starlight/domain/member/entity/Credential.java @@ -0,0 +1,28 @@ +package starlight.domain.member.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Credential { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(columnDefinition = "bigint", nullable = false) + private Long id; + + @Column(nullable = false) + private String password; + + public static Credential create(String hashedPassword) { + Credential credential = new Credential(); + credential.password = hashedPassword; + + return credential; + } +} + diff --git a/src/main/java/starlight/domain/member/entity/Member.java b/src/main/java/starlight/domain/member/entity/Member.java new file mode 100644 index 00000000..f67d9678 --- /dev/null +++ b/src/main/java/starlight/domain/member/entity/Member.java @@ -0,0 +1,70 @@ +package starlight.domain.member.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.springframework.util.Assert; +import starlight.domain.member.enumerate.MemberType; +import starlight.shared.AbstractEntity; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE \"member\" SET deleted_at = NOW() WHERE id = ?") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends AbstractEntity { + + @Column(nullable = false, columnDefinition = "varchar(320)") + private String name; + + @Column(nullable = false, columnDefinition = "varchar(320)") + private String email; + + private String profileImageUrl; + + @Column(columnDefinition = "varchar(20)") + private String phoneNumber; + + @Column(nullable = false, columnDefinition = "varchar(255)") + @Enumerated(EnumType.STRING) + private MemberType memberType; + + @OneToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "credential_id", referencedColumnName = "id") + private Credential credential; + + @Column(length = 20) + private String provider; + + @Column(length = 64) + private String providerId; + + public static Member create(String name, String email, String phoneNumber, MemberType memberType, Credential credential, String profileImageUrl) { + Member member = new Member(); + member.name = name; + member.email = email; + member.phoneNumber = phoneNumber; + member.memberType = memberType != null ? memberType : MemberType.FOUNDER; + member.credential = credential; + member.provider = "starlight"; + member.profileImageUrl = profileImageUrl; + return member; + } + + public static Member newSocial(String name, String email, String provider, + String providerId, String phoneNumber, MemberType memberType, String profileImageUrl) { + Member member = Member.create(name, email, phoneNumber, memberType, null, profileImageUrl); + + member.provider = provider; + member.providerId = providerId; + + return member; + } + + public void updateProfileImage(String profileImageUrl) { + Assert.notNull(profileImageUrl, "profileImageUrl must not be null"); + + this.profileImageUrl = profileImageUrl; + } +} diff --git a/src/main/java/starlight/domain/member/enumerate/MemberType.java b/src/main/java/starlight/domain/member/enumerate/MemberType.java new file mode 100644 index 00000000..db509c0f --- /dev/null +++ b/src/main/java/starlight/domain/member/enumerate/MemberType.java @@ -0,0 +1,14 @@ +package starlight.domain.member.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MemberType { + + FOUNDER("창업자"), + EXPERT("전문가"); + + private final String role; +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/member/exception/MemberErrorType.java b/src/main/java/starlight/domain/member/exception/MemberErrorType.java new file mode 100644 index 00000000..be31fe04 --- /dev/null +++ b/src/main/java/starlight/domain/member/exception/MemberErrorType.java @@ -0,0 +1,24 @@ +package starlight.domain.member.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorType implements ErrorType { + + ALREADY_FOUNDER(HttpStatus.BAD_REQUEST, "이미 회원 타입이 EXPERT입니다."), + INVALID_VERIFICATION_CODE(HttpStatus.UNAUTHORIZED, "유효하지 않은 이메일 인증 코드입니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + INVALID_EMAIL(HttpStatus.BAD_REQUEST, "유효하지 않은 이메일입니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + EMAIL_DUPLICATE(HttpStatus.CONFLICT, "이미 사용중인 이메일입니다."), + MEMBER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 멤버입니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/member/exception/MemberException.java b/src/main/java/starlight/domain/member/exception/MemberException.java new file mode 100644 index 00000000..690f7f35 --- /dev/null +++ b/src/main/java/starlight/domain/member/exception/MemberException.java @@ -0,0 +1,11 @@ +package starlight.domain.member.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class MemberException extends GlobalException { + + public MemberException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/order/enumerate/OrderStatus.java b/src/main/java/starlight/domain/order/enumerate/OrderStatus.java new file mode 100644 index 00000000..f5095e8f --- /dev/null +++ b/src/main/java/starlight/domain/order/enumerate/OrderStatus.java @@ -0,0 +1,15 @@ +package starlight.domain.order.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OrderStatus { + + NEW("주문 생성됨 (결제 전)"), + PAID("결제 완료"), + CANCELED("주문/결제 취소"); + + private final String description; +} diff --git a/src/main/java/starlight/domain/order/enumerate/UsageProductType.java b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java new file mode 100644 index 00000000..2c857d24 --- /dev/null +++ b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java @@ -0,0 +1,27 @@ +package starlight.domain.order.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +@Getter +@RequiredArgsConstructor +public enum UsageProductType { + + AI_REPORT_1("AI_REPORT_1", 1, 49_000L, "LITE 요금제"), + AI_REPORT_2("AI_REPORT_2", 2, 89_000L, "STANDARD 요금제"); + + private final String code; // 상품 코드 + private final int usageCount; // 사용 가능 횟수 + private final long price; // 가격 + private final String description; // 설명 + + public static UsageProductType fromCode(String code) { + return switch (code) { + case "AI_REPORT_1" -> AI_REPORT_1; + case "AI_REPORT_2" -> AI_REPORT_2; + default -> throw new OrderException(OrderErrorType.INVALID_USAGE_PRODUCT); + }; + } +} diff --git a/src/main/java/starlight/domain/order/exception/OrderErrorType.java b/src/main/java/starlight/domain/order/exception/OrderErrorType.java new file mode 100644 index 00000000..07047987 --- /dev/null +++ b/src/main/java/starlight/domain/order/exception/OrderErrorType.java @@ -0,0 +1,50 @@ +package starlight.domain.order.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum OrderErrorType implements ErrorType { + + // PG 통신 에러 + TOSS_CLIENT_CONFIRM_ERROR(HttpStatus.BAD_REQUEST, "토스 결제 요청 중 오류가 발생했습니다."), + TOSS_CLIENT_CANCEL_ERROR(HttpStatus.BAD_REQUEST, "토스 결제 취소 요청 중 오류가 발생했습니다."), + + // 도메인 규칙 위반 - 결제 상태 + ALREADY_PAID(HttpStatus.BAD_REQUEST, "이미 결제가 완료된 주문입니다."), + INVALID_ORDER_STATE_FOR_PAYMENT(HttpStatus.BAD_REQUEST, "주문 생성 상태에서만 결제 가능합니다."), + INVALID_ORDER_STATE_FOR_CANCEL(HttpStatus.BAD_REQUEST, "결제 완료 상태에서만 취소 가능합니다."), + + // 도메인 규칙 위반 - 금액/주문번호 + PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "주문 금액과 결제 금액이 일치하지 않습니다."), + ORDER_CODE_BUYER_MISMATCH(HttpStatus.BAD_REQUEST, "이미 존재하는 주문번호입니다. (구매자 상이)"), + ORDER_CODE_BUSINESS_PLAN_MISMATCH(HttpStatus.BAD_REQUEST, "이미 존재하는 주문번호입니다. (사업계획서 상이)"), + ORDER_PRODUCT_MISMATCH(HttpStatus.BAD_REQUEST, "이미 존재하는 주문번호입니다. (이용권 금액 상이)"), + + // 결제 이력 없음 + NO_REQUESTED_PAYMENT(HttpStatus.BAD_REQUEST, "승인 가능한 결제 시도가 존재하지 않습니다."), + NO_DONE_PAYMENT(HttpStatus.BAD_REQUEST, "취소 가능한 결제 이력이 존재하지 않습니다."), + NO_PAYMENT_RECORDS(HttpStatus.BAD_REQUEST, "주문에 결제 이력이 존재하지 않습니다."), + NO_PAYMENT_KEY(HttpStatus.BAD_REQUEST, "paymentKey가 없어 PG 취소를 수행할 수 없습니다."), + + // 조회 실패 + ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문을 찾을 수 없습니다."), + ALREADY_PAID_FOR_BUSINESS_PLAN(HttpStatus.BAD_REQUEST, "이미 결제가 완료된 사업계획서입니다."), + + // 사용 횟수 지갑 관련 + INVALID_INITIAL_CHARGE_COUNT(HttpStatus.BAD_REQUEST, "초기 충전 횟수는 0 이상이어야 합니다."), + INVALID_USAGE_COUNT(HttpStatus.BAD_REQUEST, "사용/충전 횟수는 1 이상이어야 합니다."), + INSUFFICIENT_AI_REPORT_BALANCE(HttpStatus.BAD_REQUEST, "남은 AI 리포트 사용 가능 횟수가 부족합니다."), + + // 기타 + UNSUPPORTED_CURRENCY(HttpStatus.BAD_REQUEST, "지원하지 않는 통화입니다."), + INVALID_USAGE_PRODUCT(HttpStatus.BAD_REQUEST, "유효하지 않은 이용권 금액입니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/order/exception/OrderException.java b/src/main/java/starlight/domain/order/exception/OrderException.java new file mode 100644 index 00000000..53d7cac8 --- /dev/null +++ b/src/main/java/starlight/domain/order/exception/OrderException.java @@ -0,0 +1,11 @@ +package starlight.domain.order.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class OrderException extends GlobalException { + + public OrderException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/order/order/Orders.java b/src/main/java/starlight/domain/order/order/Orders.java new file mode 100644 index 00000000..a6732db3 --- /dev/null +++ b/src/main/java/starlight/domain/order/order/Orders.java @@ -0,0 +1,161 @@ +package starlight.domain.order.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.enumerate.OrderStatus; +import starlight.domain.order.enumerate.UsageProductType; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.domain.order.order.vo.Money; +import starlight.domain.order.order.vo.OrderCode; +import starlight.shared.AbstractEntity; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Orders extends AbstractEntity { + + @Column(length = 64, nullable = false, unique = true) + private String orderCode; + + @Column(name = "buyer_user_id", nullable = false) + private Long buyerId; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private OrderStatus status = OrderStatus.NEW; + + @Column(length = 3, nullable = false) + private String currency = "KRW"; + + @Column(nullable = false) + private Long price; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List payments = new ArrayList<>(); + + @Version + private Long version; + + @Column(length = 50, nullable = false) + private String usageProductCode; // 어떤 상품코드로 샀는지 + + @Column(nullable = false) + private Integer usageCount; // 몇 회권인지 (1 / 2) + + public static Orders newUsageOrder(OrderCode orderCode, Long buyerId, Money money, UsageProductType product) { + Objects.requireNonNull(orderCode, "orderCode는 필수입니다."); + Objects.requireNonNull(buyerId, "buyerId는 필수입니다."); + Objects.requireNonNull(money, "money는 필수입니다."); + Objects.requireNonNull(product, "product는 필수입니다."); + + Orders orders = new Orders(); + orders.orderCode = orderCode.getValue(); + orders.buyerId = buyerId; + orders.price = money.getAmount(); + orders.currency = money.getCurrency(); + orders.usageProductCode = product.getCode(); + orders.usageCount = product.getUsageCount(); + orders.status = OrderStatus.NEW; + return orders; + } + + public void validateSameBuyer(Long buyerId) { + if (!Objects.equals(this.buyerId, buyerId)) { + throw new OrderException(OrderErrorType.ORDER_CODE_BUYER_MISMATCH); + } + } + + public void validateSameProduct(UsageProductType product) { + if (!Objects.equals(this.usageProductCode, product.getCode())) { + throw new OrderException(OrderErrorType.ORDER_PRODUCT_MISMATCH); + } + } + + /** + * 결제 시도 추가 + * - 이미 결제 완료된 주문이면 예외 + * - 주문 금액과 다른 금액이면 예외 + */ + public void addPaymentAttempt(Money paymentMoney) { + // 이미 결제 완료된 주문이면 시도 불가 + if (this.status == OrderStatus.PAID) { + throw new OrderException(OrderErrorType.ALREADY_PAID); + } + + // 주문 금액과 결제 금액이 일치해야 함 + Money orderMoney = Money.of(this.price, this.currency); + if (!orderMoney.equals(paymentMoney)) { + throw new OrderException(OrderErrorType.PAYMENT_AMOUNT_MISMATCH); + } + + // 결제 시도 생성 및 추가 + PaymentRecords p = PaymentRecords.requestedFor(this, paymentMoney.getAmount()); + this.payments.add(p); + } + + /** + * 결제 승인 처리 + * - NEW 상태에서만 가능 + */ + public void markPaid() { + if (this.status == OrderStatus.PAID) { + throw new OrderException(OrderErrorType.ALREADY_PAID); + } + if (this.status != OrderStatus.NEW) { + throw new OrderException(OrderErrorType.INVALID_ORDER_STATE_FOR_PAYMENT); + } + this.status = OrderStatus.PAID; + } + + /** + * 주문/결제 취소 + * - PAID 상태에서만 가능 + */ + public void cancel() { + if (this.status != OrderStatus.PAID) { + throw new OrderException(OrderErrorType.INVALID_ORDER_STATE_FOR_CANCEL); + } + this.status = OrderStatus.CANCELED; + } + + /** + * 가장 최근 REQUESTED 상태 결제 시도 조회 + */ + public PaymentRecords getLatestRequestedOrThrow() { + return payments.stream() + .filter(p -> "REQUESTED".equals(p.getStatus())) + .max(Comparator.comparing(PaymentRecords::getCreatedAt)) + .orElseThrow(() -> new OrderException(OrderErrorType.NO_REQUESTED_PAYMENT)); + } + + /** + * 가장 최근 DONE 상태 결제 시도 조회 + */ + public PaymentRecords getLatestDoneOrThrow() { + return payments.stream() + .filter(p -> "DONE".equals(p.getStatus())) + .max(Comparator.comparing(p -> + p.getApprovedAt() != null ? p.getApprovedAt() : p.getCreatedAt() + )) + .orElseThrow(() -> new OrderException(OrderErrorType.NO_PAYMENT_RECORDS)); + } + + /** + * 가장 최근 결제 이력 조회 (승인시간 기준) + */ + public PaymentRecords getLatestPaymentOrThrow() { + return payments.stream() + .max(Comparator.comparing(p -> + p.getApprovedAt() != null ? p.getApprovedAt() : p.getCreatedAt() + )) + .orElseThrow(() -> new OrderException(OrderErrorType.NO_DONE_PAYMENT)); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/order/order/PaymentRecords.java b/src/main/java/starlight/domain/order/order/PaymentRecords.java new file mode 100644 index 00000000..30e00793 --- /dev/null +++ b/src/main/java/starlight/domain/order/order/PaymentRecords.java @@ -0,0 +1,125 @@ +package starlight.domain.order.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +import java.time.Instant; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentRecords { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Orders order; + + @Column(length = 20, nullable = false) + private String pg; + + @Column(length = 128, unique = true) + private String paymentKey; + + @Column(length = 40) + private String method; + + @Column(length = 40) + private String provider; + + @Column(nullable = false) + private Long price; + + @Column(length = 20, nullable = false) + private String status; + + @Column(length = 255) + private String receiptUrl; + + private Instant approvedAt; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void prePersist() { + createdAt = Instant.now(); + if (status == null) status = "REQUESTED"; + if (pg == null) pg = "TOSS"; + } + + /** + * 결제 요청 생성 + * @param order 연관된 주문 + * @param amount 결제 금액 + * @return 생성된 PaymentRecords + */ + public static PaymentRecords requestedFor(Orders order, Long amount) { + Objects.requireNonNull(order, "order는 필수입니다."); + Objects.requireNonNull(amount, "amount는 필수입니다."); + + PaymentRecords payment = new PaymentRecords(); + payment.order = order; + payment.pg = "TOSS"; + payment.status = "REQUESTED"; + payment.price = amount; + + return payment; + } + + /** + * 결제 승인 완료 처리 + */ + public void markDone(String paymentKey, String method, String provider, + String receiptUrl, Instant approvedAt) { + + validateForCompletion(paymentKey); + + this.paymentKey = paymentKey; + this.method = method; + this.provider = provider; + this.receiptUrl = receiptUrl; + this.approvedAt = approvedAt != null ? approvedAt : Instant.now(); + this.status = "DONE"; + } + + /** + * 결제 취소 처리 + */ + public void markCanceled() { + if (!"DONE".equals(this.status)) { + throw new IllegalStateException( + "DONE 상태에서만 취소 가능합니다. 현재 상태: " + this.status + ); + } + this.status = "CANCELED"; + } + + /** + * 결제키가 없으면 PG 취소 불가능 + */ + public void ensureHasPaymentKey() { + if (this.paymentKey == null || this.paymentKey.trim().isEmpty()) { + throw new OrderException(OrderErrorType.NO_PAYMENT_KEY); + } + } + + private void validateForCompletion(String paymentKey) { + if (!"REQUESTED".equals(this.status)) { + throw new IllegalStateException( + "REQUESTED 상태에서만 승인 가능합니다. 현재 상태: " + this.status + ); + } + if (paymentKey == null || paymentKey.trim().isEmpty()) { + throw new IllegalArgumentException("paymentKey는 필수입니다."); + } + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/order/order/vo/Money.java b/src/main/java/starlight/domain/order/order/vo/Money.java new file mode 100644 index 00000000..97de8cdd --- /dev/null +++ b/src/main/java/starlight/domain/order/order/vo/Money.java @@ -0,0 +1,70 @@ +package starlight.domain.order.order.vo; + +import java.util.Objects; + +/** + * 금액을 표현하는 값 객체 + * 불변 객체로 통화와 금액을 함께 관리 + */ +public class Money { + + private final Long amount; + private final String currency; + + private Money(Long amount, String currency) { + if (amount == null || amount < 0) { + throw new IllegalArgumentException("금액은 0 이상이어야 합니다."); + } + if (currency == null || currency.trim().isEmpty()) { + throw new IllegalArgumentException("통화는 필수입니다."); + } + this.amount = amount; + this.currency = currency.toUpperCase(); + } + + public static Money of(Long amount, String currency) { + return new Money(amount, currency); + } + + public static Money krw(Long amount) { + return new Money(amount, "KRW"); + } + + public boolean isSameCurrency(Money other) { + return other != null && this.currency.equals(other.currency); + } + + public boolean equalsInSameCurrency(Money other) { + if (!isSameCurrency(other)) { + throw new IllegalArgumentException("다른 통화는 비교할 수 없습니다."); + } + return Objects.equals(this.amount, other.amount); + } + + public Long getAmount() { + return amount; + } + + public String getCurrency() { + return currency; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money)) return false; + Money money = (Money) o; + return Objects.equals(amount, money.amount) && + Objects.equals(currency, money.currency); + } + + @Override + public String toString() { + return currency + " " + amount; + } + + @Override + public int hashCode() { + return Objects.hash(amount, currency); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/order/order/vo/OrderCode.java b/src/main/java/starlight/domain/order/order/vo/OrderCode.java new file mode 100644 index 00000000..40e0e308 --- /dev/null +++ b/src/main/java/starlight/domain/order/order/vo/OrderCode.java @@ -0,0 +1,58 @@ +package starlight.domain.order.order.vo; + +import java.util.Objects; + +/** + * 주문번호를 표현하는 값 객체 + * 프론트엔드에서 생성한 주문번호를 검증하고 캡슐화 + */ +public class OrderCode { + + private final String value; + + private OrderCode(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("주문번호는 필수입니다."); + } + if (value.length() > 64) { + throw new IllegalArgumentException("주문번호는 64자를 초과할 수 없습니다."); + } + if (!value.matches("^[a-zA-Z0-9_-]+$")) { + throw new IllegalArgumentException("주문번호는 영문, 숫자, -, _ 만 허용됩니다."); + } + } + + /** + * 프론트엔드에서 전달받은 주문번호로 값 객체 생성 + */ + public static OrderCode of(String value) { + return new OrderCode(value); + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof OrderCode)) return false; + OrderCode orderCode = (OrderCode) o; + return Objects.equals(value, orderCode.value); + } + + @Override + public String toString() { + return value; + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/src/main/java/starlight/domain/order/wallet/UsageHistory.java b/src/main/java/starlight/domain/order/wallet/UsageHistory.java new file mode 100644 index 00000000..670e7f67 --- /dev/null +++ b/src/main/java/starlight/domain/order/wallet/UsageHistory.java @@ -0,0 +1,91 @@ +package starlight.domain.order.wallet; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; + +import java.time.Instant; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageHistory { + + private static final String TYPE_CHARGE = "CHARGE"; + private static final String TYPE_USE = "USE"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; // 누가 + + @Column + private Long businessPlanId; // 어떤 사업계획서에서 (사용 시에만 채움, 충전은 null 가능) + + @Column(nullable = false) + private String type; // CHARGE / USE 등 + + @Column(nullable = false) + private Integer amount; // 이번에 증감된 횟수 (충전: +10, 사용: -1 이런 느낌) + + @Column(nullable = false) + private Integer balanceAfter; // 이 이벤트 이후 지갑 잔여 횟수 + + @Column + private Long orderId; // 이 충전이 어느 주문에서 온 건지 대략 연결하고 싶으면 + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = Instant.now(); + } + + /** + * 충전 이력 기록 + */ + public static UsageHistory charged(Long userId, int amount, int balanceAfter, Long orderId) { + Objects.requireNonNull(userId, "userId는 필수입니다."); + + if (amount <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + + UsageHistory history = new UsageHistory(); + history.userId = userId; + history.type = TYPE_CHARGE; + history.amount = amount; // 충전은 양수 그대로 + history.balanceAfter = balanceAfter; + history.orderId = orderId; // 없으면 null + + return history; + } + + /** + * 사용 이력 기록 + */ + public static UsageHistory used(Long userId, Long businessPlanId, int amount, int balanceAfter) { + Objects.requireNonNull(userId, "userId는 필수입니다."); + Objects.requireNonNull(businessPlanId, "businessPlanId는 필수입니다."); + + if (amount <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + + UsageHistory history = new UsageHistory(); + history.userId = userId; + history.businessPlanId = businessPlanId; + history.type = TYPE_USE; + history.amount = -Math.abs(amount); // 사용은 항상 음수로 저장 + history.balanceAfter = balanceAfter; + + return history; + } +} diff --git a/src/main/java/starlight/domain/order/wallet/UsageWallet.java b/src/main/java/starlight/domain/order/wallet/UsageWallet.java new file mode 100644 index 00000000..e86a6b1d --- /dev/null +++ b/src/main/java/starlight/domain/order/wallet/UsageWallet.java @@ -0,0 +1,72 @@ +package starlight.domain.order.wallet; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.domain.order.exception.OrderErrorType; +import starlight.domain.order.exception.OrderException; +import starlight.shared.AbstractEntity; + +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageWallet extends AbstractEntity { + + @Column(nullable = false, unique = true) + private Long userId; + + @Column(nullable = false) + private Integer aiReportChargedCount; // AI 리포트: 지금까지 충전된 총 횟수 + + @Column(nullable = false) + private Integer aiReportUsedCount; // AI 리포트: 지금까지 사용된 총 횟수 + + @Version + private Long version; + + /** + * 지갑 초기화 + */ + public static UsageWallet init(Long userId) { + UsageWallet wallet = new UsageWallet(); + wallet.userId = Objects.requireNonNull(userId, "userId는 필수입니다."); + wallet.aiReportChargedCount = 0; + wallet.aiReportUsedCount = 0; + return wallet; + } + + /** + * AI 리포트 횟수 충전 + */ + public void chargeAiReport(int count) { + if (count <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + this.aiReportChargedCount += count; + } + + /** + * 남은 AI 리포트 사용 가능 횟수 + */ + public int getAiReportRemainingCount() { + return aiReportChargedCount - aiReportUsedCount; + } + + /** + * AI 리포트 사용 + */ + public void useAiReport(int count) { + if (count <= 0) { + throw new OrderException(OrderErrorType.INVALID_USAGE_COUNT); + } + if (getAiReportRemainingCount() < count) { + throw new OrderException(OrderErrorType.INSUFFICIENT_AI_REPORT_BALANCE); + } + this.aiReportUsedCount += count; + } +} diff --git a/src/main/java/starlight/shared/AbstractEntity.java b/src/main/java/starlight/shared/AbstractEntity.java new file mode 100644 index 00000000..d89a3ff0 --- /dev/null +++ b/src/main/java/starlight/shared/AbstractEntity.java @@ -0,0 +1,16 @@ +package starlight.shared; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@MappedSuperclass +public abstract class AbstractEntity extends BaseEntity { + + @Id + @Getter + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; +} diff --git a/src/main/java/starlight/shared/BaseEntity.java b/src/main/java/starlight/shared/BaseEntity.java new file mode 100644 index 00000000..329be9ff --- /dev/null +++ b/src/main/java/starlight/shared/BaseEntity.java @@ -0,0 +1,28 @@ +package starlight.shared; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "modified_at", nullable = true, updatable = true) + private LocalDateTime modifiedAt; + + @Column(name = "is_deleted", length = 1, nullable = false) + private Boolean isDeleted = false; +} \ No newline at end of file diff --git a/src/main/java/starlight/shared/apiPayload/ApiControllerAdvice.java b/src/main/java/starlight/shared/apiPayload/ApiControllerAdvice.java new file mode 100644 index 00000000..428951d0 --- /dev/null +++ b/src/main/java/starlight/shared/apiPayload/ApiControllerAdvice.java @@ -0,0 +1,63 @@ +package starlight.shared.apiPayload; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.exception.GlobalException; +import starlight.shared.apiPayload.response.ApiResponse; + +@Slf4j +@RestControllerAdvice +public class ApiControllerAdvice { + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + log.error("Exception : {}", e.getMessage(), e); + return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INTERNAL_ERROR), GlobalErrorType.INTERNAL_ERROR.getStatus()); + } + +// @ExceptionHandler(MethodArgumentNotValidException.class) +// public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { +// log.error("MethodArgumentNotValidException : {}", e.getMessage(), e); +// return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.FAILED_REQUEST_VALIDATION), GlobalErrorType.FAILED_REQUEST_VALIDATION.getStatus()); +// } + @ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException.class) + public ResponseEntity> handleBadJson(org.springframework.http.converter.HttpMessageNotReadableException e) { + return new ResponseEntity<>( + ApiResponse.error(GlobalErrorType.INVALID_REQUEST_ARGUMENT, "잘못된 JSON 형식입니다."), + GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus() + ); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) + .findFirst() + .orElse("유효하지 않은 요청 파라미터입니다."); + + return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INVALID_REQUEST_ARGUMENT, message), GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + log.error("IllegalArgumentException : {}", e.getMessage(), e); + return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INVALID_REQUEST_ARGUMENT), GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus()); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingServletRequestParameter(MissingServletRequestParameterException e) { + return new ResponseEntity<>(ApiResponse.error(GlobalErrorType.INVALID_REQUEST_ARGUMENT), GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus() + ); + } + + @ExceptionHandler(GlobalException.class) + public ResponseEntity> handleGlobalException(GlobalException e) { + log.error("CoreException : {}", e.getMessage(), e); + return new ResponseEntity<>(ApiResponse.error(e.getErrorType()), e.getErrorType().getStatus()); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/shared/apiPayload/exception/ErrorMessage.java b/src/main/java/starlight/shared/apiPayload/exception/ErrorMessage.java new file mode 100644 index 00000000..d17bb59f --- /dev/null +++ b/src/main/java/starlight/shared/apiPayload/exception/ErrorMessage.java @@ -0,0 +1,21 @@ +package starlight.shared.apiPayload.exception; + +import lombok.Getter; + +@Getter +public class ErrorMessage { + + private final String code; + + private final String message; + + public ErrorMessage(ErrorType errorType) { + this.code = errorType.name(); + this.message = errorType.getMessage(); + } + + public ErrorMessage(ErrorType errorType, String customMessage) { + this.code = errorType.name(); + this.message = customMessage; + } +} diff --git a/src/main/java/starlight/shared/apiPayload/exception/ErrorType.java b/src/main/java/starlight/shared/apiPayload/exception/ErrorType.java new file mode 100644 index 00000000..4de45946 --- /dev/null +++ b/src/main/java/starlight/shared/apiPayload/exception/ErrorType.java @@ -0,0 +1,12 @@ +package starlight.shared.apiPayload.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorType { + + String name(); + + HttpStatus getStatus(); + + String getMessage(); +} diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalErrorType.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalErrorType.java new file mode 100644 index 00000000..4ff677d9 --- /dev/null +++ b/src/main/java/starlight/shared/apiPayload/exception/GlobalErrorType.java @@ -0,0 +1,34 @@ +package starlight.shared.apiPayload.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum GlobalErrorType implements ErrorType { + + //Redis + REDIS_SET_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis에 값을 저장하는 데 실패했습니다."), + REDIS_GET_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis에서 값을 가져오는 데 실패했습니다."), + REDIS_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis에서 값을 삭제하는 데 실패했습니다."), + SHA256_GENERATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SHA256 해시 생성에 실패했습니다."), + + // JWT + FORBIDDEN(HttpStatus.FORBIDDEN, "접근이 금지되었습니다."), + JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "JSON 처리 중 오류가 발생했습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "토큰이 유효하지 않습니다."), + + // Common Errors + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 내부 오류입니다."), + FAILED_REQUEST_VALIDATION(HttpStatus.BAD_REQUEST, "요청 데이터 검증에 실패하였습니다."), + INVALID_REQUEST_ARGUMENT(HttpStatus.BAD_REQUEST, "잘못된 요청 인자입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증에 실패하였습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java new file mode 100644 index 00000000..1c7a8198 --- /dev/null +++ b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java @@ -0,0 +1,14 @@ +package starlight.shared.apiPayload.exception; + +import lombok.Getter; + +@Getter +public class GlobalException extends RuntimeException { + + private final ErrorType errorType; + + public GlobalException(ErrorType errorType) { + super(errorType.getMessage()); + this.errorType = errorType; + } +} \ No newline at end of file diff --git a/src/main/java/starlight/shared/apiPayload/response/ApiResponse.java b/src/main/java/starlight/shared/apiPayload/response/ApiResponse.java new file mode 100644 index 00000000..3ddd78a3 --- /dev/null +++ b/src/main/java/starlight/shared/apiPayload/response/ApiResponse.java @@ -0,0 +1,23 @@ +package starlight.shared.apiPayload.response; + +import starlight.shared.apiPayload.exception.ErrorMessage; +import starlight.shared.apiPayload.exception.ErrorType; + +public record ApiResponse(ResultType result, T data, ErrorMessage error) { + + public static ApiResponse success() { + return new ApiResponse<>(ResultType.SUCCESS, null, null); + } + + public static ApiResponse success(S data) { + return new ApiResponse<>(ResultType.SUCCESS, data, null); + } + + public static ApiResponse error(ErrorType error) { + return new ApiResponse<>(ResultType.ERROR, null, new ErrorMessage(error)); + } + + public static ApiResponse error(ErrorType error, String message) { + return new ApiResponse<>(ResultType.ERROR, null, new ErrorMessage(error, message)); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/shared/apiPayload/response/ResultType.java b/src/main/java/starlight/shared/apiPayload/response/ResultType.java new file mode 100644 index 00000000..f01b5a18 --- /dev/null +++ b/src/main/java/starlight/shared/apiPayload/response/ResultType.java @@ -0,0 +1,5 @@ +package starlight.shared.apiPayload.response; + +public enum ResultType { + SUCCESS, ERROR +} diff --git a/src/main/java/starlight/shared/dto/infrastructure/ClovaStudioResponse.java b/src/main/java/starlight/shared/dto/infrastructure/ClovaStudioResponse.java new file mode 100644 index 00000000..2f62abb3 --- /dev/null +++ b/src/main/java/starlight/shared/dto/infrastructure/ClovaStudioResponse.java @@ -0,0 +1,30 @@ +package starlight.shared.dto.infrastructure; + +public record ClovaStudioResponse( + Status status, + Result result +) { + public record Status( + int code, + String message + ) {} + + public record Result( + Message message, + String finishReason, + long created, + long seed, + Usage usage + ) {} + + public record Usage( + int promptTokens, + int completionTokens, + int totalTokens + ) {} + + public record Message( + String role, + String content + ) {} +} \ No newline at end of file diff --git a/src/main/java/starlight/shared/dto/infrastructure/OcrResponse.java b/src/main/java/starlight/shared/dto/infrastructure/OcrResponse.java new file mode 100644 index 00000000..96da4a3a --- /dev/null +++ b/src/main/java/starlight/shared/dto/infrastructure/OcrResponse.java @@ -0,0 +1,39 @@ +package starlight.shared.dto.infrastructure; + +import java.util.List; + +public record OcrResponse( + String version, + String requestId, + long timestamp, + List images +) { + public record ImageResult( + String uid, + String name, + String inferResult, + String message, + List fields + ) { + public record Field( + String valueType, + String inferText, + Double inferConfidence, + String type, + Boolean lineBreak + ) {} + } + + public static OcrResponse create(String version, String requestId, List images) { + return new OcrResponse( + version, + requestId, + System.currentTimeMillis(), + images == null ? List.of() : List.copyOf(images) + ); + } + + public static OcrResponse createEmpty() { + return create("V2", "empty", List.of()); + } +} diff --git a/src/main/java/starlight/shared/dto/infrastructure/PreSignedUrlResponse.java b/src/main/java/starlight/shared/dto/infrastructure/PreSignedUrlResponse.java new file mode 100644 index 00000000..64bcd2f9 --- /dev/null +++ b/src/main/java/starlight/shared/dto/infrastructure/PreSignedUrlResponse.java @@ -0,0 +1,17 @@ +package starlight.shared.dto.infrastructure; + +import lombok.Builder; + +@Builder +public record PreSignedUrlResponse ( + String preSignedUrl, + + String objectUrl +){ + public static PreSignedUrlResponse of(String preSignedUrl, String objectUrl) { + return PreSignedUrlResponse.builder() + .preSignedUrl(preSignedUrl) + .objectUrl(objectUrl) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/shared/enumerate/SectionType.java b/src/main/java/starlight/shared/enumerate/SectionType.java new file mode 100644 index 00000000..6a4828dc --- /dev/null +++ b/src/main/java/starlight/shared/enumerate/SectionType.java @@ -0,0 +1,17 @@ +package starlight.shared.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SectionType { + + OVERVIEW("개요"), + PROBLEM_RECOGNITION("문제 인식"), + FEASIBILITY("실현 가능성"), + GROWTH_STRATEGY("성장 전략"), + TEAM_COMPETENCE("팀 역량"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/starlight/shared/valueobject/RawJson.java b/src/main/java/starlight/shared/valueobject/RawJson.java new file mode 100644 index 00000000..5b2b80de --- /dev/null +++ b/src/main/java/starlight/shared/valueobject/RawJson.java @@ -0,0 +1,71 @@ +package starlight.shared.valueobject; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +@Slf4j +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RawJson { + + @Column(columnDefinition = "TEXT", nullable = false) + private String value; + + // 스레드 세이프 : 정적 재사용 + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + + public static RawJson create(String raw) { + if (raw == null) { + throw new GlobalException(GlobalErrorType.JSON_PROCESSING_ERROR); + } + RawJson rawJson = new RawJson(); + rawJson.value = canonicalize(raw); + return rawJson; + } + + public static RawJson create(JsonNode node) { + if (node == null) { + throw new GlobalException(GlobalErrorType.JSON_PROCESSING_ERROR); + } + try { + RawJson rawJson = new RawJson(); + rawJson.value = MAPPER.writeValueAsString(node); // 이미 정규화된 문자열 + return rawJson; + } catch (Exception e) { + log.error("failed to serialize JsonNode", e); + throw new GlobalException(GlobalErrorType.JSON_PROCESSING_ERROR); + } + } + + private static String canonicalize(String raw) { + try { + JsonNode node = MAPPER.readTree(raw); + return MAPPER.writeValueAsString(node); // 공백/순서 정규화 + } catch (Exception e) { + log.error("failed to canonicalize raw_json", e); + throw new GlobalException(GlobalErrorType.JSON_PROCESSING_ERROR); + } + } + + public JsonNode asTree() { + try { + return MAPPER.readTree(value); + } catch (Exception e) { + log.error("failed to toTree json_raw", e); + throw new GlobalException(GlobalErrorType.JSON_PROCESSING_ERROR); + } + } +} + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 00000000..64b094a1 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,348 @@ + + + + + + StarLight API Server + + + + + +
+ +
+
+
+ +
+

+ + StarLight API Server +

+ + Checking… +
+

스타트업 생태계를 밝히는 StarLight의 API 허브. Swagger 문서와 헬스/인포를 한눈에.

+ + + +
+
+

실시간 상태

+
+ 응답시간 ms + 서버시간 --:--:-- + + Spring Boot + Java + Profiles default + Build +
+
+ + + +
+

cURL 한 줄 테스트

+
+ curl -s http://localhost:8080/actuator/health | jq + +
+
+
+ +
© StarLight · All systems go.
+
+
+ + + + diff --git a/src/main/resources/static/ops.html b/src/main/resources/static/ops.html new file mode 100644 index 00000000..439c5073 --- /dev/null +++ b/src/main/resources/static/ops.html @@ -0,0 +1,285 @@ + + + + + + StarLight · Ops Dashboard + + + + +
+ +
StarLight · Ops Dashboard
+
--:--:--
+
+ +
+
+
+

헬스 상태

+
+ Checking… + 응답시간 ms + 프로파일 + 빌드 + Java +
+ +
+ +
+

핵심 리소스 스파크라인

+
JVM 메모리 사용(bytes)
+ +
CPU(프로세스) 사용률
+ +
+ +
+

컴포넌트 상세

+ + + +
이름상태세부
+
+ +
+

Metric 조회

+
+ + +
+ + + +
이름태그
+

* 시계열 저장 없이 현재 스냅샷 값을 표시합니다. 주기적으로 폴링해 스파크라인에 반영합니다.

+
+
+
+ + + + diff --git a/src/main/resources/static/payment.html b/src/main/resources/static/payment.html new file mode 100644 index 00000000..b9050217 --- /dev/null +++ b/src/main/resources/static/payment.html @@ -0,0 +1,250 @@ + + + + + starlight 크레딧 결제하기 (Popup + postMessage) + + + + + + +
+

starlight 크레딧 결제하기

+

상품(이용권)을 선택하고, 팝업에서 결제 후 postMessage로 결과를 받습니다.

+ + +
+ + +
+ +
+
+ +
+ + + +
+ +
+
+ + + + diff --git a/src/main/resources/static/toss/popup.html b/src/main/resources/static/toss/popup.html new file mode 100644 index 00000000..4fa47efe --- /dev/null +++ b/src/main/resources/static/toss/popup.html @@ -0,0 +1,196 @@ + + + + + + 결제 처리 중... + + + +
+

결제 처리 중...

+
+

잠시만 기다려 주세요.

+
+ + + + diff --git a/src/main/resources/templates/feedback-request.html b/src/main/resources/templates/feedback-request.html new file mode 100644 index 00000000..19ea41b8 --- /dev/null +++ b/src/main/resources/templates/feedback-request.html @@ -0,0 +1,159 @@ + + + + + + 사업계획서 피드백 요청 + + + + + + + + +
+ + + + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java b/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java new file mode 100644 index 00000000..460483f4 --- /dev/null +++ b/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java @@ -0,0 +1,61 @@ +package starlight.adapter.ai; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.adapter.ai.infra.OpenAiGenerator; +import starlight.adapter.ai.util.ChecklistCatalog; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AiChecklistGraderTest { + + @Test + @DisplayName("criteria별 컨텍스트를 합치고 LLM 결과를 반환") + void check_returnsFromLlm() { + OpenAiGenerator generator = mock(OpenAiGenerator.class); + when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) + .thenReturn(List.of(true, false, true, false, true)); + + ChecklistCatalog catalog = mock(ChecklistCatalog.class); + when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) + .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); + when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) + .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); + + OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); + + List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); + assertThat(result).containsExactly(true, false, true, false, true); + verify(generator).generateChecklistArray( + eq(SubSectionType.OVERVIEW_BASIC), + eq("input text"), + eq(List.of("c1", "c2", "c3", "c4", "c5")), + eq(List.of("d1", "d2", "d3", "d4", "d5")) + ); + verify(catalog).getCriteriaBySubSectionType(SubSectionType.OVERVIEW_BASIC); + verify(catalog).getDetailedCriteriaBySubSectionType(SubSectionType.OVERVIEW_BASIC); + } + + @Test + @DisplayName("LLM 결과 길이가 5보다 짧으면 false로 패딩") + void check_normalizesToFive() { + OpenAiGenerator generator = mock(OpenAiGenerator.class); + when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) + .thenReturn(List.of(true)); + + ChecklistCatalog catalog = mock(ChecklistCatalog.class); + when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) + .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); + when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) + .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); + + OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); + List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); + assertThat(result).containsExactly(true, false, false, false, false); + } +} diff --git a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java new file mode 100644 index 00000000..c26b05d8 --- /dev/null +++ b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java @@ -0,0 +1,100 @@ +package starlight.adapter.ai; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.adapter.ai.infra.OpenAiGenerator; +import starlight.adapter.ai.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResponse; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("OpenAiReportGrader 테스트") +class OpenAiReportGraderTest { + + @Test + @DisplayName("컨텐츠를 채점하여 AiReportResponse를 반환한다") + void gradeContent_returnsAiReportResponse() { + // given + String content = "사업계획서 내용"; + String llmResponse = """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 25, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "sectionScores": [ + { + "sectionType": "PROBLEM_RECOGNITION", + "gradingListScores": "[{\\"item\\":\\"항목1\\",\\"score\\":5,\\"maxScore\\":5}]" + } + ], + "strengths": [ + {"title": "강점1", "content": "내용1"} + ], + "weaknesses": [ + {"title": "약점1", "content": "내용1"} + ] + } + """; + + OpenAiGenerator generator = mock(OpenAiGenerator.class); + when(generator.generateReport(content)).thenReturn(llmResponse); + + AiReportResponseParser parser = mock(AiReportResponseParser.class); + AiReportResponse expectedResponse = AiReportResponse.fromGradingResult( + 20, 25, 30, 20, + List.of(new AiReportResponse.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), + List.of(new AiReportResponse.StrengthWeakness("강점1", "내용1")), + List.of(new AiReportResponse.StrengthWeakness("약점1", "내용1")) + ); + when(parser.parse(llmResponse)).thenReturn(expectedResponse); + + OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); + + // when + AiReportResponse result = sut.gradeContent(content); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + + verify(generator).generateReport(content); + verify(parser).parse(llmResponse); + } + + @Test + @DisplayName("각 컴포넌트가 순서대로 호출된다") + void gradeContent_callsComponentsInOrder() { + // given + String content = "사업계획서 내용"; + String llmResponse = "{}"; + + OpenAiGenerator generator = mock(OpenAiGenerator.class); + when(generator.generateReport(any())).thenReturn(llmResponse); + + AiReportResponseParser parser = mock(AiReportResponseParser.class); + when(parser.parse(any())).thenReturn(AiReportResponse.fromGradingResult(0, 0, 0, 0, List.of(), List.of(), List.of())); + + OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); + + // when + sut.gradeContent(content); + + // then + var inOrder = inOrder(generator, parser); + inOrder.verify(generator).generateReport(content); + inOrder.verify(parser).parse(llmResponse); + } +} + diff --git a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java new file mode 100644 index 00000000..73858498 --- /dev/null +++ b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java @@ -0,0 +1,129 @@ +package starlight.adapter.ai.infra; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.Prompt; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; + +class OpenAiGeneratorTest { + + @Test + @DisplayName("올바른 JSON 배열을 파싱해 반환") + void generateChecklistArray_parsesJson() { + ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + when(builder.build()).thenReturn(chatClient); + + // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 + // 마지막 content()만 반환값 설정 + when(chatClient.prompt(any(Prompt.class)) + .options(any()) + .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) + .call() + .content()).thenReturn("[true,false,true,false,true]"); + + PromptProvider promptProvider = mock(PromptProvider.class); + when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) + .thenReturn(mock(Prompt.class)); + + AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + when(advisorProvider.getSimpleLoggerAdvisor()) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); + + OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); + + List result = sut.generateChecklistArray( + SubSectionType.OVERVIEW_BASIC, + "test content", + List.of("c1", "c2", "c3", "c4", "c5"), + List.of("d1", "d2", "d3", "d4", "d5") + ); + assertThat(result).containsExactly(true, false, true, false, true); + } + + @Test + @DisplayName("파싱 실패 시 보수적으로 모두 false 반환") + void generateChecklistArray_parseFail_returnsAllFalse() { + ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + when(builder.build()).thenReturn(chatClient); + + // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 + // 마지막 content()만 반환값 설정 + when(chatClient.prompt(any(Prompt.class)) + .options(any()) + .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) + .call() + .content()).thenReturn("not-json"); + + PromptProvider promptProvider = mock(PromptProvider.class); + when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) + .thenReturn(mock(Prompt.class)); + + AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + when(advisorProvider.getSimpleLoggerAdvisor()) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); + + OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); + + List result = sut.generateChecklistArray( + SubSectionType.OVERVIEW_BASIC, + "test content", + List.of("c1", "c2", "c3", "c4", "c5"), + List.of("d1", "d2", "d3", "d4", "d5") + ); + assertThat(result).containsExactly(false, false, false, false, false); + } + + @Test + @DisplayName("generateReport는 OpenAI 응답 문자열을 반환한다") + void generateReport_returnsString() { + ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); + ChatClient.Builder builder = mock(ChatClient.Builder.class); + when(builder.build()).thenReturn(chatClient); + + String expectedResponse = """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 25, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "sectionScores": [], + "strengths": [], + "weaknesses": [] + } + """.trim(); + + // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 + // 마지막 content()만 반환값 설정 + when(chatClient.prompt(any(Prompt.class)) + .options(any()) + .advisors(any(), any()) + .call() + .content()).thenReturn(expectedResponse); + + PromptProvider promptProvider = mock(PromptProvider.class); + when(promptProvider.createReportGradingPrompt(anyString())) + .thenReturn(mock(Prompt.class)); + + AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + when(advisorProvider.getQuestionAnswerAdvisor(anyDouble(), anyInt(), any())) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor.class)); + when(advisorProvider.getSimpleLoggerAdvisor()) + .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); + + OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); + + String result = sut.generateReport("test content"); + + assertThat(result).isEqualTo(expectedResponse); + } +} diff --git a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java b/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java new file mode 100644 index 00000000..1f5426d4 --- /dev/null +++ b/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java @@ -0,0 +1,151 @@ +package starlight.adapter.ai.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.domain.aireport.exception.AiReportException; +import starlight.domain.aireport.exception.AiReportErrorType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("AiReportResponseParser 테스트") +class AiReportResponseParserTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final AiReportResponseParser parser = new AiReportResponseParser(objectMapper); + + @Test + @DisplayName("유효한 JSON 응답을 파싱한다") + void parse_validJson_returnsResponse() { + // given + String validJson = """ + { + "problemRecognitionScore": 18, + "feasibilityScore": 28, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "strengths": [ + {"title": "강점1", "content": "내용1"} + ], + "weaknesses": [ + {"title": "약점1", "content": "내용1"} + ], + "sectionScores": [ + { + "sectionType": "PROBLEM_RECOGNITION", + "gradingListScores": "[{\\"item\\":\\"항목1\\",\\"score\\":5,\\"maxScore\\":5}]" + } + ] + } + """; + + // when + AiReportResponse result = parser.parse(validJson); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(18); + assertThat(result.feasibilityScore()).isEqualTo(28); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + } + + @Test + @DisplayName("null 응답 시 예외를 던진다") + void parse_nullResponse_throwsException() { + // when & then + assertThatThrownBy(() -> parser.parse(null)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("빈 문자열 응답 시 예외를 던진다") + void parse_emptyResponse_throwsException() { + // when & then + assertThatThrownBy(() -> parser.parse("")) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("필수 필드가 없는 응답 시 예외를 던진다") + void parse_missingRequiredFields_throwsException() { + // given + String invalidJson = """ + { + "strengths": [], + "weaknesses": [] + } + """; + + // when & then + assertThatThrownBy(() -> parser.parse(invalidJson)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("기본값(모두 0) 응답 시 예외를 던진다") + void parse_defaultResponse_throwsException() { + // given + String defaultJson = """ + { + "problemRecognitionScore": 0, + "feasibilityScore": 0, + "growthStrategyScore": 0, + "teamCompetenceScore": 0, + "strengths": [], + "weaknesses": [], + "sectionScores": [] + } + """; + + // when & then + assertThatThrownBy(() -> parser.parse(defaultJson)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + @Test + @DisplayName("text 필드가 있는 응답을 파싱한다") + void parse_textFieldResponse_parsesCorrectly() { + // given + String textFieldJson = """ + { + "text": "{\\"problemRecognitionScore\\": 18, \\"feasibilityScore\\": 28, \\"growthStrategyScore\\": 30, \\"teamCompetenceScore\\": 20, \\"strengths\\": [], \\"weaknesses\\": [], \\"sectionScores\\": []}" + } + """; + + // when + AiReportResponse result = parser.parse(textFieldJson); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(18); + assertThat(result.feasibilityScore()).isEqualTo(28); + } + + @Test + @DisplayName("잘못된 JSON 형식 시 예외를 던진다") + void parse_invalidJson_throwsException() { + // given + String invalidJson = "not a json"; + + // when & then + assertThatThrownBy(() -> parser.parse(invalidJson)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } +} + diff --git a/src/test/java/starlight/adapter/auth/redis/RedisKeyValueMapTest.java b/src/test/java/starlight/adapter/auth/redis/RedisKeyValueMapTest.java new file mode 100644 index 00000000..ed48b3e3 --- /dev/null +++ b/src/test/java/starlight/adapter/auth/redis/RedisKeyValueMapTest.java @@ -0,0 +1,175 @@ +package starlight.adapter.auth.redis; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import starlight.shared.apiPayload.exception.GlobalException; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RedisKeyValueMapTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private RedisKeyValueMap redisKeyValueMap; + + @Test + @DisplayName("setValue 성공") + void setValue_Success() { + // given + String key = "testKey"; + String value = "testValue"; + Long timeout = 3600000L; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // when + redisKeyValueMap.setValue(key, value, timeout); + + // then + verify(valueOperations).set(eq(key), eq(value), eq(Duration.ofMillis(timeout))); + } + + @Test + @DisplayName("setValue 실패 - Redis 에러") + void setValue_Fail() { + // given + String key = "testKey"; + String value = "testValue"; + Long timeout = 3600000L; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + doThrow(new RuntimeException()).when(valueOperations).set(any(), any(), any(Duration.class)); + + // when & then + assertThatThrownBy(() -> redisKeyValueMap.setValue(key, value, timeout)) + .isInstanceOf(GlobalException.class); + } + + @Test + @DisplayName("getValue 성공") + void getValue_Success() { + // given + String key = "testKey"; + String expectedValue = "testValue"; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(key)).willReturn(expectedValue); + + // when + String result = redisKeyValueMap.getValue(key); + + // then + assertThat(result).isEqualTo(expectedValue); + } + + @Test + @DisplayName("getValue 실패 - 값이 없는 경우") + void getValue_ReturnEmptyString() { + // given + String key = "testKey"; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(key)).willReturn(null); + + // when + String result = redisKeyValueMap.getValue(key); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("getValue 실패 - Redis 에러") + void getValue_Fail() { + // given + String key = "testKey"; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get(key)).willThrow(new RuntimeException()); + + // when & then + assertThatThrownBy(() -> redisKeyValueMap.getValue(key)) + .isInstanceOf(GlobalException.class); + } + + @Test + @DisplayName("deleteValue 성공") + void deleteValue_Success() { + // given + String key = "testKey"; + given(redisTemplate.delete(key)).willReturn(true); + + // when + redisKeyValueMap.deleteValue(key); + + // then + verify(redisTemplate).delete(key); + } + + @Test + @DisplayName("deleteValue 실패 - Redis 에러") + void deleteValue_Fail() { + // given + String key = "testKey"; + doThrow(new RuntimeException()).when(redisTemplate).delete(key); + + // when & then + assertThatThrownBy(() -> redisKeyValueMap.deleteValue(key)) + .isInstanceOf(GlobalException.class); + } + + @Test + @DisplayName("checkExistsValue 성공 - 존재하는 경우") + void checkExistsValue_True() { + // given + String key = "testKey"; + given(redisTemplate.hasKey(key)).willReturn(true); + + // when + boolean result = redisKeyValueMap.checkExistsValue(key); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("checkExistsValue 성공 - 존재하지 않는 경우") + void checkExistsValue_False() { + // given + String key = "testKey"; + given(redisTemplate.hasKey(key)).willReturn(false); + + // when + boolean result = redisKeyValueMap.checkExistsValue(key); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("checkExistsValue 실패 - Redis 에러") + void checkExistsValue_Fail() { + // given + String key = "testKey"; + given(redisTemplate.hasKey(key)).willThrow(new RuntimeException()); + + // when & then + assertThatThrownBy(() -> redisKeyValueMap.checkExistsValue(key)) + .isInstanceOf(GlobalException.class); + } +} diff --git a/src/test/java/starlight/adapter/auth/security/filter/ExceptionFilterUnitTest.java b/src/test/java/starlight/adapter/auth/security/filter/ExceptionFilterUnitTest.java new file mode 100644 index 00000000..54a422db --- /dev/null +++ b/src/test/java/starlight/adapter/auth/security/filter/ExceptionFilterUnitTest.java @@ -0,0 +1,86 @@ +package starlight.adapter.auth.security.filter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class ExceptionFilterUnitTest { + + private final ObjectMapper om = new ObjectMapper(); + private final ExceptionFilter filter = new ExceptionFilter(om); + + @Test + void wraps_GlobalException_to_Json_and_sets_status_400() throws Exception { + // given + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/any"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + FilterChain chain = (request, response) -> { + throw new GlobalException(GlobalErrorType.BAD_REQUEST); // 400 기대 + }; + + // when + filter.doFilter(req, res, chain); + + // then + assertThat(res.getStatus()).isEqualTo(400); + assertThat(res.getContentType()).isEqualTo("application/json;charset=UTF-8"); + + JsonNode body = om.readTree(res.getContentAsString()); + assertThat(body.path("result").asText()).isEqualTo("ERROR"); + assertThat(body.path("data").isNull()).isTrue(); + assertThat(body.at("/error/code").asText()).isEqualTo("BAD_REQUEST"); + assertThat(body.at("/error/message").asText()).contains("잘못된 요청"); + } + + @Test + void passes_through_when_no_exception() throws Exception { + // given + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/ok"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + FilterChain chain = (request, response) -> { + // 정상 흐름: 예외 없음 + response.getWriter().write("OK"); + }; + + // when + filter.doFilter(req, res, chain); + + // then + assertThat(res.getStatus()).isEqualTo(200); + assertThat(res.getContentType()).isNull(); // 체인이 안 정하면 null일 수 있음 + assertThat(res.getContentAsString()).isEqualTo("OK"); + } + + @Test + void does_nothing_if_response_already_committed() throws Exception { + // given + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/committed"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + FilterChain chain = (request, response) -> { + response.getWriter().write("partial"); + response.flushBuffer(); // 응답 커밋 + throw new GlobalException(GlobalErrorType.BAD_REQUEST); // 이후 필터는 건드리면 안 됨 + }; + + // when + filter.doFilter(req, res, chain); + + // then + assertThat(res.isCommitted()).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + assertThat(res.getContentAsString()).isEqualTo("partial"); + } +} diff --git a/src/test/java/starlight/adapter/auth/security/jwt/JwtTokenProviderTest.java b/src/test/java/starlight/adapter/auth/security/jwt/JwtTokenProviderTest.java new file mode 100644 index 00000000..486e9954 --- /dev/null +++ b/src/test/java/starlight/adapter/auth/security/jwt/JwtTokenProviderTest.java @@ -0,0 +1,267 @@ +package starlight.adapter.auth.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.application.auth.required.KeyValueMap; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; +import starlight.shared.apiPayload.exception.GlobalException; + +import java.security.Key; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class JwtTokenProviderTest { + + @Mock + private KeyValueMap redisClient; + + @Mock + private HttpServletRequest request; + + @InjectMocks + private JwtTokenProvider jwtTokenProvider; + + private Member member; + private Key key; + private final String secretKey = "dGVzdC1zZWNyZXQta2V5LWZvci1qd3QtdG9rZW4tZ2VuZXJhdGlvbi1hbmQtdmFsaWRhdGlvbg=="; + private final long accessTokenExpirationTime = 3600000L; // 1시간 + private final long refreshTokenExpirationTime = 86400000L; // 24시간 + + @BeforeEach + void setUp() { + member = Member.create("정성호","test@example.com", "010-2112-9765", MemberType.FOUNDER, null, "1234.png"); + + byte[] secretKeyBytes = Decoders.BASE64.decode(secretKey); + key = Keys.hmacShaKeyFor(secretKeyBytes); + + ReflectionTestUtils.setField(jwtTokenProvider, "secretKey", secretKey); + ReflectionTestUtils.setField(jwtTokenProvider, "accessTokenExpirationTime", accessTokenExpirationTime); + ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenExpirationTime", refreshTokenExpirationTime); + ReflectionTestUtils.setField(jwtTokenProvider, "key", key); + } + + @Test + @DisplayName("AccessToken 생성 성공") + void createAccessToken_Success() { + // when + String accessToken = jwtTokenProvider.createAccessToken(member); + + // then + assertThat(accessToken).isNotNull(); + assertThat(jwtTokenProvider.getEmail(accessToken)).isEqualTo(member.getEmail()); + assertThat(jwtTokenProvider.validateToken(accessToken)).isTrue(); + } + + @Test + @DisplayName("AccessToken과 RefreshToken 생성 성공") + void createToken_Success() { + // when + TokenResponse tokenResponse = jwtTokenProvider.createToken(member); + + // then + assertThat(tokenResponse).isNotNull(); + assertThat(tokenResponse.accessToken()).isNotNull(); + assertThat(tokenResponse.refreshToken()).isNotNull(); + assertThat(jwtTokenProvider.validateToken(tokenResponse.accessToken())).isTrue(); + assertThat(jwtTokenProvider.validateToken(tokenResponse.refreshToken())).isTrue(); + } + + @Test + @DisplayName("토큰 재발급 성공 - RefreshToken 유효기간이 충분한 경우") + void recreate_Success_WithValidRefreshToken() { + // given + TokenResponse originalToken = jwtTokenProvider.createToken(member); + + // when + TokenResponse newToken = jwtTokenProvider.recreate(member, originalToken.refreshToken()); + + // then + assertThat(newToken).isNotNull(); + assertThat(newToken.accessToken()).isNotNull(); + assertThat(jwtTokenProvider.validateToken(newToken.accessToken())).isTrue(); + assertThat(newToken.refreshToken()).isEqualTo(originalToken.refreshToken()); + assertThat(jwtTokenProvider.getEmail(newToken.accessToken())).isEqualTo(member.getEmail()); + } + + @Test + @DisplayName("토큰 재발급 성공 - RefreshToken 재발급 필요한 경우") + void recreate_Success_WithExpiredRefreshToken() { + // given + Claims claims = Jwts.claims().setSubject(member.getEmail()); + Date now = new Date(); + String expiredRefreshToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + 1000L)) // 1초 + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // when + TokenResponse newToken = jwtTokenProvider.recreate(member, expiredRefreshToken); + + // then + assertThat(newToken).isNotNull(); + assertThat(newToken.accessToken()).isNotNull(); + assertThat(newToken.refreshToken()).isNotEqualTo(expiredRefreshToken); + } + + @Test + @DisplayName("토큰 유효성 검사 성공") + void validateToken_Success() { + // given + String accessToken = jwtTokenProvider.createAccessToken(member); + + // when + boolean isValid = jwtTokenProvider.validateToken(accessToken); + + // then + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("토큰 유효성 검사 실패 - 만료된 토큰") + void validateToken_Fail_ExpiredToken() { + // given + Claims claims = Jwts.claims().setSubject(member.getEmail()); + Date now = new Date(); + String expiredToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date(now.getTime() - 2000L)) + .setExpiration(new Date(now.getTime() - 1000L)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // when + boolean isValid = jwtTokenProvider.validateToken(expiredToken); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("토큰 유효성 검사 실패 - 잘못된 토큰") + void validateToken_Fail_InvalidToken() { + // given + String invalidToken = "invalid.token.string"; + + // when + boolean isValid = jwtTokenProvider.validateToken(invalidToken); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("토큰에서 이메일 추출 성공") + void getEmail_Success() { + // given + String accessToken = jwtTokenProvider.createAccessToken(member); + + // when + String email = jwtTokenProvider.getEmail(accessToken); + + // then + assertThat(email).isEqualTo(member.getEmail()); + } + + @Test + @DisplayName("토큰 만료 시간 가져오기 성공") + void getExpirationTime_Success() { + // given + String accessToken = jwtTokenProvider.createAccessToken(member); + + // when + Long expirationTime = jwtTokenProvider.getExpirationTime(accessToken); + + // then + assertThat(expirationTime).isGreaterThan(System.currentTimeMillis()); + } + + @Test + @DisplayName("RefreshToken 추출 성공") + void resolveRefreshToken_Success() { + // given + String token = "testToken"; + given(request.getHeader("Authorization")).willReturn("Bearer " + token); + + // when + String resolvedToken = jwtTokenProvider.resolveRefreshToken(request); + + // then + assertThat(resolvedToken).isEqualTo(token); + } + + @Test + @DisplayName("RefreshToken 추출 실패 - Authorization 헤더 없음") + void resolveRefreshToken_Fail_NoHeader() { + // given + given(request.getHeader("Authorization")).willReturn(null); + + // when + String resolvedToken = jwtTokenProvider.resolveRefreshToken(request); + + // then + assertThat(resolvedToken).isNull(); + } + + @Test + @DisplayName("AccessToken 추출 성공") + void resolveAccessToken_Success() { + // given + String token = "testToken"; + given(request.getHeader("Authorization")).willReturn("Bearer " + token); + + // when + String resolvedToken = jwtTokenProvider.resolveAccessToken(request); + + // then + assertThat(resolvedToken).isEqualTo(token); + } + + @Test + @DisplayName("토큰 무효화 성공") + void invalidateTokens_Success() { + // given + TokenResponse tokenResponse = jwtTokenProvider.createToken(member); + + // when + jwtTokenProvider.invalidateTokens(tokenResponse.refreshToken(), tokenResponse.accessToken()); + + // then + verify(redisClient).deleteValue(eq(member.getEmail())); + verify(redisClient).setValue(eq(tokenResponse.accessToken()), eq("logout"), anyLong()); + } + + @Test + @DisplayName("토큰 무효화 실패 - 유효하지 않은 RefreshToken") + void invalidateTokens_Fail_InvalidRefreshToken() { + // given + String invalidRefreshToken = "invalid.refresh.token"; + String accessToken = jwtTokenProvider.createAccessToken(member); + + // when & then + assertThatThrownBy(() -> jwtTokenProvider.invalidateTokens(invalidRefreshToken, accessToken)) + .isInstanceOf(GlobalException.class); + } +} diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java b/src/test/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java new file mode 100644 index 00000000..94bd5f21 --- /dev/null +++ b/src/test/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java @@ -0,0 +1,137 @@ +package starlight.adapter.auth.security.oauth2; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.test.util.ReflectionTestUtils; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.member.persistence.MemberRepository; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class CustomOAuth2UserServiceUnitTest { + + @Mock MemberRepository memberRepository; + @Mock OAuth2UserService delegate; + + @InjectMocks CustomOAuth2UserService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new CustomOAuth2UserService(memberRepository); + ReflectionTestUtils.setField(sut, "delegate", delegate); + } + + private ClientRegistration naverRegistration() { + return ClientRegistration.withRegistrationId("naver") + .clientId("test-id") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://example/authorize") + .tokenUri("https://example/token") + .userInfoUri("https://example/me") + .userNameAttributeName("response") + .build(); + } + + private OAuth2UserRequest naverRequest() { + return new OAuth2UserRequest( + naverRegistration(), + new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "access-token", + Instant.now(), Instant.now().plusSeconds(3600) + ) + ); + } + + private OAuth2UserRequest naverReq = naverRequest(); + + private DefaultOAuth2User naverUser(String id, String email, String name) { + Map resp = new HashMap<>(); + resp.put("id", id); + if (email != null) resp.put("email", email); + resp.put("name", name); + Map attrs = Map.of("response", resp); + + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + attrs, + "response" + ); + } + + @Test + void when_providerId_exists_then_load_existing_member() { + var oau = naverUser("nid-1", "a@b.com", "홍길동"); + when(delegate.loadUser(any())).thenReturn(oau); + + var existing = Member.newSocial("홍길동", "a@b.com", "naver", "nid-1", null, MemberType.FOUNDER, "1234.png"); + when(memberRepository.findByProviderAndProviderId("naver", "nid-1")) + .thenReturn(Optional.of(existing)); + + var result = sut.loadUser(naverReq); + + verify(memberRepository, never()).save(any()); + assertThat(result).isInstanceOf(AuthDetails.class); + var details = (AuthDetails) result; + assertThat(details.member().getProvider()).isEqualTo("naver"); + assertThat(details.member().getProviderId()).isEqualTo("nid-1"); + } + + @Test + void when_not_found_by_providerId_but_email_matches_then_bind_email() { + var oau = naverUser("nid-2", "c@d.com", "아무개"); + when(delegate.loadUser(any())).thenReturn(oau); + + when(memberRepository.findByProviderAndProviderId("naver", "nid-2")) + .thenReturn(Optional.empty()); + + var byEmail = Member.newSocial("기존이름", "c@d.com", "kakao", "kid-9", null, MemberType.FOUNDER, "1234.png"); + when(memberRepository.findByEmail("c@d.com")).thenReturn(Optional.of(byEmail)); + + var result = sut.loadUser(naverReq); + + verify(memberRepository, never()).save(any()); + var details = (AuthDetails) result; + // 정책에 따라: 기존 계정에 naver 연결 or 그냥 로그인만 + assertThat(details.member().getEmail()).isEqualTo("c@d.com"); + } + + @Test + void when_no_match_then_create_new_member() { + var oau = naverUser("nid-3", null, "신규유저"); // 이메일 동의 안 한 케이스 + when(delegate.loadUser(any())).thenReturn(oau); + + when(memberRepository.findByProviderAndProviderId("naver", "nid-3")) + .thenReturn(Optional.empty()); + + var saved = Member.newSocial("신규유저", null, "naver", "nid-3", null, MemberType.FOUNDER, "1234.png"); + when(memberRepository.save(any(Member.class))).thenReturn(saved); + + var result = sut.loadUser(naverReq); + + verify(memberRepository).save(any(Member.class)); + var details = (AuthDetails) result; + assertThat(details.member().getProviderId()).isEqualTo("nid-3"); + } +} diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2AttributesUnitTest.java b/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2AttributesUnitTest.java new file mode 100644 index 00000000..d7bb0949 --- /dev/null +++ b/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2AttributesUnitTest.java @@ -0,0 +1,68 @@ +package starlight.adapter.auth.security.oauth2; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuth2AttributesUnitTest { + + private ClientRegistration naverRegistration() { + return ClientRegistration.withRegistrationId("naver") + .clientId("test-id") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://example/authorize") + .tokenUri("https://example/token") + .userInfoUri("https://example/me") + .userNameAttributeName("response") + .build(); + } + + private OAuth2UserRequest naverRequest() { + return new OAuth2UserRequest( + naverRegistration(), + new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "access-token", + Instant.now(), Instant.now().plusSeconds(3600) + ) + ); + } + + @Test + void parse_naver_response_wrapped() { + Map resp = Map.of( + "id", "nid-123", + "email", "user@naver.com", + "name", "홍길동", + "profile_image", "http://img/naver.jpg" + ); + Map attrs = Map.of("response", resp); + + var user = new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + attrs, + "response" + ); + + var parsed = OAuth2Attributes.parse(naverRequest(), user); + + + assertThat(parsed.provider()).isEqualTo("naver"); + assertThat(parsed.providerId()).isEqualTo("nid-123"); + assertThat(parsed.email()).isEqualTo("user@naver.com"); + assertThat(parsed.name()).isEqualTo("홍길동"); + assertThat(parsed.profileImageUrl()).isEqualTo("http://img/naver.jpg"); + assertThat(parsed.nameAttributeKey()).isEqualTo("id"); + } +} diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2LoginFlowTest.java b/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2LoginFlowTest.java new file mode 100644 index 00000000..c4bc1429 --- /dev/null +++ b/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2LoginFlowTest.java @@ -0,0 +1,82 @@ +package starlight.adapter.auth.security.oauth2; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(OAuth2LoginFlowTest.TestBeans.class) +class OAuth2LoginFlowTest { + + @Autowired MockMvc mvc; + @Autowired TestOAuth2Objects testOAuth2Objects; + + @Test + void protected_endpoint_after_oauth2Login() throws Exception { + DefaultOAuth2User user = new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + Map.of("response", Map.of( + "id", "nid-42", + "email", "u@naver.com", + "name", "홍길동", + "profile_image", "http://img" + )), + "response" + ); + + ClientRegistration reg = testOAuth2Objects.naverRegistration(); + + mvc.perform(get("/api/protected") + .with(oauth2Login() + .oauth2User(user) + .clientRegistration(reg) + ) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.registrationId").value("naver")) + .andExpect(jsonPath("$.principalName").exists()); + } + + @TestConfiguration + static class TestBeans { + @Bean + TestOAuth2Objects testOAuth2Objects() { + return new TestOAuth2Objects(); + } + + // 테스트용 컨트롤러: 인증 성공 시 registrationId와 principal 이름을 반환 + @RestController + static class ProtectedEchoController { + @GetMapping("/api/protected") + public Map me(org.springframework.security.core.Authentication authentication) { + org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken token = + (org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken) authentication; + String regId = token.getAuthorizedClientRegistrationId(); + String name = token.getName(); // DefaultOAuth2User의 getName() (여기선 "response") + return Map.of("registrationId", regId, "principalName", name); + } + } + } +} + diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java b/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java new file mode 100644 index 00000000..6f3e4496 --- /dev/null +++ b/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java @@ -0,0 +1,59 @@ +package starlight.adapter.auth.security.oauth2; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.application.auth.required.KeyValueMap; +import starlight.application.auth.required.TokenProvider; +import starlight.domain.member.entity.Member; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class OAuth2SuccessHandlerUnitTest { + + KeyValueMap keyValueMap = mock(KeyValueMap.class); + TokenProvider tokenProvider = mock(TokenProvider.class); + OAuth2SuccessHandler handler; + + @BeforeEach + void setUp() { + handler = new OAuth2SuccessHandler(tokenProvider, keyValueMap); + try { + var field = OAuth2SuccessHandler.class.getDeclaredField("successRedirectBase"); + field.setAccessible(true); + field.set(handler, "/redirect"); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("successRedirectBase 필드 접근/설정에 실패했습니다.", e); + } + } + + @Test + void 인증성공시_토큰발급_및_리다이렉트() throws Exception { + // given + Member user = Member.create("testuser", "", "", null, null, "img.png"); + + AuthDetails authDetails = new AuthDetails(user); + + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(authDetails); + + when(tokenProvider.createToken(any(Member.class))) + .thenReturn(new TokenResponse("access-token", "refresh-token")); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + handler.onAuthenticationSuccess(request, response, authentication); + + // then + String redirectedUrl = response.getRedirectedUrl(); + assertThat(redirectedUrl).startsWith("/redirect?access="); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/TestOAuth2Objects.java b/src/test/java/starlight/adapter/auth/security/oauth2/TestOAuth2Objects.java new file mode 100644 index 00000000..ed080b6a --- /dev/null +++ b/src/test/java/starlight/adapter/auth/security/oauth2/TestOAuth2Objects.java @@ -0,0 +1,56 @@ +package starlight.adapter.auth.security.oauth2; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public class TestOAuth2Objects { + + public ClientRegistration naverRegistration() { + return ClientRegistration.withRegistrationId("naver") + .clientId("test-id") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://nid.naver.com/oauth2.0/authorize") + .tokenUri("https://nid.naver.com/oauth2.0/token") + .userInfoUri("https://openapi.naver.com/v1/nid/me") + .userNameAttributeName("response") + .build(); + } + + public OAuth2UserRequest naverRequest() { + return new OAuth2UserRequest( + naverRegistration(), + new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "access-token", + Instant.now(), + Instant.now().plusSeconds(3600) + ) + ); + } + + public DefaultOAuth2User naverUser(String id, String email, String name, String profileImageUrl) { + Map response = new java.util.HashMap<>(); + response.put("id", id); + if (email != null) response.put("email", email); + if (name != null) response.put("name", name); + if (profileImageUrl != null) response.put("profile_image", profileImageUrl); + + Map attributes = Map.of("response", response); + + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + "response" + ); + } +} diff --git a/src/test/java/starlight/adapter/auth/webapi/AuthControllerSliceTest.java b/src/test/java/starlight/adapter/auth/webapi/AuthControllerSliceTest.java new file mode 100644 index 00000000..58cd7b37 --- /dev/null +++ b/src/test/java/starlight/adapter/auth/webapi/AuthControllerSliceTest.java @@ -0,0 +1,137 @@ +package starlight.adapter.auth.webapi; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.auth.security.auth.AuthDetailsService; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.auth.webapi.dto.response.MemberResponse; +import starlight.application.auth.provided.AuthService; +import starlight.application.auth.required.TokenProvider; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +@TestPropertySource(properties = {"jwt.header=Authorization"}) +@Import(AuthControllerSliceTest.AuthTestConfig.class) // 커스텀 argument resolver 등록 +class AuthControllerSliceTest { + + @Autowired MockMvc mvc; + + @MockitoBean AuthService authService; + @MockitoBean TokenProvider tokenProvider; + @MockitoBean AuthDetailsService authDetailsService; + + @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @TestConfiguration + static class AuthTestConfig implements WebMvcConfigurer { + @Bean + HandlerMethodArgumentResolver authDetailsResolver() { + return new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(AuthDetails.class); + } + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + AuthDetails authDetails = Mockito.mock(AuthDetails.class); + Member member = Member.create("tester","tester@ex.com", null, MemberType.FOUNDER, null, "image.png"); + when(authDetails.getUser()).thenReturn(member); + return authDetails; + } + }; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authDetailsResolver()); + } + } + + @Test + void signOut_OK_토큰파싱_후_서비스호출() throws Exception { + when(tokenProvider.resolveRefreshToken(any())).thenReturn("RT"); + when(tokenProvider.resolveAccessToken(any())).thenReturn("AT"); + + mvc.perform(post("/v1/auth/sign-out")) + .andExpect(status().isOk()); + + verify(authService).signOut("RT", "AT"); + } + + @Test + void recreate_OK_헤더에서_토큰읽어_서비스호출() throws Exception { + when(authService.recreate(eq("Bearer REAL_RT"), any(Member.class))) + .thenReturn(new TokenResponse("NEW_AT", "RT_OR_NEW")); + + mvc.perform(get("/v1/auth/recreate") + .header("Authorization", "Bearer REAL_RT")) + .andExpect(status().isOk()); + + verify(authService).recreate(eq("Bearer REAL_RT"), any(Member.class)); + } + + @Test + void signIn_OK() throws Exception { + when(authService.signIn(argThat(req -> + "a@b.com".equals(req.email()) && "pw".equals(req.password()) + ))).thenReturn(new TokenResponse("AT", "RT")); + + mvc.perform(post("/v1/auth/sign-in") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"a@b.com\",\"password\":\"pw\"}")) + .andExpect(status().isOk()); + + verify(authService).signIn(argThat(req -> + "a@b.com".equals(req.email()) && "pw".equals(req.password()) + )); + } + + @Test + void signUp_OK() throws Exception { + when(authService.signUp(any(AuthRequest.class))).thenAnswer(invocation -> { + AuthRequest request = invocation.getArgument(0); + Credential credential = Credential.create("hashedPassword"); + Member member = request.toMember(credential); + return MemberResponse.of(member); + }); + + mvc.perform(post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"정성호\",\"email\":\"user@ex.com\",\"password\":\"pw\",\"phoneNumber\":\"010-1234-5678\"}")) + .andExpect(status().isOk()); + + verify(authService).signUp(argThat(req -> + "user@ex.com".equals(req.email()) && "pw".equals(req.password()) && "010-1234-5678".equals(req.phoneNumber()) + )); + } +} diff --git a/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java b/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java new file mode 100644 index 00000000..10abb979 --- /dev/null +++ b/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java @@ -0,0 +1,81 @@ +package starlight.adapter.businessplan.webapi; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import starlight.adapter.businessplan.spellcheck.dto.Finding; +import starlight.application.businessplan.required.SpellChecker; + +import java.util.List; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc(addFilters = false) +class SpellControllerTest { + + @Autowired + MockMvc mvc; + @Autowired + ObjectMapper om; + + @TestConfiguration + static class TestBeans { + @Bean + SpellChecker spellChecker() { + return new SpellChecker() { + @Override + public List check(String sentence) { + if (sentence.contains("teh")) { + return List.of(new Finding( + "spell", "error", "teh", + List.of("the"), + "맞춤법 오류", "teh", "teh cat", + "맞춤법을 확인하세요", + List.of())); + } + return List.of(); + } + + @Override + public String applyTopSuggestions(String original, List findings) { + return original.replace("teh", "the"); + } + }; + } + } + +// @Test +// @DisplayName("End-to-End - 가짜 맞춤법 검사기로 통합 테스트") +// void endToEnd_withFakeSpellChecker() throws Exception { +// var body = Map.of( +// "subSectionType", "OVERVIEW_BASIC", +// "meta", Map.of( +// "author", "tester", +// "createdAt", "2025-10-28"), +// "blocks", List.of( +// Map.of( +// "meta", Map.of("title", "Intro"), +// "content", List.of( +// Map.of("type", "text", "value", "teh cat"))))); +// +// mvc.perform(post("/v1/business-plans/spellcheck") +// .contentType(MediaType.APPLICATION_JSON) +// .characterEncoding("UTF-8") +// .content(om.writeValueAsBytes(body))) +// .andExpect(status().isOk()) +// .andExpect(jsonPath("$.data.corrected").value("the cat")) +// .andExpect(jsonPath("$.data.typos").isArray()) +// .andExpect(jsonPath("$.data.typos[0].token").value("teh")); +// } +} diff --git a/src/test/java/starlight/adapter/expert/persistence/ExpertRepositoryTest.java b/src/test/java/starlight/adapter/expert/persistence/ExpertRepositoryTest.java new file mode 100644 index 00000000..a74a759d --- /dev/null +++ b/src/test/java/starlight/adapter/expert/persistence/ExpertRepositoryTest.java @@ -0,0 +1,60 @@ +package starlight.adapter.expert.persistence; + +import org.junit.jupiter.api.DisplayName; +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.test.util.ReflectionTestUtils; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import jakarta.persistence.EntityManager; +import java.lang.reflect.Constructor; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class ExpertRepositoryTest { + + @Autowired ExpertRepository repository; + @Autowired EntityManager em; + + @Test + @DisplayName("findByAllCategories: 전달된 모든 카테고리를 가진 Expert만 조회된다(AND)") + void findByAllCategories_AND() throws Exception { + // given + Expert a = expert("A", + Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY)); + Expert b = expert("B", + Set.of(TagCategory.GROWTH_STRATEGY)); // 조건 미충족 + Expert c = expert("C", + Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY, TagCategory.METRIC_DATA)); + + em.persist(a); em.persist(b); em.persist(c); + em.flush(); em.clear(); + + // when + List found = repository.findByAllCategories( + Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY), + 2L // size + ); + + // then + assertThat(found).extracting("name").containsExactlyInAnyOrder("A", "C"); + } + + // ---- helpers ---- + private Expert expert(String name, Set cats) throws Exception { + Constructor ctor = Expert.class.getDeclaredConstructor(); + ctor.setAccessible(true); + Expert e = ctor.newInstance(); + ReflectionTestUtils.setField(e, "name", name); + ReflectionTestUtils.setField(e, "email", name.toLowerCase() + "@example.com"); + ReflectionTestUtils.setField(e, "careers", List.of("career1", "career2")); + ReflectionTestUtils.setField(e, "categories", new LinkedHashSet<>(cats)); + return e; + } +} diff --git a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java new file mode 100644 index 00000000..ab3a903b --- /dev/null +++ b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java @@ -0,0 +1,110 @@ +package starlight.adapter.expert.webapi; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; + +import starlight.adapter.auth.security.filter.JwtFilter; +import starlight.application.expert.provided.ExpertFinder; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.lang.reflect.Constructor; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest( + controllers = ExpertController.class, + excludeAutoConfiguration = JpaRepositoriesAutoConfiguration.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = JwtFilter.class + ) +) +@AutoConfigureMockMvc(addFilters = false) +class ExpertControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper om; + + @MockitoBean ExpertFinder expertFinder; + @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; // ← 필드로 추가! + + @Test + @DisplayName("카테고리 미전달 시 전체 조회") + void listAll() throws Exception { + Expert e1 = expert(1L, "홍길동", + Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY)); + when(expertFinder.loadAll()).thenReturn(List.of(e1)); + + mockMvc.perform(get("/v1/experts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result").value("SUCCESS")) + .andExpect(jsonPath("$.data[0].name").value("홍길동")); + } + + @Test + @DisplayName("카테고리 AND 매칭 (?categories=A&categories=B)") + void searchByAllCategories_multiParams() throws Exception { + Expert e1 = expert(2L, "이영희", + Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY)); + + when(expertFinder.findByAllCategories(Set.of( + TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY + ))).thenReturn(List.of(e1)); + + mockMvc.perform(get("/v1/experts") + .param("categories", "GROWTH_STRATEGY") + .param("categories", "TEAM_CAPABILITY")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result").value("SUCCESS")) + .andExpect(jsonPath("$.data[0].name").value("이영희")); + } + + @Test + @DisplayName("카테고리 AND 매칭 (콤마 구분)") + void searchByAllCategories_commaSeparated() throws Exception { + Expert e1 = expert(3L, "박철수", + Set.of(TagCategory.MARKET_BM, TagCategory.METRIC_DATA)); + + when(expertFinder.findByAllCategories(Set.of( + TagCategory.MARKET_BM, TagCategory.METRIC_DATA + ))).thenReturn(List.of(e1)); + + mockMvc.perform(get("/v1/experts") + .param("categories", "MARKET_BM,METRIC_DATA")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result").value("SUCCESS")) + .andExpect(jsonPath("$.data[0].name").value("박철수")); + } + + // helper + private Expert expert(Long id, String name, Set cats) throws Exception { + Constructor ctor = Expert.class.getDeclaredConstructor(); + ctor.setAccessible(true); + Expert e = ctor.newInstance(); + ReflectionTestUtils.setField(e, "id", id); + ReflectionTestUtils.setField(e, "name", name); + ReflectionTestUtils.setField(e, "email", name + "@example.com"); + ReflectionTestUtils.setField(e, "profileImageUrl", "https://cdn.example.com/" + id + ".png"); + ReflectionTestUtils.setField(e, "mentoringPriceWon", 50000); + ReflectionTestUtils.setField(e, "careers", List.of("A사 PO", "B사 PM")); + ReflectionTestUtils.setField(e, "categories", new LinkedHashSet<>(cats)); + return e; + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/ClovaOcrProviderTest.java b/src/test/java/starlight/adapter/ncp/ocr/ClovaOcrProviderTest.java new file mode 100644 index 00000000..cd372635 --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/ClovaOcrProviderTest.java @@ -0,0 +1,269 @@ +package starlight.adapter.ncp.ocr; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; +import starlight.adapter.ncp.ocr.infra.ClovaOcrClient; +import starlight.adapter.ncp.ocr.infra.PdfDownloadClient; +import starlight.adapter.ncp.ocr.util.OcrResponseMerger; +import starlight.adapter.ncp.ocr.util.OcrTextExtractor; +import starlight.adapter.ncp.ocr.util.PdfUtils; +import starlight.shared.dto.infrastructure.OcrResponse; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClovaOcrProvider 테스트") +class ClovaOcrProviderTest { + + @Mock + private ClovaOcrClient clovaOcrClient; + + @Mock + private PdfDownloadClient pdfDownloadClient; + + @InjectMocks + private ClovaOcrProvider clovaOcrProvider; + + private static final String TEST_PDF_URL = "https://example.com/test.pdf"; + private byte[] testPdfBytes; + private OcrResponse mockResponse1; + private OcrResponse mockResponse2; + + @BeforeEach + void setUp() { + testPdfBytes = "test pdf content".getBytes(); + mockResponse1 = OcrResponse.createEmpty(); + mockResponse2 = OcrResponse.createEmpty(); + } + + @Test + @DisplayName("단일 청크 PDF OCR 처리 성공") + void ocrPdfByUrl_Success_SingleChunk() { + // given + byte[] chunk = "chunk1".getBytes(); + OcrResponse expectedResponse = OcrResponse.createEmpty(); + + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + + try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); + MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { + + pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) + .thenReturn(List.of(chunk)); + when(clovaOcrClient.recognizePdfBytes(chunk)).thenReturn(mockResponse1); + mergerMock.when(() -> OcrResponseMerger.merge(List.of(mockResponse1))) + .thenReturn(expectedResponse); + + // when + OcrResponse result = clovaOcrProvider.ocrPdfByUrl(TEST_PDF_URL); + + // then + assertThat(result).isEqualTo(expectedResponse); + verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(clovaOcrClient).recognizePdfBytes(chunk); + } + } + + @Test + @DisplayName("다중 청크 PDF OCR 처리 성공") + void ocrPdfByUrl_Success_MultipleChunks() { + // given + byte[] chunk1 = "chunk1".getBytes(); + byte[] chunk2 = "chunk2".getBytes(); + OcrResponse mergedResponse = OcrResponse.createEmpty(); + + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + + try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); + MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { + + pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) + .thenReturn(List.of(chunk1, chunk2)); + when(clovaOcrClient.recognizePdfBytes(chunk1)).thenReturn(mockResponse1); + when(clovaOcrClient.recognizePdfBytes(chunk2)).thenReturn(mockResponse2); + mergerMock.when(() -> OcrResponseMerger.merge(List.of(mockResponse1, mockResponse2))) + .thenReturn(mergedResponse); + + // when + OcrResponse result = clovaOcrProvider.ocrPdfByUrl(TEST_PDF_URL); + + // then + assertThat(result).isEqualTo(mergedResponse); + verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(clovaOcrClient).recognizePdfBytes(chunk1); + verify(clovaOcrClient).recognizePdfBytes(chunk2); + } + } + + @Test + @DisplayName("PDF 다운로드 실패 시 예외 전파") + void ocrPdfByUrl_ThrowsException_WhenDownloadFails() { + // given + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)) + .thenThrow(new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR)); + + // when & then + assertThatThrownBy(() -> clovaOcrProvider.ocrPdfByUrl(TEST_PDF_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + + verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verifyNoInteractions(clovaOcrClient); + } + + @Test + @DisplayName("PDF 분할 실패 시 예외 전파") + void ocrPdfByUrl_ThrowsException_WhenSplitFails() { + // given + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + + try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class)) { + pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) + .thenThrow(new OcrException(OcrErrorType.PDF_SPLIT_ERROR)); + + // when & then + assertThatThrownBy(() -> clovaOcrProvider.ocrPdfByUrl(TEST_PDF_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_SPLIT_ERROR); + + verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verifyNoInteractions(clovaOcrClient); + } + } + + @Test + @DisplayName("OCR 클라이언트 호출 실패 시 예외 전파") + void ocrPdfByUrl_ThrowsException_WhenOcrFails() { + // given + byte[] chunk = "chunk1".getBytes(); + + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + + try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class)) { + pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) + .thenReturn(List.of(chunk)); + when(clovaOcrClient.recognizePdfBytes(chunk)) + .thenThrow(new OcrException(OcrErrorType.OCR_CLIENT_ERROR)); + + // when & then + assertThatThrownBy(() -> clovaOcrProvider.ocrPdfByUrl(TEST_PDF_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.OCR_CLIENT_ERROR); + + verify(clovaOcrClient).recognizePdfBytes(chunk); + } + } + + @Test + @DisplayName("텍스트 추출까지 포함한 전체 플로우 성공") + void ocrPdfTextByUrl_Success() { + // given + byte[] chunk = "chunk1".getBytes(); + OcrResponse ocrResponse = OcrResponse.createEmpty(); + String expectedText = "Extracted text content"; + + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + + try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); + MockedStatic mergerMock = mockStatic(OcrResponseMerger.class); + MockedStatic extractorMock = mockStatic(OcrTextExtractor.class)) { + + pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) + .thenReturn(List.of(chunk)); + when(clovaOcrClient.recognizePdfBytes(chunk)).thenReturn(mockResponse1); + mergerMock.when(() -> OcrResponseMerger.merge(List.of(mockResponse1))) + .thenReturn(ocrResponse); + extractorMock.when(() -> OcrTextExtractor.toPlainText(ocrResponse)) + .thenReturn(expectedText); + + // when + String result = clovaOcrProvider.ocrPdfTextByUrl(TEST_PDF_URL); + + // then + assertThat(result).isEqualTo(expectedText); + verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(clovaOcrClient).recognizePdfBytes(chunk); + } + } + + @Test + @DisplayName("텍스트 추출 중 OCR 실패 시 예외 전파") + void ocrPdfTextByUrl_ThrowsException_WhenOcrFails() { + // given + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)) + .thenThrow(new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR)); + + // when & then + assertThatThrownBy(() -> clovaOcrProvider.ocrPdfTextByUrl(TEST_PDF_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + } + + @Test + @DisplayName("빈 청크 리스트 처리") + void ocrPdfByUrl_WithEmptyChunks() { + // given + OcrResponse emptyResponse = OcrResponse.createEmpty(); + + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + + try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); + MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { + + pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) + .thenReturn(List.of()); + mergerMock.when(() -> OcrResponseMerger.merge(List.of())) + .thenReturn(emptyResponse); + + // when + OcrResponse result = clovaOcrProvider.ocrPdfByUrl(TEST_PDF_URL); + + // then + assertThat(result).isEqualTo(emptyResponse); + verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verifyNoInteractions(clovaOcrClient); + } + } + + @Test + @DisplayName("10페이지 이상 PDF 처리 - 정확히 20페이지") + void ocrPdfByUrl_ExactlyTwoChunks() { + // given + byte[] chunk1 = "chunk1".getBytes(); + byte[] chunk2 = "chunk2".getBytes(); + OcrResponse mergedResponse = OcrResponse.createEmpty(); + + when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + + try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); + MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { + + pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) + .thenReturn(List.of(chunk1, chunk2)); + when(clovaOcrClient.recognizePdfBytes(chunk1)).thenReturn(mockResponse1); + when(clovaOcrClient.recognizePdfBytes(chunk2)).thenReturn(mockResponse2); + mergerMock.when(() -> OcrResponseMerger.merge(List.of(mockResponse1, mockResponse2))) + .thenReturn(mergedResponse); + + // when + OcrResponse result = clovaOcrProvider.ocrPdfByUrl(TEST_PDF_URL); + + // then + assertThat(result).isEqualTo(mergedResponse); + verify(clovaOcrClient, times(2)).recognizePdfBytes(any()); + } + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClientTest.java b/src/test/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClientTest.java new file mode 100644 index 00000000..651ac187 --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClientTest.java @@ -0,0 +1,135 @@ +package starlight.adapter.ncp.ocr.infra; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClient; +import starlight.adapter.ncp.ocr.dto.ClovaOcrRequest; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; +import starlight.shared.dto.infrastructure.OcrResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ClovaOcrClient 테스트") +class ClovaOcrClientTest { + + @Mock private RestClient clovaOcrRestClient; + @Mock private RestClient.RequestBodyUriSpec requestBodyUriSpec; + @Mock private RestClient.RequestBodySpec requestBodySpec; + @Mock private RestClient.ResponseSpec responseSpec; + + private ClovaOcrClient clovaOcrClient; + private byte[] testPdfBytes; + + @BeforeEach + void setUp() { + clovaOcrClient = new ClovaOcrClient(clovaOcrRestClient); + testPdfBytes = "test pdf content".getBytes(); + } + + @Test + @DisplayName("PDF 바이트를 전달하면 OCR 응답을 반환한다") + void recognizePdfBytes_Success() { + // given + OcrResponse expectedResponse = OcrResponse.createEmpty(); + + when(clovaOcrRestClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(ArgumentMatchers.any())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.body(OcrResponse.class)).thenReturn(expectedResponse); + + // when + OcrResponse result = clovaOcrClient.recognizePdfBytes(testPdfBytes); + + // then + assertThat(result).isNotNull(); + assertThat(result).isEqualTo(expectedResponse); + } + + @Test + @DisplayName("OCR 호출 중 예외 발생 시 OcrException을 던진다") + void recognizePdfBytes_ThrowsOcrException_WhenClientFails() { + // given + when(clovaOcrRestClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(ArgumentMatchers.any())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.body(OcrResponse.class)).thenThrow(new RuntimeException("Network error")); + + // when & then + assertThatThrownBy(() -> clovaOcrClient.recognizePdfBytes(testPdfBytes)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.OCR_CLIENT_ERROR); + } + + @Test + @DisplayName("빈 PDF 바이트 배열로도 정상 호출된다") + void recognizePdfBytes_WithEmptyBytes() { + // given + byte[] emptyBytes = new byte[0]; + OcrResponse expectedResponse = OcrResponse.createEmpty(); + + when(clovaOcrRestClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(ArgumentMatchers.any())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.body(OcrResponse.class)).thenReturn(expectedResponse); + + // when + OcrResponse result = clovaOcrClient.recognizePdfBytes(emptyBytes); + + // then + assertThat(result).isNotNull(); + assertThat(result).isEqualTo(expectedResponse); + } + + @Test + @DisplayName("응답이 null인 경우 예외가 발생한다") + void recognizePdfBytes_WithNullResponse() { + // given + when(clovaOcrRestClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(ArgumentMatchers.any())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.body(OcrResponse.class)).thenReturn(null); + + // when & then + assertThatThrownBy(() -> clovaOcrClient.recognizePdfBytes(testPdfBytes)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.OCR_CLIENT_ERROR); + } + + @Test + @DisplayName("retrieve 단계에서 예외 발생 시 OcrException 발생") + void recognizePdfBytes_ThrowsOcrException_WhenRetrieveFails() { + // given + when(clovaOcrRestClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(ArgumentMatchers.any())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenThrow(new RuntimeException("Connection timeout")); + + // when & then + assertThatThrownBy(() -> clovaOcrClient.recognizePdfBytes(testPdfBytes)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.OCR_CLIENT_ERROR); + } + + @Test + @DisplayName("body 파싱 중 예외 발생 시 OcrException 발생") + void recognizePdfBytes_ThrowsOcrException_WhenBodyParseFails() { + // given + when(clovaOcrRestClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(ArgumentMatchers.any())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.body(OcrResponse.class)).thenThrow(new RuntimeException("JSON parse error")); + + // when & then + assertThatThrownBy(() -> clovaOcrClient.recognizePdfBytes(testPdfBytes)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.OCR_CLIENT_ERROR); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientIntegrationTest.java b/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientIntegrationTest.java new file mode 100644 index 00000000..442f13cb --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientIntegrationTest.java @@ -0,0 +1,161 @@ +package starlight.adapter.ncp.ocr.infra; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; + +import java.io.IOException; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("PdfDownloadClient 통합 테스트 (MockWebServer)") +class PdfDownloadClientIntegrationTest { + + private MockWebServer mockWebServer; + private PdfDownloadClient pdfDownloadClient; + private String baseUrl; + + @BeforeEach + void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + baseUrl = mockWebServer.url("/").toString(); + + JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); + factory.setReadTimeout(Duration.ofSeconds(5)); + + RestClient restClient = RestClient.builder() + .requestFactory(factory) + .defaultHeader("User-Agent", "TEST-CLIENT/1.0") + .build(); + + pdfDownloadClient = new PdfDownloadClient(restClient); + } + + @AfterEach + void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @Test + @DisplayName("실제 HTTP 요청으로 PDF 다운로드 성공") + void downloadPdfFromUrl_RealHttpRequest_Success() throws InterruptedException { + // given + byte[] expectedBytes = "PDF content".getBytes(); + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(new String(expectedBytes)) + .addHeader("Content-Type", "application/pdf")); + + String url = baseUrl + "test.pdf"; + + // when + byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + + // then + assertThat(result).isEqualTo(expectedBytes); + + RecordedRequest request = mockWebServer.takeRequest(); + assertThat(request.getMethod()).isEqualTo("GET"); + assertThat(request.getPath()).isEqualTo("/test.pdf"); + assertThat(request.getHeader("User-Agent")).isEqualTo("TEST-CLIENT/1.0"); + } + + @Test + @DisplayName("404 응답 시 PDF_DOWNLOAD_ERROR 예외 발생") + void downloadPdfFromUrl_Returns404_ThrowsException() { + // given + mockWebServer.enqueue(new MockResponse() + .setResponseCode(404) + .setBody("Not Found")); + + String url = baseUrl + "notfound.pdf"; + + // when & then + assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + } + + @Test + @DisplayName("500 응답 시 PDF_DOWNLOAD_ERROR 예외 발생") + void downloadPdfFromUrl_Returns500_ThrowsException() { + // given + mockWebServer.enqueue(new MockResponse() + .setResponseCode(500) + .setBody("Internal Server Error")); + + String url = baseUrl + "error.pdf"; + + // when & then + assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + } + + @Test + @DisplayName("빈 응답 본문인 경우 PDF_EMPTY_RESPONSE 예외 발생") + void downloadPdfFromUrl_EmptyBody_ThrowsException() { + // given + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("")); + + String url = baseUrl + "empty.pdf"; + + // when & then + assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + } + + @Test + @DisplayName("쿼리 파라미터가 포함된 URL 처리") + void downloadPdfFromUrl_WithQueryParams_Success() throws InterruptedException { + // given + byte[] expectedBytes = "PDF with params".getBytes(); + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(new String(expectedBytes))); + + String url = baseUrl + "test.pdf?token=abc123&expires=2025-12-31"; + + // when + byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + + // then + assertThat(result).isEqualTo(expectedBytes); + + RecordedRequest request = mockWebServer.takeRequest(); + assertThat(request.getPath()).contains("token=abc123"); + assertThat(request.getPath()).contains("expires=2025-12-31"); + } + + @Test + @DisplayName("큰 PDF 파일 다운로드 성공 (10MB)") + void downloadPdfFromUrl_LargeFile_Success() { + // given + byte[] largeBytes = new byte[10 * 1024 * 1024]; // 10MB + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(new String(largeBytes))); + + String url = baseUrl + "large.pdf"; + + // when + byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + + // then + assertThat(result).hasSize(10 * 1024 * 1024); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientTest.java b/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientTest.java new file mode 100644 index 00000000..83842a7f --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientTest.java @@ -0,0 +1,189 @@ +package starlight.adapter.ncp.ocr.infra; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClient; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("PdfDownloadClient 테스트") +class PdfDownloadClientTest { + + private RestClient pdfDownloadClient; + @SuppressWarnings("rawtypes") + private RestClient.RequestHeadersUriSpec requestHeadersUriSpec; + private RestClient.ResponseSpec responseSpec; + private PdfDownloadClient pdfDownloadClientInstance; + + private static final String TEST_URL = "https://example.com/test.pdf"; + private byte[] testPdfBytes; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + pdfDownloadClient = mock(RestClient.class); + requestHeadersUriSpec = mock(RestClient.RequestHeadersUriSpec.class); + responseSpec = mock(RestClient.ResponseSpec.class); + + pdfDownloadClientInstance = new PdfDownloadClient(pdfDownloadClient); + testPdfBytes = createPdfBytes(1024); // 1KB + } + + @Test + @DisplayName("정상적인 PDF 다운로드 성공") + void downloadPdfFromUrl_Success() { + // given + ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); + + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); + + // when + byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL); + + // then + assertThat(result).isEqualTo(testPdfBytes); + verify(pdfDownloadClient).get(); + verify(requestHeadersUriSpec).uri(URI.create(TEST_URL)); + } + + @Test + @DisplayName("빈 응답인 경우 PDF_EMPTY_RESPONSE 예외 발생") + void downloadPdfFromUrl_ThrowsException_WhenResponseIsEmpty() { + // given + ResponseEntity responseEntity = ResponseEntity.ok(new byte[0]); + + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); + + // when & then + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + } + + @Test + @DisplayName("응답 Body가 null인 경우 PDF_EMPTY_RESPONSE 예외 발생") + void downloadPdfFromUrl_ThrowsException_WhenResponseBodyIsNull() { + // given + ResponseEntity responseEntity = ResponseEntity.ok().build(); + + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); + + // when & then + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + } + + @Test + @DisplayName("PDF 크기가 30MB를 초과하면 PDF_TOO_LARGE 예외 발생") + void downloadPdfFromUrl_ThrowsException_WhenPdfIsTooLarge() { + // given + byte[] largePdfBytes = createPdfBytes(31 * 1024 * 1024); // 31MB + ResponseEntity responseEntity = ResponseEntity.ok(largePdfBytes); + + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); + + // when & then + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_TOO_LARGE); + } + + @Test + @DisplayName("정확히 30MB인 PDF는 정상 다운로드") + void downloadPdfFromUrl_Success_WhenPdfIsExactly30MB() { + // given + byte[] exactSizePdfBytes = createPdfBytes(30 * 1024 * 1024); // 정확히 30MB + ResponseEntity responseEntity = ResponseEntity.ok(exactSizePdfBytes); + + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); + + // when + byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL); + + // then + assertThat(result).isEqualTo(exactSizePdfBytes); + } + + @Test + @DisplayName("네트워크 예외 발생 시 PDF_DOWNLOAD_ERROR 예외 발생") + void downloadPdfFromUrl_ThrowsException_WhenNetworkError() { + // given + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenThrow(new RuntimeException("Network error")); + + // when & then + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + } + + @Test + @DisplayName("특수문자가 포함된 URL도 정상 처리") + void downloadPdfFromUrl_Success_WithEncodedUrl() { + // given + String encodedUrl = "https://example.com/test%20file.pdf?param=value&signed=abc123"; + ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); + + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); + + // when + byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(encodedUrl); + + // then + assertThat(result).isEqualTo(testPdfBytes); + verify(requestHeadersUriSpec).uri(URI.create(encodedUrl)); + } + + @Test + @DisplayName("프리사인드 URL도 정상 처리") + void downloadPdfFromUrl_Success_WithPresignedUrl() { + // given + String presignedUrl = "https://s3.amazonaws.com/bucket/file.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=xxx"; + ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); + + when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); + + // when + byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(presignedUrl); + + // then + assertThat(result).isEqualTo(testPdfBytes); + } + + // 헬퍼 메서드 + private byte[] createPdfBytes(int size) { + return new byte[size]; + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/OcrResponseMergerUnitTest.java b/src/test/java/starlight/adapter/ncp/ocr/util/OcrResponseMergerUnitTest.java new file mode 100644 index 00000000..9c9dce45 --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/util/OcrResponseMergerUnitTest.java @@ -0,0 +1,173 @@ +package starlight.adapter.ncp.ocr.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.shared.dto.infrastructure.OcrResponse; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OcrResponseMerger 테스트") +class OcrResponseMergerUnitTest { + + @Test + @DisplayName("null 입력 시 빈 응답 반환") + void merge_ReturnsEmpty_WhenInputIsNull() { + // when + OcrResponse result = OcrResponseMerger.merge(null); + + // then + assertThat(result).isNotNull(); + assertThat(result.images()).isEmpty(); + assertThat(result.version()).isEqualTo("V2"); + assertThat(result.requestId()).isEqualTo("empty"); + } + + @Test + @DisplayName("빈 리스트 입력 시 빈 응답 반환") + void merge_ReturnsEmpty_WhenInputIsEmpty() { + // when + OcrResponse result = OcrResponseMerger.merge(List.of()); + + // then + assertThat(result).isNotNull(); + assertThat(result.images()).isEmpty(); + } + + @Test + @DisplayName("단일 응답 병합") + void merge_SingleResponse() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Hello", 0.95, "normal", false + ); + OcrResponse.ImageResult image1 = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image1)); + + // when + OcrResponse result = OcrResponseMerger.merge(List.of(response)); + + // then + assertThat(result.version()).isEqualTo("V2"); + assertThat(result.requestId()).isEqualTo("req1"); + assertThat(result.images()).hasSize(1); + assertThat(result.images().get(0).fields()).hasSize(1); + assertThat(result.images().get(0).fields().get(0).inferText()).isEqualTo("Hello"); + } + + @Test + @DisplayName("다중 응답 병합 - 이미지 순서 유지") + void merge_MultipleResponses_MaintainsOrder() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Page1", 0.95, "normal", false + ); + OcrResponse.ImageResult.Field field2 = new OcrResponse.ImageResult.Field( + "text", "Page2", 0.95, "normal", false + ); + OcrResponse.ImageResult.Field field3 = new OcrResponse.ImageResult.Field( + "text", "Page3", 0.95, "normal", false + ); + + OcrResponse.ImageResult image1 = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1) + ); + OcrResponse.ImageResult image2 = new OcrResponse.ImageResult( + "img2", "page2", "SUCCESS", null, List.of(field2) + ); + OcrResponse.ImageResult image3 = new OcrResponse.ImageResult( + "img3", "page3", "SUCCESS", null, List.of(field3) + ); + + OcrResponse response1 = OcrResponse.create("V2", "req1", List.of(image1)); + OcrResponse response2 = OcrResponse.create("V2", "req2", List.of(image2, image3)); + + // when + OcrResponse result = OcrResponseMerger.merge(List.of(response1, response2)); + + // then + assertThat(result.images()).hasSize(3); + assertThat(result.images().get(0).fields().get(0).inferText()).isEqualTo("Page1"); + assertThat(result.images().get(1).fields().get(0).inferText()).isEqualTo("Page2"); + assertThat(result.images().get(2).fields().get(0).inferText()).isEqualTo("Page3"); + } + + @Test + @DisplayName("첫 번째 응답의 version과 requestId 사용") + void merge_UsesFirstResponseMetadata() { + // given + OcrResponse response1 = OcrResponse.create("V2", "first-request", List.of()); + OcrResponse response2 = OcrResponse.create("V3", "second-request", List.of()); + + // when + OcrResponse result = OcrResponseMerger.merge(List.of(response1, response2)); + + // then + assertThat(result.version()).isEqualTo("V2"); + assertThat(result.requestId()).isEqualTo("first-request"); + } + + @Test + @DisplayName("images가 null인 응답 포함 시 안전하게 병합") + void merge_HandlesNullImages() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Hello", 0.95, "normal", false + ); + OcrResponse.ImageResult image1 = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1) + ); + + OcrResponse response1 = OcrResponse.create("V2", "req1", List.of(image1)); + OcrResponse response2 = new OcrResponse("V2", "req2", System.currentTimeMillis(), null); + + // when + OcrResponse result = OcrResponseMerger.merge(List.of(response1, response2)); + + // then + assertThat(result.images()).hasSize(1); + assertThat(result.images().get(0).fields().get(0).inferText()).isEqualTo("Hello"); + } + + @Test + @DisplayName("10개 이상의 응답 병합") + void merge_ManyResponses() { + // given + List responses = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + OcrResponse.ImageResult.Field field = new OcrResponse.ImageResult.Field( + "text", "Page" + i, 0.95, "normal", false + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img" + i, "page" + i, "SUCCESS", null, List.of(field) + ); + responses.add(OcrResponse.create("V2", "req" + i, List.of(image))); + } + + // when + OcrResponse result = OcrResponseMerger.merge(responses); + + // then + assertThat(result.images()).hasSize(15); + assertThat(result.images().get(0).fields().get(0).inferText()).isEqualTo("Page0"); + assertThat(result.images().get(14).fields().get(0).inferText()).isEqualTo("Page14"); + } + + @Test + @DisplayName("빈 images를 가진 응답들 병합") + void merge_EmptyImagesResponses() { + // given + OcrResponse response1 = OcrResponse.create("V2", "req1", List.of()); + OcrResponse response2 = OcrResponse.create("V2", "req2", List.of()); + + // when + OcrResponse result = OcrResponseMerger.merge(List.of(response1, response2)); + + // then + assertThat(result.images()).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/OcrTextExtractorUnitTest.java b/src/test/java/starlight/adapter/ncp/ocr/util/OcrTextExtractorUnitTest.java new file mode 100644 index 00000000..8af0db54 --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/util/OcrTextExtractorUnitTest.java @@ -0,0 +1,312 @@ +package starlight.adapter.ncp.ocr.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.shared.dto.infrastructure.OcrResponse; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("OcrTextExtractor 테스트") +class OcrTextExtractorUnitTest { + + @Test + @DisplayName("null 입력 시 빈 문자열 반환") + void toPlainText_ReturnsEmpty_WhenInputIsNull() { + // when + String result = OcrTextExtractor.toPlainText(null); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("images가 null인 응답 처리") + void toPlainText_HandlesNullImages() { + // given + OcrResponse response = new OcrResponse("V2", "req1", System.currentTimeMillis(), null); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("단일 페이지 텍스트 추출") + void toPlainText_SinglePage() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Hello", 0.95, "normal", false + ); + OcrResponse.ImageResult.Field field2 = new OcrResponse.ImageResult.Field( + "text", "World", 0.95, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1, field2) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Hello World"); + } + + @Test + @DisplayName("다중 페이지 텍스트 추출 - 구분선 포함") + void toPlainText_MultiplePages_WithSeparator() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Page1", 0.95, "normal", true + ); + OcrResponse.ImageResult.Field field2 = new OcrResponse.ImageResult.Field( + "text", "Page2", 0.95, "normal", true + ); + OcrResponse.ImageResult image1 = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1) + ); + OcrResponse.ImageResult image2 = new OcrResponse.ImageResult( + "img2", "page2", "SUCCESS", null, List.of(field2) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image1, image2)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Page1\n\n-----\n\nPage2"); + } + + @Test + @DisplayName("신뢰도 낮은 필드는 제외") + void toPlainText_FiltersLowConfidence() { + // given + OcrResponse.ImageResult.Field highConfidence = new OcrResponse.ImageResult.Field( + "text", "Good", 0.95, "normal", false + ); + OcrResponse.ImageResult.Field lowConfidence = new OcrResponse.ImageResult.Field( + "text", "Bad", 0.70, "normal", false + ); + OcrResponse.ImageResult.Field anotherHigh = new OcrResponse.ImageResult.Field( + "text", "Text", 0.90, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(highConfidence, lowConfidence, anotherHigh) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Good Text"); + assertThat(result).doesNotContain("Bad"); + } + + @Test + @DisplayName("신뢰도 경계값 테스트 - 0.85") + void toPlainText_ConfidenceThreshold() { + // given + OcrResponse.ImageResult.Field exactThreshold = new OcrResponse.ImageResult.Field( + "text", "Exact", 0.85, "normal", false + ); + OcrResponse.ImageResult.Field justBelow = new OcrResponse.ImageResult.Field( + "text", "Below", 0.849, "normal", false + ); + OcrResponse.ImageResult.Field justAbove = new OcrResponse.ImageResult.Field( + "text", "Above", 0.851, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(exactThreshold, justBelow, justAbove) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).contains("Exact"); + assertThat(result).contains("Above"); + assertThat(result).doesNotContain("Below"); + } + + @Test + @DisplayName("줄바꿈 처리") + void toPlainText_HandlesLineBreaks() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Line1", 0.95, "normal", true + ); + OcrResponse.ImageResult.Field field2 = new OcrResponse.ImageResult.Field( + "text", "Line2", 0.95, "normal", true + ); + OcrResponse.ImageResult.Field field3 = new OcrResponse.ImageResult.Field( + "text", "Line3", 0.95, "normal", false + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1, field2, field3) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Line1\nLine2\nLine3"); + } + + @Test + @DisplayName("공백 정규화 - 연속 공백 제거") + void toPlainText_NormalizesWhitespace() { + // given + OcrResponse.ImageResult.Field field = new OcrResponse.ImageResult.Field( + "text", "Hello World", 0.95, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Hello World"); + } + + @Test + @DisplayName("구두점 앞 공백 제거") + void toPlainText_RemovesSpaceBeforePunctuation() { + // given + OcrResponse.ImageResult.Field field = new OcrResponse.ImageResult.Field( + "text", "Hello , World !", 0.95, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Hello, World!"); + } + + @Test + @DisplayName("괄호 주변 공백 정리") + void toPlainText_NormalizesParentheses() { + // given + OcrResponse.ImageResult.Field field = new OcrResponse.ImageResult.Field( + "text", "Hello ( World )", 0.95, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Hello (World)"); + } + + @Test + @DisplayName("inferText가 null인 필드 처리") + void toPlainText_HandlesNullInferText() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Hello", 0.95, "normal", false + ); + OcrResponse.ImageResult.Field field2 = new OcrResponse.ImageResult.Field( + "text", null, 0.95, "normal", false + ); + OcrResponse.ImageResult.Field field3 = new OcrResponse.ImageResult.Field( + "text", "World", 0.95, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1, field2, field3) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Hello World"); + } + + @Test + @DisplayName("confidence가 null인 필드는 제외") + void toPlainText_SkipsNullConfidence() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Hello", 0.95, "normal", false + ); + OcrResponse.ImageResult.Field field2 = new OcrResponse.ImageResult.Field( + "text", "Skip", null, "normal", false + ); + OcrResponse.ImageResult.Field field3 = new OcrResponse.ImageResult.Field( + "text", "World", 0.95, "normal", true + ); + OcrResponse.ImageResult image = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1, field2, field3) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEqualTo("Hello World"); + assertThat(result).doesNotContain("Skip"); + } + + @Test + @DisplayName("빈 페이지 처리") + void toPlainText_HandlesEmptyPages() { + // given + OcrResponse.ImageResult emptyImage = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of() + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(emptyImage)); + + // when + String result = OcrTextExtractor.toPlainText(response); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("toPages 메서드 테스트") + void toPages_ReturnsListOfPageTexts() { + // given + OcrResponse.ImageResult.Field field1 = new OcrResponse.ImageResult.Field( + "text", "Page1", 0.95, "normal", true + ); + OcrResponse.ImageResult.Field field2 = new OcrResponse.ImageResult.Field( + "text", "Page2", 0.95, "normal", true + ); + OcrResponse.ImageResult image1 = new OcrResponse.ImageResult( + "img1", "page1", "SUCCESS", null, List.of(field1) + ); + OcrResponse.ImageResult image2 = new OcrResponse.ImageResult( + "img2", "page2", "SUCCESS", null, List.of(field2) + ); + OcrResponse response = OcrResponse.create("V2", "req1", List.of(image1, image2)); + + // when + List pages = OcrTextExtractor.toPages(response); + + // then + assertThat(pages).hasSize(2); + assertThat(pages.get(0)).isEqualTo("Page1"); + assertThat(pages.get(1)).isEqualTo("Page2"); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/PdfUtilsUnitTest.java b/src/test/java/starlight/adapter/ncp/ocr/util/PdfUtilsUnitTest.java new file mode 100644 index 00000000..f822afbe --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/util/PdfUtilsUnitTest.java @@ -0,0 +1,248 @@ +package starlight.adapter.ncp.ocr.util; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.adapter.ncp.ocr.exception.OcrErrorType; +import starlight.adapter.ncp.ocr.exception.OcrException; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("PdfUtils 테스트") +class PdfUtilsUnitTest { + + @Test + @DisplayName("페이지 수가 maxPagesPerChunk 이하면 분할하지 않음") + void splitByPageLimit_NoSplit_WhenPagesLessThanLimit() throws IOException { + // given + byte[] pdfBytes = createTestPdf(5); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(1); + assertThat(chunks.get(0)).isEqualTo(pdfBytes); + } + + @Test + @DisplayName("정확히 maxPagesPerChunk 페이지면 분할하지 않음") + void splitByPageLimit_NoSplit_WhenPagesExactlyLimit() throws IOException { + // given + byte[] pdfBytes = createTestPdf(10); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(1); + assertThat(chunks.get(0)).isEqualTo(pdfBytes); + } + + @Test + @DisplayName("11페이지 PDF는 2개 청크로 분할") + void splitByPageLimit_TwoChunks_When11Pages() throws IOException { + // given + byte[] pdfBytes = createTestPdf(11); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(2); + assertThat(getPdfPageCount(chunks.get(0))).isEqualTo(10); + assertThat(getPdfPageCount(chunks.get(1))).isEqualTo(1); + } + + @Test + @DisplayName("20페이지 PDF는 2개 청크로 분할") + void splitByPageLimit_TwoChunks_When20Pages() throws IOException { + // given + byte[] pdfBytes = createTestPdf(20); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(2); + assertThat(getPdfPageCount(chunks.get(0))).isEqualTo(10); + assertThat(getPdfPageCount(chunks.get(1))).isEqualTo(10); + } + + @Test + @DisplayName("25페이지 PDF는 3개 청크로 분할") + void splitByPageLimit_ThreeChunks_When25Pages() throws IOException { + // given + byte[] pdfBytes = createTestPdf(25); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(3); + assertThat(getPdfPageCount(chunks.get(0))).isEqualTo(10); + assertThat(getPdfPageCount(chunks.get(1))).isEqualTo(10); + assertThat(getPdfPageCount(chunks.get(2))).isEqualTo(5); + } + + @Test + @DisplayName("1페이지 PDF 처리") + void splitByPageLimit_SinglePage() throws IOException { + // given + byte[] pdfBytes = createTestPdf(1); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(1); + assertThat(getPdfPageCount(chunks.get(0))).isEqualTo(1); + } + + @Test + @DisplayName("maxPagesPerChunk가 1일 때 각 페이지가 개별 청크로 분할") + void splitByPageLimit_OnePagePerChunk() throws IOException { + // given + byte[] pdfBytes = createTestPdf(3); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 1); + + // then + assertThat(chunks).hasSize(3); + assertThat(getPdfPageCount(chunks.get(0))).isEqualTo(1); + assertThat(getPdfPageCount(chunks.get(1))).isEqualTo(1); + assertThat(getPdfPageCount(chunks.get(2))).isEqualTo(1); + } + + @Test + @DisplayName("잘못된 PDF 바이트 입력 시 예외 발생") + void splitByPageLimit_ThrowsException_WhenInvalidPdfBytes() { + // given + byte[] invalidPdfBytes = "not a pdf".getBytes(); + + // when & then + assertThatThrownBy(() -> PdfUtils.splitByPageLimit(invalidPdfBytes, 10)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_SPLIT_ERROR); + } + + @Test + @DisplayName("빈 바이트 배열 입력 시 예외 발생") + void splitByPageLimit_ThrowsException_WhenEmptyBytes() { + // given + byte[] emptyBytes = new byte[0]; + + // when & then + assertThatThrownBy(() -> PdfUtils.splitByPageLimit(emptyBytes, 10)) + .isInstanceOf(OcrException.class) + .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_SPLIT_ERROR); + } + + @Test + @DisplayName("큰 PDF 분할 - 100페이지") + void splitByPageLimit_LargePdf_100Pages() throws IOException { + // given + byte[] pdfBytes = createTestPdf(100); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(10); + for (byte[] chunk : chunks) { + assertThat(getPdfPageCount(chunk)).isEqualTo(10); + } + } + + @Test + @DisplayName("분할된 청크들의 총 페이지 수는 원본과 동일") + void splitByPageLimit_TotalPagesMatchOriginal() throws IOException { + // given + int originalPages = 33; + byte[] pdfBytes = createTestPdf(originalPages); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + int totalPages = chunks.stream() + .mapToInt(this::getPdfPageCount) + .sum(); + assertThat(totalPages).isEqualTo(originalPages); + } + + @Test + @DisplayName("maxPagesPerChunk가 원본보다 훨씬 클 때") + void splitByPageLimit_LimitMuchLargerThanPages() throws IOException { + // given + byte[] pdfBytes = createTestPdf(5); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 1000); + + // then + assertThat(chunks).hasSize(1); + assertThat(getPdfPageCount(chunks.get(0))).isEqualTo(5); + } + + @Test + @DisplayName("각 청크는 유효한 PDF 문서") + void splitByPageLimit_EachChunkIsValidPdf() throws IOException { + // given + byte[] pdfBytes = createTestPdf(15); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + for (byte[] chunk : chunks) { + try (PDDocument doc = PDDocument.load(chunk)) { + assertThat(doc.getNumberOfPages()).isGreaterThan(0); + } + } + } + + @Test + @DisplayName("페이지 순서 유지 확인") + void splitByPageLimit_MaintainsPageOrder() throws IOException { + // given + byte[] pdfBytes = createTestPdf(25); + + // when + List chunks = PdfUtils.splitByPageLimit(pdfBytes, 10); + + // then + assertThat(chunks).hasSize(3); + assertThat(getPdfPageCount(chunks.get(0))).isEqualTo(10); // 1-10페이지 + assertThat(getPdfPageCount(chunks.get(1))).isEqualTo(10); // 11-20페이지 + assertThat(getPdfPageCount(chunks.get(2))).isEqualTo(5); // 21-25페이지 + } + + // 헬퍼 메서드 + private byte[] createTestPdf(int pageCount) throws IOException { + try (PDDocument document = new PDDocument()) { + for (int i = 0; i < pageCount; i++) { + document.addPage(new PDPage()); + } + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + document.save(out); + return out.toByteArray(); + } + } + } + + private int getPdfPageCount(byte[] pdfBytes) { + try (PDDocument doc = PDDocument.load(pdfBytes)) { + return doc.getNumberOfPages(); + } catch (IOException e) { + throw new RuntimeException("Failed to load PDF", e); + } + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/PrivateConstructorTests.java b/src/test/java/starlight/adapter/ncp/ocr/util/PrivateConstructorTests.java new file mode 100644 index 00000000..1513ae9a --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/ocr/util/PrivateConstructorTests.java @@ -0,0 +1,88 @@ +package starlight.adapter.ncp.ocr.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("유틸리티 클래스 Private 생성자 테스트") +class PrivateConstructorTests { + + @Test + @DisplayName("OcrResponseMerger는 private 생성자를 가진다") + void ocrResponseMerger_HasPrivateConstructor() throws NoSuchMethodException { + // given + Constructor constructor = OcrResponseMerger.class.getDeclaredConstructor(); + + // then + assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue(); + } + + @Test + @DisplayName("OcrTextExtractor는 private 생성자를 가진다") + void ocrTextExtractor_HasPrivateConstructor() throws NoSuchMethodException { + // given + Constructor constructor = OcrTextExtractor.class.getDeclaredConstructor(); + + // then + assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue(); + } + + @Test + @DisplayName("PdfUtils는 private 생성자를 가진다") + void pdfUtils_HasPrivateConstructor() throws NoSuchMethodException { + // given + Constructor constructor = PdfUtils.class.getDeclaredConstructor(); + + // then + assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue(); + } + + @Test + @DisplayName("OcrResponseMerger는 인스턴스화할 수 없다 (리플렉션 테스트)") + void ocrResponseMerger_CannotInstantiateViaReflection() throws Exception { + // given + Constructor constructor = OcrResponseMerger.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + // when + Object instance = constructor.newInstance(); + + // then - 인스턴스는 생성되지만 사용할 수 없음을 확인 + assertThat(instance).isNotNull(); + assertThat(instance).isInstanceOf(OcrResponseMerger.class); + } + + @Test + @DisplayName("OcrTextExtractor는 인스턴스화할 수 없다 (리플렉션 테스트)") + void ocrTextExtractor_CannotInstantiateViaReflection() throws Exception { + // given + Constructor constructor = OcrTextExtractor.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + // when + Object instance = constructor.newInstance(); + + // then + assertThat(instance).isNotNull(); + assertThat(instance).isInstanceOf(OcrTextExtractor.class); + } + + @Test + @DisplayName("PdfUtils는 인스턴스화할 수 없다 (리플렉션 테스트)") + void pdfUtils_CannotInstantiateViaReflection() throws Exception { + // given + Constructor constructor = PdfUtils.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + // when + Object instance = constructor.newInstance(); + + // then + assertThat(instance).isNotNull(); + assertThat(instance).isInstanceOf(PdfUtils.class); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/ncp/storage/NcpPresignedUrlProviderUnitTest.java new file mode 100644 index 00000000..7e7426d7 --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/storage/NcpPresignedUrlProviderUnitTest.java @@ -0,0 +1,180 @@ +package starlight.adapter.ncp.storage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +import java.net.URL; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NcpPresignedUrlProvider 단위 테스트") +class NcpPresignedUrlProviderUnitTest { + + @Mock + private S3Client ncpS3Client; + + @Mock + private S3Presigner s3Presigner; + + @InjectMocks + private NcpPresignedUrlProvider presignedUrlProvider; + + private static final String BUCKET_NAME = "test-bucket"; + private static final String ENDPOINT = "https://kr.object.ncloudstorage.com"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(presignedUrlProvider, "bucket", BUCKET_NAME); + ReflectionTestUtils.setField(presignedUrlProvider, "endpoint", ENDPOINT); + } + + @Test + @DisplayName("Presigned URL 생성 성공") + void getPreSignedUrl_Success() throws Exception { + // given + Long userId = 1L; + String fileName = "test-image.jpg"; + String expectedKey = "1/test-image.jpg"; + URL mockUrl = new URL("https://test-bucket.kr.object.ncloudstorage.com/presigned-url"); + + PresignedPutObjectRequest mockPresignedRequest = mock(PresignedPutObjectRequest.class); + given(mockPresignedRequest.url()).willReturn(mockUrl); + given(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))) + .willReturn(mockPresignedRequest); + + // when + PreSignedUrlResponse response = presignedUrlProvider.getPreSignedUrl(userId, fileName); + + // then + assertThat(response).isNotNull(); + assertThat(response.preSignedUrl()).contains("presigned-url"); + assertThat(response.objectUrl()).contains(BUCKET_NAME); + assertThat(response.objectUrl()).contains(expectedKey); + + verify(s3Presigner).presignPutObject(any(PutObjectPresignRequest.class)); + } + + @Test + @DisplayName("파일명에 공백이 있을 때 URL 인코딩 처리") + void getPreSignedUrl_WithSpaceInFileName() throws Exception { + // given + Long userId = 1L; + String fileName = "test image.jpg"; + URL mockUrl = new URL("https://test-bucket.kr.object.ncloudstorage.com/presigned-url"); + + PresignedPutObjectRequest mockPresignedRequest = mock(PresignedPutObjectRequest.class); + given(mockPresignedRequest.url()).willReturn(mockUrl); + given(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))) + .willReturn(mockPresignedRequest); + + // when + PreSignedUrlResponse response = presignedUrlProvider.getPreSignedUrl(userId, fileName); + + // then + assertThat(response.objectUrl()).contains("test%20image.jpg"); + verify(s3Presigner).presignPutObject(any(PutObjectPresignRequest.class)); + } + + @Test + @DisplayName("파일명에 특수문자가 있을 때 URL 인코딩 처리") + void getPreSignedUrl_WithSpecialCharacters() throws Exception { + // given + Long userId = 1L; + String fileName = "test#파일.jpg"; + URL mockUrl = new URL("https://test-bucket.kr.object.ncloudstorage.com/presigned-url"); + + PresignedPutObjectRequest mockPresignedRequest = mock(PresignedPutObjectRequest.class); + given(mockPresignedRequest.url()).willReturn(mockUrl); + given(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))) + .willReturn(mockPresignedRequest); + + // when + PreSignedUrlResponse response = presignedUrlProvider.getPreSignedUrl(userId, fileName); + + // then + assertThat(response.objectUrl()).doesNotContain("#"); + assertThat(response.objectUrl()).contains("%"); + } + + @Test + @DisplayName("객체 공개 처리 성공") + void makePublic_Success() { + // given + String objectUrl = "https://test-bucket.kr.object.ncloudstorage.com/1/test-image.jpg"; + + given(ncpS3Client.putObjectAcl(any(PutObjectAclRequest.class))) + .willReturn(PutObjectAclResponse.builder().build()); + + // when + String result = presignedUrlProvider.makePublic(objectUrl); + + // then + assertThat(result).isEqualTo(objectUrl); + + // ArgumentCaptor를 사용한 상세 검증 + ArgumentCaptor captor = ArgumentCaptor.forClass(PutObjectAclRequest.class); + verify(ncpS3Client).putObjectAcl(captor.capture()); + + PutObjectAclRequest capturedRequest = captor.getValue(); + assertThat(capturedRequest.bucket()).isEqualTo(BUCKET_NAME); + assertThat(capturedRequest.key()).isEqualTo("1/test-image.jpg"); + assertThat(capturedRequest.acl()).isEqualTo(ObjectCannedACL.PUBLIC_READ); + } + + @Test + @DisplayName("객체 공개 처리 실패 - S3Exception") + void makePublic_Failure_S3Exception() { + // given + String objectUrl = "https://test-bucket.kr.object.ncloudstorage.com/1/test-image.jpg"; + + given(ncpS3Client.putObjectAcl(any(PutObjectAclRequest.class))) + .willThrow(S3Exception.builder().message("Access Denied").build()); + + // when & then + assertThatThrownBy(() -> presignedUrlProvider.makePublic(objectUrl)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("객체 공개 처리 실패"); + + verify(ncpS3Client).putObjectAcl(any(PutObjectAclRequest.class)); + } + + @Test + @DisplayName("잘못된 URL 형식 - 스킴 없음") + void makePublic_InvalidUrl_NoScheme() { + // given + String invalidUrl = "test-bucket.kr.object.ncloudstorage.com/1/test-image.jpg"; + + // when & then + assertThatThrownBy(() -> presignedUrlProvider.makePublic(invalidUrl)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("잘못된 URL 형식"); + } + + @Test + @DisplayName("잘못된 URL 형식 - 경로 없음") + void makePublic_InvalidUrl_NoPath() { + // given + String invalidUrl = "https://test-bucket.kr.object.ncloudstorage.com"; + + // when & then + assertThatThrownBy(() -> presignedUrlProvider.makePublic(invalidUrl)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("path가 없습니다"); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/adapter/ncp/webapi/ImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/ncp/webapi/ImageControllerIntegrationTest.java new file mode 100644 index 00000000..7d26dcf6 --- /dev/null +++ b/src/test/java/starlight/adapter/ncp/webapi/ImageControllerIntegrationTest.java @@ -0,0 +1,156 @@ +package starlight.adapter.ncp.webapi; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.MediaType; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import starlight.adapter.auth.security.auth.AuthDetails; +import starlight.adapter.auth.security.filter.JwtFilter; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; +import starlight.application.infrastructure.provided.PresignedUrlProvider; +import starlight.bootstrap.SecurityConfig; + +import java.util.List; + +import static org.mockito.BDDMockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + controllers = ImageController.class, + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + JwtFilter.class, + SecurityConfig.class + }) + } +) +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("ImageController 통합 테스트") +class ImageControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PresignedUrlProvider presignedUrlProvider; + + @MockitoBean + JpaMetamodelMappingContext jpaMetamodelMappingContext; + + private AuthDetails createMockAuthDetails(Long memberId) { + Member mockMember = mock(Member.class); + given(mockMember.getId()).willReturn(memberId); + given(mockMember.getEmail()).willReturn("test@example.com"); + given(mockMember.getMemberType()).willReturn(MemberType.FOUNDER); + return new AuthDetails(mockMember); + } + +// @Test +// @DisplayName("GET /v1/images/upload-url - Presigned URL 조회 성공") +// @WithMockUser // (선택) user(...)와 중복이면 제거 가능 +// void getPresignedUrl_Success() throws Exception { +// // given +// Long userId = 1L; +// String fileName = "test-image.jpg"; +// String preSignedUrl = "https://test-bucket.kr.object.ncloudstorage.com/presigned-url"; +// String objectUrl = "https://test-bucket.kr.object.ncloudstorage.com/1/test-image.jpg"; +// +// PreSignedUrlResponse response = PreSignedUrlResponse.of(preSignedUrl, objectUrl); +// given(presignedUrlProvider.getPreSignedUrl(userId, fileName)).willReturn(response); +// +// // when & then +// mockMvc.perform(get("/v1/images/upload-url") +// .with(user(createMockAuthDetails(userId))) +// .param("fileName", fileName) +// .contentType(MediaType.APPLICATION_JSON)) +// .andDo(print()) +// .andExpect(status().isOk()) +// .andExpect(jsonPath("$.result").value("SUCCESS")) +// .andExpect(jsonPath("$.data.preSignedUrl").value(preSignedUrl)) +// .andExpect(jsonPath("$.data.objectUrl").value(objectUrl)); +// +// verify(presignedUrlProvider).getPreSignedUrl(userId, fileName); +// } + + @Test + @DisplayName("GET /v1/images/upload-url - fileName 누락 시 400 에러") + void getPresignedUrl_MissingFileName() throws Exception { + // when & then + mockMvc.perform(get("/v1/images/upload-url") + .param("userId", "1") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()); + + verify(presignedUrlProvider, never()).getPreSignedUrl(any(), any()); + } + + @Test + @DisplayName("POST /v1/images/upload-url/public - 이미지 공개 처리 성공") + void finalizePublic_Success() throws Exception { + // given + String objectUrl = "https://test-bucket.kr.object.ncloudstorage.com/1/test-image.jpg"; + given(presignedUrlProvider.makePublic(objectUrl)).willReturn(objectUrl); + + // when & then + mockMvc.perform(post("/v1/images/upload-url/public") + .param("objectUrl", objectUrl) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result").value("SUCCESS")) + .andExpect(jsonPath("$.data").value(objectUrl)); + + verify(presignedUrlProvider).makePublic(objectUrl); + } + + @Test + @DisplayName("POST /v1/images/upload-url/public - objectUrl 누락 시 400 에러") + void finalizePublic_MissingObjectUrl() throws Exception { + // when & then + mockMvc.perform(post("/v1/images/upload-url/public") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isBadRequest()); + + verify(presignedUrlProvider, never()).makePublic(any()); + } + + @Test + @DisplayName("POST /v1/images/upload-url/public - 잘못된 URL 형식으로 예외 발생") + void finalizePublic_InvalidUrl() throws Exception { + // given + String invalidUrl = "invalid-url"; + given(presignedUrlProvider.makePublic(invalidUrl)) + .willThrow(new IllegalArgumentException("잘못된 URL 형식")); + + // when & then + mockMvc.perform(post("/v1/images/upload-url/public") + .param("objectUrl", invalidUrl) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().is4xxClientError()); + + verify(presignedUrlProvider).makePublic(invalidUrl); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java new file mode 100644 index 00000000..f52c326f --- /dev/null +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java @@ -0,0 +1,404 @@ +package starlight.application.aireport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import starlight.adapter.ai.util.AiReportResponseParser; +import starlight.adapter.aireport.persistence.AiReportJpa; +import starlight.adapter.aireport.persistence.AiReportRepository; +import starlight.adapter.businessplan.persistence.BusinessPlanJpa; +import starlight.adapter.businessplan.persistence.BusinessPlanRepository; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.application.aireport.required.AiReportGrader; +import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.infrastructure.provided.OcrProvider; +import starlight.domain.aireport.entity.AiReport; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.entity.SubSection; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@Import({AiReportServiceImpl.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceImplIntegrationTest.TestBeans.class}) +@DisplayName("AiReportServiceImpl 통합 테스트") +class AiReportServiceImplIntegrationTest { + + @Autowired + AiReportServiceImpl sut; + @Autowired + BusinessPlanRepository businessPlanRepository; + @Autowired + AiReportRepository aiReportRepository; + @Autowired + EntityManager em; + @Autowired + ObjectMapper objectMapper; + @Autowired + AiReportResponseParser responseParser; + + @TestConfiguration + static class TestBeans { + + @Bean + AiReportGrader aiReportGrader() { + return content -> { + // 간단한 mock 응답 반환 + return AiReportResponse.fromGradingResult( + 20, 25, 30, 20, + List.of(new AiReportResponse.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), + List.of(new AiReportResponse.StrengthWeakness("강점1", "내용1")), + List.of(new AiReportResponse.StrengthWeakness("약점1", "내용1")) + ); + }; + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + AiReportResponseParser responseParser() { + return new AiReportResponseParser(new ObjectMapper()); + } + + @Bean + BusinessPlanService businessPlanService(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanService() { + @Override + public starlight.application.businessplan.provided.dto.BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, org.springframework.data.domain.Pageable pageable) { + throw new UnsupportedOperationException("Not implemented in test"); + } + @Override + public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { + BusinessPlan plan = BusinessPlan.create("default title", memberId); + BusinessPlan saved = businessPlanRepository.save(plan); + return BusinessPlanResponse.Result.from(saved, "Business plan created"); + } + + @Override + public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); + BusinessPlan saved = businessPlanRepository.save(plan); + return BusinessPlanResponse.Result.from(saved, "PDF Business plan created"); + } + + @Override + public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public starlight.application.businessplan.provided.dto.SubSectionResponse.Result upsertSubSection( + Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, List checks, + starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public starlight.application.businessplan.provided.dto.SubSectionResponse.Detail getSubSectionDetail( + Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public List checkAndUpdateSubSection(Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, + starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public starlight.application.businessplan.provided.dto.SubSectionResponse.Result deleteSubSection( + Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + throw new UnsupportedOperationException("Not implemented in test"); + } + }; + } + + @Bean + OcrProvider ocrProvider() { + return new OcrProvider() { + @Override + public starlight.shared.dto.infrastructure.OcrResponse ocrPdfByUrl(String pdfUrl) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public String ocrPdfTextByUrl(String pdfUrl) { + return "PDF에서 추출한 텍스트 내용입니다. 이것은 테스트용 OCR 결과입니다."; + } + }; + } + + @Bean + BusinessPlanContentExtractor businessPlanContentExtractor() { + return new BusinessPlanContentExtractor(); + } + } + + /** + * BusinessPlan에 모든 서브섹션을 생성하여 작성 완료 상태로 만듦 + */ + private void createAllSubSections(BusinessPlan plan) { + // Overview + SubSection overviewBasic = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", List.of(false, false, false, false, false)); + plan.getOverview().putSubSection(overviewBasic); + + // ProblemRecognition + SubSection problemBackground = SubSection.create(SubSectionType.PROBLEM_BACKGROUND, "content", "{}", List.of(false, false, false, false, false)); + SubSection problemPurpose = SubSection.create(SubSectionType.PROBLEM_PURPOSE, "content", "{}", List.of(false, false, false, false, false)); + SubSection problemMarket = SubSection.create(SubSectionType.PROBLEM_MARKET, "content", "{}", List.of(false, false, false, false, false)); + plan.getProblemRecognition().putSubSection(problemBackground); + plan.getProblemRecognition().putSubSection(problemPurpose); + plan.getProblemRecognition().putSubSection(problemMarket); + + // Feasibility + SubSection feasibilityStrategy = SubSection.create(SubSectionType.FEASIBILITY_STRATEGY, "content", "{}", List.of(false, false, false, false, false)); + SubSection feasibilityMarket = SubSection.create(SubSectionType.FEASIBILITY_MARKET, "content", "{}", List.of(false, false, false, false, false)); + plan.getFeasibility().putSubSection(feasibilityStrategy); + plan.getFeasibility().putSubSection(feasibilityMarket); + + // GrowthTactic + SubSection growthModel = SubSection.create(SubSectionType.GROWTH_MODEL, "content", "{}", List.of(false, false, false, false, false)); + SubSection growthFunding = SubSection.create(SubSectionType.GROWTH_FUNDING, "content", "{}", List.of(false, false, false, false, false)); + SubSection growthEntry = SubSection.create(SubSectionType.GROWTH_ENTRY, "content", "{}", List.of(false, false, false, false, false)); + plan.getGrowthTactic().putSubSection(growthModel); + plan.getGrowthTactic().putSubSection(growthFunding); + plan.getGrowthTactic().putSubSection(growthEntry); + + // TeamCompetence + SubSection teamFounder = SubSection.create(SubSectionType.TEAM_FOUNDER, "content", "{}", List.of(false, false, false, false, false)); + SubSection teamMembers = SubSection.create(SubSectionType.TEAM_MEMBERS, "content", "{}", List.of(false, false, false, false, false)); + plan.getTeamCompetence().putSubSection(teamFounder); + plan.getTeamCompetence().putSubSection(teamMembers); + } + + @Test + @DisplayName("채점 성공 시 새로운 AiReport를 생성하고 저장한다") + void gradeBusinessPlan_createsNewReport() { + // given + Long memberId = 1L; + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); + createAllSubSections(plan); + businessPlanRepository.save(plan); + em.flush(); + em.clear(); + + Long planId = plan.getId(); + + // when + AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isNotNull(); + assertThat(result.businessPlanId()).isEqualTo(planId); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + + // DB에 저장되었는지 확인 + Optional savedReport = aiReportRepository.findByBusinessPlanId(planId); + assertThat(savedReport).isPresent(); + assertThat(savedReport.get().getBusinessPlanId()).isEqualTo(planId); + + // Plan 상태가 변경되었는지 확인 + BusinessPlan updatedPlan = businessPlanRepository.findById(planId).orElseThrow(); + assertThat(updatedPlan.getPlanStatus()).isEqualTo(PlanStatus.AI_REVIEWED); + } + + @Test + @DisplayName("기존 리포트가 있으면 업데이트한다") + void gradeBusinessPlan_updatesExistingReport() { + // given + Long memberId = 1L; + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); + createAllSubSections(plan); + businessPlanRepository.save(plan); + em.flush(); + em.clear(); + + Long planId = plan.getId(); + + // 첫 번째 채점 + AiReportResponse firstResult = sut.gradeBusinessPlan(planId, memberId); + em.flush(); + em.clear(); + + // 두 번째 채점 (업데이트) + AiReportResponse secondResult = sut.gradeBusinessPlan(planId, memberId); + + // then + assertThat(secondResult).isNotNull(); + assertThat(secondResult.id()).isEqualTo(firstResult.id()); // 같은 ID + assertThat(secondResult.businessPlanId()).isEqualTo(planId); + + // DB에 하나만 존재하는지 확인 + List reports = aiReportRepository.findAll(); + assertThat(reports).hasSize(1); + } + + @Test + @DisplayName("리포트 조회 성공 시 AiReportResponse를 반환한다") + void getAiReport_returnsResponse() { + // given + Long memberId = 1L; + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); + createAllSubSections(plan); + businessPlanRepository.save(plan); + em.flush(); + em.clear(); + + Long planId = plan.getId(); + + // 채점하여 리포트 생성 + sut.gradeBusinessPlan(planId, memberId); + em.flush(); + em.clear(); + + // when + AiReportResponse result = sut.getAiReport(planId, memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isNotNull(); + assertThat(result.businessPlanId()).isEqualTo(planId); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + } + + @Test + @DisplayName("convertToJsonNode와 toResponse가 올바르게 동작한다") + void convertToJsonNode_and_toResponse_workCorrectly() { + // given + Long memberId = 1L; + BusinessPlan plan = businessPlanRepository.save(BusinessPlan.create("default title", memberId)); + createAllSubSections(plan); + businessPlanRepository.save(plan); + em.flush(); + em.clear(); + + Long planId = plan.getId(); + + // 채점하여 리포트 생성 + AiReportResponse gradingResult = sut.gradeBusinessPlan(planId, memberId); + em.flush(); + em.clear(); + + // when - 조회 + AiReportResponse retrievedResult = sut.getAiReport(planId, memberId); + + // then - 저장된 데이터와 조회된 데이터가 일치하는지 확인 + assertThat(retrievedResult.problemRecognitionScore()).isEqualTo(gradingResult.problemRecognitionScore()); + assertThat(retrievedResult.feasibilityScore()).isEqualTo(gradingResult.feasibilityScore()); + assertThat(retrievedResult.growthStrategyScore()).isEqualTo(gradingResult.growthStrategyScore()); + assertThat(retrievedResult.teamCompetenceScore()).isEqualTo(gradingResult.teamCompetenceScore()); + assertThat(retrievedResult.totalScore()).isEqualTo(gradingResult.totalScore()); + assertThat(retrievedResult.strengths()).hasSize(gradingResult.strengths().size()); + assertThat(retrievedResult.weaknesses()).hasSize(gradingResult.weaknesses().size()); + assertThat(retrievedResult.sectionScores()).hasSize(gradingResult.sectionScores().size()); + } + + @Test + @DisplayName("PDF URL을 기반으로 사업계획서를 생성하고 AI 리포트를 생성한다") + void createAndGradePdfBusinessPlan_createsBusinessPlanAndReport() { + // given + Long memberId = 1L; + String title = "테스트 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + + // when + AiReportResponse result = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isNotNull(); + assertThat(result.businessPlanId()).isNotNull(); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + + // BusinessPlan이 생성되었는지 확인 + BusinessPlan createdPlan = businessPlanRepository.findById(result.businessPlanId()).orElseThrow(); + assertThat(createdPlan.getTitle()).isEqualTo(title); + assertThat(createdPlan.getPdfUrl()).isEqualTo(pdfUrl); + assertThat(createdPlan.getMemberId()).isEqualTo(memberId); + assertThat(createdPlan.getPlanStatus()).isEqualTo(PlanStatus.AI_REVIEWED); + + // AiReport가 생성되었는지 확인 + Optional savedReport = aiReportRepository.findByBusinessPlanId(result.businessPlanId()); + assertThat(savedReport).isPresent(); + assertThat(savedReport.get().getBusinessPlanId()).isEqualTo(result.businessPlanId()); + } + + @Test + @DisplayName("PDF 기반으로 생성한 사업계획서의 리포트를 조회할 수 있다") + void createAndGradePdfBusinessPlan_canRetrieveReport() { + // given + Long memberId = 1L; + String title = "테스트 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + + // when - PDF로 사업계획서 생성 및 채점 + AiReportResponse createdResult = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + Long planId = createdResult.businessPlanId(); + em.flush(); + em.clear(); + + // when - 리포트 조회 + AiReportResponse retrievedResult = sut.getAiReport(planId, memberId); + + // then + assertThat(retrievedResult).isNotNull(); + assertThat(retrievedResult.id()).isEqualTo(createdResult.id()); + assertThat(retrievedResult.businessPlanId()).isEqualTo(planId); + assertThat(retrievedResult.totalScore()).isEqualTo(95); + assertThat(retrievedResult.problemRecognitionScore()).isEqualTo(createdResult.problemRecognitionScore()); + assertThat(retrievedResult.feasibilityScore()).isEqualTo(createdResult.feasibilityScore()); + assertThat(retrievedResult.growthStrategyScore()).isEqualTo(createdResult.growthStrategyScore()); + assertThat(retrievedResult.teamCompetenceScore()).isEqualTo(createdResult.teamCompetenceScore()); + } +} + diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java new file mode 100644 index 00000000..ffdcdf3f --- /dev/null +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java @@ -0,0 +1,250 @@ +package starlight.application.aireport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.adapter.ai.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.application.aireport.required.AiReportGrader; +import starlight.application.aireport.required.AiReportQuery; +import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.infrastructure.provided.OcrProvider; +import starlight.domain.aireport.entity.AiReport; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.shared.valueobject.RawJson; + +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.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("AiReportServiceImpl 유닛 테스트") +class AiReportServiceImplUnitTest { + + private final BusinessPlanQuery businessPlanQuery = mock(BusinessPlanQuery.class); + private final BusinessPlanService businessPlanService = mock(BusinessPlanService.class); + private final AiReportQuery aiReportQuery = mock(AiReportQuery.class); + private final AiReportGrader aiReportGrader = mock(AiReportGrader.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final OcrProvider ocrProvider = mock(OcrProvider.class); + private final AiReportResponseParser responseParser = new AiReportResponseParser(objectMapper); + private final BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); + + private AiReportServiceImpl sut; + + @Test + @DisplayName("채점 성공 시 새로운 AiReport를 생성하고 저장한다") + void gradeBusinessPlan_createsNewReport() { + // given + Long planId = 1L; + Long memberId = 1L; + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.getId()).thenReturn(planId); + when(plan.isOwnedBy(memberId)).thenReturn(true); + when(plan.areWritingCompleted()).thenReturn(true); + when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); + + String extractedContent = "사업계획서 내용"; + when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); + + AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + 20, 25, 30, 20, + List.of(), + List.of(), + List.of() + ); + when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + + String rawJson = """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 25, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "sectionScores": [], + "strengths": [], + "weaknesses": [] + } + """; + AiReport savedReport = mock(AiReport.class); + when(savedReport.getId()).thenReturn(1L); + when(savedReport.getBusinessPlanId()).thenReturn(planId); + when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); + when(aiReportQuery.save(any(AiReport.class))).thenReturn(savedReport); + + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + + // when + AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + + // then + assertThat(result).isNotNull(); + verify(plan).updateStatus(PlanStatus.AI_REVIEWED); + verify(aiReportQuery).save(any(AiReport.class)); + } + + @Test + @DisplayName("기존 리포트가 있으면 업데이트한다") + void gradeBusinessPlan_updatesExistingReport() { + // given + Long planId = 1L; + Long memberId = 1L; + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.getId()).thenReturn(planId); + when(plan.isOwnedBy(memberId)).thenReturn(true); + when(plan.areWritingCompleted()).thenReturn(true); + when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + + AiReport existingReport = mock(AiReport.class); + when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(existingReport)); + + String extractedContent = "사업계획서 내용"; + when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); + + AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + 20, 25, 30, 20, + List.of(), + List.of(), + List.of() + ); + when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + + String rawJson = """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 25, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "sectionScores": [], + "strengths": [], + "weaknesses": [] + } + """; + when(existingReport.getId()).thenReturn(1L); + when(existingReport.getBusinessPlanId()).thenReturn(planId); + when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); + when(aiReportQuery.save(existingReport)).thenReturn(existingReport); + + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + + // when + AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + + // then + assertThat(result).isNotNull(); + verify(existingReport).update(anyString()); + // 기존 리포트가 있어도 상태는 AI_REVIEWED로 갱신됨 + verify(plan).updateStatus(PlanStatus.AI_REVIEWED); + } + + @Test + @DisplayName("소유자가 아니면 예외를 던진다") + void gradeBusinessPlan_throwsExceptionWhenNotOwner() { + // given + Long planId = 1L; + Long memberId = 1L; + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(memberId)).thenReturn(false); + when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + + // when & then + assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.UNAUTHORIZED_ACCESS); + } + + @Test + @DisplayName("작성 완료되지 않았으면 예외를 던진다") + void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { + // given + Long planId = 1L; + Long memberId = 1L; + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(memberId)).thenReturn(true); + when(plan.areWritingCompleted()).thenReturn(false); + when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + + // when & then + assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.NOT_READY_FOR_AI_REPORT); + } + + @Test + @DisplayName("리포트 조회 성공 시 AiReportResponse를 반환한다") + void getAiReport_returnsResponse() { + // given + Long planId = 1L; + Long memberId = 1L; + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.getId()).thenReturn(planId); + when(plan.isOwnedBy(memberId)).thenReturn(true); + when(plan.areWritingCompleted()).thenReturn(true); + when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + + String rawJson = """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 25, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "sectionScores": [], + "strengths": [], + "weaknesses": [] + } + """; + AiReport aiReport = mock(AiReport.class); + when(aiReport.getId()).thenReturn(1L); + when(aiReport.getBusinessPlanId()).thenReturn(planId); + when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); + when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); + + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + + // when + AiReportResponse result = sut.getAiReport(planId, memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(1L); + assertThat(result.businessPlanId()).isEqualTo(planId); + } + + @Test + @DisplayName("리포트가 없으면 예외를 던진다") + void getAiReport_throwsExceptionWhenNotFound() { + // given + Long planId = 1L; + Long memberId = 1L; + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.getId()).thenReturn(planId); + when(plan.isOwnedBy(memberId)).thenReturn(true); + when(plan.areWritingCompleted()).thenReturn(true); + when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); + + sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + + // when & then + assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) + .isInstanceOf(AiReportException.class) + .extracting("errorType") + .isEqualTo(AiReportErrorType.AI_REPORT_NOT_FOUND); + } +} + diff --git a/src/test/java/starlight/application/auth/AuthServiceImplIntegrationTest.java b/src/test/java/starlight/application/auth/AuthServiceImplIntegrationTest.java new file mode 100644 index 00000000..bf8603b1 --- /dev/null +++ b/src/test/java/starlight/application/auth/AuthServiceImplIntegrationTest.java @@ -0,0 +1,155 @@ +package starlight.application.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.auth.webapi.dto.request.SignInRequest; +import starlight.adapter.auth.webapi.dto.response.MemberResponse; +import starlight.application.auth.required.KeyValueMap; +import starlight.application.auth.required.TokenProvider; +import starlight.application.member.provided.CredentialService; +import starlight.application.member.provided.MemberService; +import starlight.domain.auth.exception.AuthException; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; +import starlight.domain.member.exception.MemberException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringBootTest +@TestPropertySource(properties = { + "jwt.token.refresh-expiration-time=3600" +}) +class AuthServiceImplIntegrationTest { + + @MockitoBean MemberService memberService; + @MockitoBean CredentialService credentialService; + @MockitoBean TokenProvider tokenProvider; + @MockitoBean KeyValueMap redisClient; + + @Autowired AuthServiceImpl sut; + + @Test + void signUp_정상_자격증명_생성후_회원생성_리턴() { + AuthRequest req = mock(AuthRequest.class); + Credential cred = mock(Credential.class); + Member member = Member.create("name", "u@ex.com", null, MemberType.FOUNDER, null, "img.png"); + + when(credentialService.createCredential(req)).thenReturn(cred); + when(memberService.createUser(cred, req)).thenReturn(member); + + MemberResponse res = sut.signUp(req); + + verify(credentialService).createCredential(req); + verify(memberService).createUser(cred, req); + assertNotNull(res); + } + + @Test + void signIn_정상_토큰생성_리프레시_Redis저장() { + SignInRequest req = new SignInRequest("a@b.com", "pw"); + Member member = Member.create("test", "a@b.com", null, MemberType.FOUNDER, null, "img.png"); + TokenResponse token = new TokenResponse("AT", "RT"); + + when(memberService.getUserByEmail("a@b.com")).thenReturn(member); + // 비밀번호 검증은 side-effect만 확인 + doNothing().when(credentialService).checkPassword(member, "pw"); + when(tokenProvider.createToken(member)).thenReturn(token); + + TokenResponse out = sut.signIn(req); + + verify(credentialService).checkPassword(member, "pw"); + verify(redisClient).setValue("a@b.com", "RT", 3600L); + assertEquals("AT", out.accessToken()); + assertEquals("RT", out.refreshToken()); + } + + @Test + void signIn_비번오류_전파() { + SignInRequest req = new SignInRequest("a@b.com", "bad"); + Member member = Member.create("test", "a@b.com", null, MemberType.FOUNDER, null, "img.png"); + + when(memberService.getUserByEmail("a@b.com")).thenReturn(member); + doThrow(new AuthException(starlight.domain.auth.exception.AuthErrorType.TOKEN_INVALID)) + .when(credentialService).checkPassword(member, "bad"); + + assertThrows(AuthException.class, () -> sut.signIn(req)); + verify(tokenProvider, never()).createToken(any()); + verify(redisClient, never()).setValue(any(), any(), anyLong()); + } + + @Test + void signOut_null토큰이면_TOKEN_NOT_FOUND() { + assertThrows(AuthException.class, () -> sut.signOut(null, "AT")); + assertThrows(AuthException.class, () -> sut.signOut("RT", null)); + verify(tokenProvider, never()).invalidateTokens(any(), any()); + } + + @Test + void signOut_AccessToken_유효성_실패면_TOKEN_INVALID() { + when(tokenProvider.validateToken("BAD_AT")).thenReturn(false); + assertThrows(AuthException.class, () -> sut.signOut("RT", "BAD_AT")); + verify(tokenProvider, never()).invalidateTokens(any(), any()); + } + + @Test + void signOut_정상_무효화호출() { + when(tokenProvider.validateToken("GOOD_AT")).thenReturn(true); + doNothing().when(tokenProvider).invalidateTokens("RT", "GOOD_AT"); + + assertDoesNotThrow(() -> sut.signOut("RT", "GOOD_AT")); + verify(tokenProvider).invalidateTokens("RT", "GOOD_AT"); + } + + @Test + void recreate_token_null이면_TOKEN_NOT_FOUND() { + Member member = Member.create("m", "m@ex.com", null, MemberType.FOUNDER, null, "img.png"); + assertThrows(AuthException.class, () -> sut.recreate(null, member)); + } + + @Test + void recreate_member_null이면_MEMBER_NOT_FOUND() { + assertThrows(MemberException.class, () -> sut.recreate("Bearer RT", null)); + } + + @Test + void recreate_refresh_유효성_실패면_TOKEN_INVALID() { + when(tokenProvider.validateToken("REAL_RT")).thenReturn(false); + assertThrows(AuthException.class, () -> sut.recreate("Bearer REAL_RT", + Member.create("m","m@ex.com", null, MemberType.FOUNDER, null, "img.png"))); + } + + @Test + void recreate_Redis저장값과_불일치면_TOKEN_NOT_FOUND() { + Member member = Member.create("m","m@ex.com", null, MemberType.FOUNDER, null, "img.png"); + + when(tokenProvider.validateToken("REAL_RT")).thenReturn(true); + when(tokenProvider.getEmail("REAL_RT")).thenReturn("m@ex.com"); + when(redisClient.getValue("m@ex.com")).thenReturn("OTHER_RT"); // 불일치 + + assertThrows(AuthException.class, () -> sut.recreate("Bearer REAL_RT", member)); + verify(tokenProvider, never()).recreate(any(), anyString()); + } + + @Test + void recreate_정상_재발급성공() { + Member member = Member.create("m","m@ex.com", null, MemberType.FOUNDER, null, "img.png"); + TokenResponse recreated = new TokenResponse("NEW_AT", "SAME_OR_NEW_RT"); + + when(tokenProvider.validateToken("REAL_RT")).thenReturn(true); + when(tokenProvider.getEmail("REAL_RT")).thenReturn("m@ex.com"); + when(redisClient.getValue("m@ex.com")).thenReturn("REAL_RT"); + when(tokenProvider.recreate(member, "REAL_RT")).thenReturn(recreated); + + TokenResponse out = sut.recreate("Bearer REAL_RT", member); + + assertEquals("NEW_AT", out.accessToken()); + verify(tokenProvider).recreate(member, "REAL_RT"); + } +} diff --git a/src/test/java/starlight/application/auth/AuthServiceImplUnitTest.java b/src/test/java/starlight/application/auth/AuthServiceImplUnitTest.java new file mode 100644 index 00000000..f07d6d26 --- /dev/null +++ b/src/test/java/starlight/application/auth/AuthServiceImplUnitTest.java @@ -0,0 +1,71 @@ +package starlight.application.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import starlight.adapter.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.auth.webapi.dto.request.SignInRequest; +import starlight.application.auth.required.KeyValueMap; +import starlight.application.auth.required.TokenProvider; +import starlight.application.member.provided.CredentialService; +import starlight.application.member.provided.MemberService; +import starlight.domain.auth.exception.AuthException; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceImplUnitTest { + + @Mock MemberService memberService; + @Mock CredentialService credentialService; + @Mock TokenProvider tokenProvider; + @Mock KeyValueMap redisClient; + + @InjectMocks AuthServiceImpl sut; + + @BeforeEach + void init() { + ReflectionTestUtils.setField(sut, "refreshTokenExpirationTime", 3600L); // @Value 대체 + } + + @Test + void signIn_정상() { + SignInRequest req = new SignInRequest("a@b.com", "pw"); + Member member = Member.create("testName", "a@b.com", null, MemberType.FOUNDER, null, "image.png"); + TokenResponse token = new TokenResponse("AT", "RT"); + when(memberService.getUserByEmail("a@b.com")).thenReturn(member); + when(tokenProvider.createToken(member)).thenReturn(token); + + TokenResponse res = sut.signIn(req); + + verify(credentialService).checkPassword(member, "pw"); + verify(redisClient).setValue("a@b.com", "RT", 3600L); + assertEquals("AT", res.accessToken()); + } + + @Test + void signOut_AccessToken_유효성_실패면_예외() { + when(tokenProvider.validateToken("bad")).thenReturn(false); + assertThrows(AuthException.class, () -> sut.signOut("r", "bad")); + verify(tokenProvider, never()).invalidateTokens(any(), any()); + } + + @Test + void recreate_저장된_리프레시와_불일치면_예외() { + Member member = Member.create("testName", "a@b.com", null, MemberType.FOUNDER, null, "image.png"); + when(tokenProvider.validateToken("REAL_RT")).thenReturn(true); + when(tokenProvider.getEmail("REAL_RT")).thenReturn("a@b.com"); + when(redisClient.getValue("a@b.com")).thenReturn("OTHER_RT"); + + assertThrows(AuthException.class, + () -> sut.recreate("Bearer REAL_RT", member)); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java new file mode 100644 index 00000000..35fefd30 --- /dev/null +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java @@ -0,0 +1,119 @@ +package starlight.application.businessplan; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import starlight.adapter.businessplan.persistence.BusinessPlanJpa; +import starlight.adapter.businessplan.persistence.BusinessPlanRepository; +import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.member.required.MemberQuery; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.entity.SubSection; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.domain.member.entity.Member; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@Import({ BusinessPlanServiceImpl.class, BusinessPlanJpa.class, + BusinessPlanServiceImplIntegrationTest.TestBeans.class }) +class BusinessPlanServiceImplIntegrationTest { + + @Autowired + BusinessPlanServiceImpl sut; + @Autowired + BusinessPlanRepository businessPlanRepository; + @Autowired + EntityManager em; + + @TestConfiguration + static class TestBeans { + @Bean + ChecklistGrader checklistGrader() { + return (subSectionType, content) -> List.of(false, false, false, false, false); + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + @Bean + MemberQuery memberQuery() { + return new MemberQuery() { + @Override + public Member getOrThrow(Long memberId) { + Member m = mock(Member.class); + when(m.getName()).thenReturn("tester"); + return m; + } + }; + } + } + + @Test + void create_and_update_title_and_delete_with_subsections_cleanup() { + // create + var createdPreview = sut.createBusinessPlan(1L); + Long planId = createdPreview.businessPlanId(); + assertThat(planId).isNotNull(); + + // attach a subsection to overview + SubSection s1 = SubSection.create(SubSectionType.OVERVIEW_BASIC, "c", "{}", + List.of(false, false, false, false, false)); + BusinessPlan createdEntity = businessPlanRepository.findById(planId).orElseThrow(); + createdEntity.getOverview().putSubSection(s1); + businessPlanRepository.save(createdEntity); + em.flush(); + em.clear(); + + // sanity: persisted - Overview를 통해 SubSection이 있는지 확인 + BusinessPlan reloaded = businessPlanRepository.findById(planId).orElseThrow(); + assertThat(reloaded.getOverview().getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNotNull(); + + // update title + String updatedTitle = sut.updateBusinessPlanTitle(planId, "new-title", createdEntity.getMemberId()); + assertThat(updatedTitle).isEqualTo("new-title"); + + // delete plan -> cascade로 subsections도 함께 삭제 + sut.deleteBusinessPlan(planId, createdEntity.getMemberId()); + + // SubSection이 cascade로 삭제되었는지 확인 + BusinessPlan afterDelete = businessPlanRepository.findById(planId).orElse(null); + assertThat(afterDelete).isNull(); + } + + @Test + void createBusinessPlanWithPdf_createsPlanWithPdfInfo() { + // given + String title = "PDF 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + Long memberId = 1L; + + // when + var createdResult = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); + Long planId = createdResult.businessPlanId(); + + // then + assertThat(planId).isNotNull(); + assertThat(createdResult.title()).isEqualTo(title); + + BusinessPlan createdPlan = businessPlanRepository.findById(planId).orElseThrow(); + assertThat(createdPlan.getTitle()).isEqualTo(title); + assertThat(createdPlan.getPdfUrl()).isEqualTo(pdfUrl); + assertThat(createdPlan.getMemberId()).isEqualTo(memberId); + assertThat(createdPlan.getPlanStatus()) + .isEqualTo(starlight.domain.businessplan.enumerate.PlanStatus.WRITTEN_COMPLETED); + } +} diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java new file mode 100644 index 00000000..4c579585 --- /dev/null +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java @@ -0,0 +1,568 @@ +package starlight.application.businessplan; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.businessplan.provided.dto.SubSectionResponse; +import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.required.ChecklistGrader; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.entity.Overview; +import starlight.domain.businessplan.entity.SubSection; +import starlight.domain.businessplan.entity.BaseSection; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.domain.businessplan.exception.BusinessPlanException; +import starlight.shared.enumerate.SectionType; +import starlight.application.member.required.MemberQuery; +import starlight.domain.member.entity.Member; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) +class BusinessPlanServiceImplUnitTest { + + @Mock + private BusinessPlanQuery businessPlanQuery; + + @Mock + private ChecklistGrader checklistGrader; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private MemberQuery memberQuery; + + @InjectMocks + private BusinessPlanServiceImpl sut; + + private BusinessPlan buildPlanWithSections(Long memberId) { + return BusinessPlan.create("default title", memberId); + } + + @BeforeEach + void setup() { + when(objectMapper.valueToTree(any())) + .thenReturn(new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode()); + try { + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + } catch (Exception ignored) { + } + // memberQuery 기본 스텁 + Member stubMember = mock(Member.class); + when(stubMember.getName()).thenReturn("tester"); + when(memberQuery.getOrThrow(anyLong())).thenReturn(stubMember); + } + + @Test + @DisplayName("사업계획서 생성 시 루트가 저장된다") + void createBusinessPlan_savesRoot() { + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + BusinessPlanResponse.Result created = sut.createBusinessPlan(1L); + + assertThat(created).isNotNull(); + assertThat(created.message()).isEqualTo("Business plan created"); + verify(businessPlanQuery).save(any(BusinessPlan.class)); + } + + @Test + @DisplayName("PDF URL을 기반으로 사업계획서를 생성하면 저장된다") + void createBusinessPlanWithPdf_savesRoot() { + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + String title = "테스트 사업계획서"; + String pdfUrl = "https://example.com/test.pdf"; + Long memberId = 1L; + + BusinessPlanResponse.Result created = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); + + assertThat(created).isNotNull(); + assertThat(created.message()).isEqualTo("PDF Business plan created"); + assertThat(created.title()).isEqualTo(title); + verify(businessPlanQuery).save(any(BusinessPlan.class)); + } + + @Test + @DisplayName("사업계획서 제목 수정은 소유자 검증 후 저장한다") + void updateTitle_checksOwnership_thenSaves() { + BusinessPlan plan = spy(buildPlanWithSections(10L)); + doReturn(true).when(plan).isOwnedBy(10L); + when(businessPlanQuery.getOrThrow(100L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + String updatedTitle = sut.updateBusinessPlanTitle(100L, "new-title", 10L); + + assertThat(updatedTitle).isEqualTo("new-title"); + verify(businessPlanQuery).save(plan); + } + + @Test + @DisplayName("제목 수정 - 소유자 아님이면 예외") + void updateTitle_unauthorized_throws() { + BusinessPlan plan = spy(buildPlanWithSections(20L)); + doReturn(false).when(plan).isOwnedBy(10L); + when(businessPlanQuery.getOrThrow(100L)).thenReturn(plan); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.updateBusinessPlanTitle(100L, "title", 10L)); + } + + @Test + @DisplayName("사업계획서 삭제 시 cascade로 SubSection이 함께 삭제된다") + void deleteBusinessPlan_cascadeDeletesSubSections() { + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(10L)).thenReturn(true); + when(plan.getId()).thenReturn(100L); + when(businessPlanQuery.getOrThrow(100L)).thenReturn(plan); + + BusinessPlanResponse.Result deleted = sut.deleteBusinessPlan(100L, 10L); + + assertThat(deleted).isNotNull(); + assertThat(deleted.businessPlanId()).isEqualTo(100L); + assertThat(deleted.message()).isEqualTo("Business plan deleted"); + + verify(businessPlanQuery).delete(plan); + } + + @Test + @DisplayName("서브섹션 생성: 없으면 신규 생성 후 부모 섹션에 연결하여 저장") + void upsertSubSection_creates_whenNotExists() { + // given + BusinessPlan plan = buildPlanWithSections(10L); + Overview overview = plan.getOverview(); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); + jsonNode.putArray("content"); + when(objectMapper.valueToTree(any())).thenReturn(jsonNode); + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } + + // when + List checks = List.of(false, false, false, false, false); + SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.OVERVIEW_BASIC, 10L); + + // then + assertThat(res).isNotNull(); + assertThat(res.message()).isEqualTo("Subsection created"); + assertThat(res.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); + assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNotNull(); + assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC).getSubSectionType()) + .isEqualTo(SubSectionType.OVERVIEW_BASIC); + verify(businessPlanQuery).save(plan); + } + + @Test + @DisplayName("서브섹션 생성: 기존 존재하면 업데이트 경로") + void upsertSubSection_updates_whenExists() { + BusinessPlan plan = buildPlanWithSections(10L); + Overview overview = plan.getOverview(); + + // 기존 SubSection 생성 및 설정 + SubSection existing = SubSection.create(SubSectionType.OVERVIEW_BASIC, "old", "{}", + List.of(false, false, false, false, false)); + overview.putSubSection(existing); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); + jsonNode.putArray("content"); + when(objectMapper.valueToTree(any())).thenReturn(jsonNode); + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } + + List checks = List.of(false, false, false, false, false); + SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.OVERVIEW_BASIC, 10L); + + assertThat(res.message()).isEqualTo("Subsection updated"); + verify(businessPlanQuery).save(plan); + } + + @Test + @DisplayName("서브섹션 생성: 소유자 아님이면 예외") + void upsertSubSection_unauthorized_throws() { + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(10L)).thenReturn(false); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); + jsonNode.putArray("content"); + List checks = List.of(false, false, false, false, false); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L)); + } + + @Test + @DisplayName("서브섹션 조회: 상세 정보를 반환한다") + void getSubSectionDetail_returnsContent() { + BusinessPlan plan = buildPlanWithSections(10L); + Overview overview = plan.getOverview(); + + SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", + List.of(true, false, true, false, true)); + overview.putSubSection(sub); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + SubSectionResponse.Detail detail = sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L); + + assertThat(detail).isNotNull(); + assertThat(detail.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); + assertThat(detail.content()).isNotNull(); + } + + @Test + @DisplayName("서브섹션 조회: 없으면 예외") + void getSubSectionDetail_notFound_throws() { + BusinessPlan plan = buildPlanWithSections(10L); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L)); + } + + @Test + @DisplayName("서브섹션 조회: 소유자 아님이면 예외") + void getSubSectionDetail_unauthorized_throws() { + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(10L)).thenReturn(false); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L)); + } + + @Test + @DisplayName("서브섹션 삭제: 정상 삭제") + void deleteSubSection_success() { + BusinessPlan plan = buildPlanWithSections(10L); + Overview overview = plan.getOverview(); + + SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "content", "{}", + List.of(false, false, false, false, false)); + overview.putSubSection(sub); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + SubSectionResponse.Result res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); + + assertThat(res).isNotNull(); + assertThat(res.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); + assertThat(res.subSectionId()).isNull(); + assertThat(res.message()).isEqualTo("Subsection deleted"); + assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNull(); + verify(businessPlanQuery).save(plan); + } + + @Test + @DisplayName("서브섹션 삭제: 소유자 아님이면 예외") + void deleteSubSection_unauthorized_throws() { + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(10L)).thenReturn(false); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L)); + } + + @Test + @DisplayName("사업계획서 목록 조회(PreviewPage): 매핑 필드를 올바르게 반환한다") + void getBusinessPlanList_returnsPreviewPage() { + // given + BusinessPlan plan = buildPlanWithSections(1L); + Pageable pageable = PageRequest.of(1, 3); // 내부 0-base 가정, 여기선 1페이지(=두번째) 요청 + when(businessPlanQuery.findPreviewPage(any(Long.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(plan), pageable, 7)); + + // when + BusinessPlanResponse.PreviewPage res = sut.getBusinessPlanList(1L, pageable); + + // then + assertThat(res.totalElements()).isEqualTo(7); + assertThat(res.size()).isEqualTo(3); + assertThat(res.page()).isEqualTo(pageable.getPageNumber() + 1); // 1-base + assertThat(res.totalPages()).isEqualTo((int) Math.ceil(7 / 3.0)); + assertThat(res.numberOfElements()).isEqualTo(1); + assertThat(res.content()).hasSize(1); + assertThat(res.content().get(0).businessPlanId()).isEqualTo(plan.getId()); + verify(businessPlanQuery).findPreviewPage(any(Long.class), any(Pageable.class)); + } + + @Test + @DisplayName("사업계획서 전체 서브섹션을 조회하면 존재하는 서브섹션만 반환한다") + void getBusinessPlanSubSections_returnsExistingSubSectionList() { + BusinessPlan plan = buildPlanWithSections(10L); + + SubSection overview = SubSection.create(SubSectionType.OVERVIEW_BASIC, "overview", "{\"text\":\"overview\"}", + List.of(false, false, false, false, false)); + plan.getOverview().putSubSection(overview); + + SubSection problem = SubSection.create(SubSectionType.PROBLEM_BACKGROUND, "problem", "{\"text\":\"problem\"}", + List.of(false, false, false, false, false)); + plan.getProblemRecognition().putSubSection(problem); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + BusinessPlanResponse.Detail detail = sut.getBusinessPlanDetail(1L, 10L); + + assertThat(detail.title()).isEqualTo(plan.getTitle()); + assertThat(detail.subSectionDetailList()).hasSize(2); + assertThat(detail.subSectionDetailList()) + .extracting(SubSectionResponse.Detail::subSectionType) + .containsExactly(SubSectionType.OVERVIEW_BASIC, SubSectionType.PROBLEM_BACKGROUND); + assertThat(detail.subSectionDetailList().get(0).content().path("text").asText()).isEqualTo("overview"); + assertThat(detail.subSectionDetailList().get(1).content().path("text").asText()).isEqualTo("problem"); + } + + @Test + @DisplayName("사업계획서 전체 서브섹션 조회: 소유자 아님이면 예외") + void getBusinessPlanDetail_unauthorized_throws() { + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(10L)).thenReturn(false); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.getBusinessPlanDetail(1L, 10L)); + } + + @Test + @DisplayName("서브섹션 체크: 체크리스트가 저장된다") + void checkAndUpdateSubSection_savesChecks() { + BusinessPlan plan = buildPlanWithSections(10L); + Overview overview = plan.getOverview(); + + List previousChecks = List.of(false, false, false, false, false); + SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "previous-content", "{}", previousChecks); + overview.putSubSection(sub); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + List updatedChecks = List.of(true, true, true, true, true); + when(checklistGrader.check( + eq(SubSectionType.OVERVIEW_BASIC), + eq("updated content"))).thenReturn(updatedChecks); + + com.fasterxml.jackson.databind.ObjectMapper realObjectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = realObjectMapper.createObjectNode(); + jsonNode.putArray("content") + .addObject() + .put("type", "text") + .put("value", "updated content"); + jsonNode.putArray("checks") + .add(false) + .add(false) + .add(false) + .add(false) + .add(false); + + when(objectMapper.valueToTree(any())).thenReturn(jsonNode); + try { + when(objectMapper.writeValueAsString(any())).thenReturn(jsonNode.toString()); + } catch (Exception ignored) { + } + + List result = sut.checkAndUpdateSubSection(1L, jsonNode, SubSectionType.OVERVIEW_BASIC, 10L); + + assertThat(result).containsExactlyElementsOf(updatedChecks); + assertThat(sub.getChecks()).containsExactlyElementsOf(updatedChecks); + assertThat(sub.getContent()).isEqualTo("updated content"); + verify(businessPlanQuery).save(plan); + } + + @Test + @DisplayName("서브섹션 체크: 없으면 예외") + void checkAndUpdateSubSection_notFound_throws() { + BusinessPlan plan = buildPlanWithSections(10L); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + JsonNode node = mock(JsonNode.class); + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.checkAndUpdateSubSection(1L, node, SubSectionType.OVERVIEW_BASIC, 10L)); + } + + @Test + @DisplayName("서브섹션 체크: 소유자 아님이면 예외") + void checkAndUpdateSubSection_unauthorized_throws() { + BusinessPlan plan = mock(BusinessPlan.class); + when(plan.isOwnedBy(10L)).thenReturn(false); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + + JsonNode node = mock(JsonNode.class); + org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, + () -> sut.checkAndUpdateSubSection(1L, node, SubSectionType.OVERVIEW_BASIC, 10L)); + } + + @Test + @DisplayName("섹션 매핑: 각 Section 타입별로 올바르게 SubSection이 저장된다") + void createSubSection_forEachSectionType() { + BusinessPlan plan = buildPlanWithSections(10L); + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); + jsonNode.putArray("content"); + when(objectMapper.valueToTree(any())).thenReturn(jsonNode); + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } + + List checks = List.of(false, false, false, false, false); + SubSectionResponse.Result r1 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.PROBLEM_BACKGROUND, 10L); + SubSectionResponse.Result r2 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionType.FEASIBILITY_STRATEGY, 10L); + SubSectionResponse.Result r3 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.GROWTH_MODEL, + 10L); + SubSectionResponse.Result r4 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_FOUNDER, + 10L); + + assertThat(r1.message()).isEqualTo("Subsection created"); + assertThat(r2.message()).isEqualTo("Subsection created"); + assertThat(r3.message()).isEqualTo("Subsection created"); + assertThat(r4.message()).isEqualTo("Subsection created"); + } + + @Test + @DisplayName("서브섹션 생성: 모든 서브섹션이 생성되면 상태가 DRAFTED로 변경된다") + void upsertSubSection_allSubSectionsCreated_updatesStatusToDrafted() { + // given + BusinessPlan plan = spy(buildPlanWithSections(10L)); + doReturn(true).when(plan).isOwnedBy(10L); + + // 모든 서브섹션을 생성 (마지막 하나만 남음) + List allTypes = List.of( + SubSectionType.OVERVIEW_BASIC, + SubSectionType.PROBLEM_BACKGROUND, SubSectionType.PROBLEM_PURPOSE, SubSectionType.PROBLEM_MARKET, + SubSectionType.FEASIBILITY_STRATEGY, SubSectionType.FEASIBILITY_MARKET, + SubSectionType.GROWTH_MODEL, SubSectionType.GROWTH_FUNDING, SubSectionType.GROWTH_ENTRY, + SubSectionType.TEAM_FOUNDER); + + for (SubSectionType type : allTypes) { + SubSection sub = SubSection.create(type, "content", "{}", List.of(false, false, false, false, false)); + getSectionByPlanAndType(plan, type.getSectionType()).putSubSection(sub); + } + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); + jsonNode.putArray("content"); + when(objectMapper.valueToTree(any())).thenReturn(jsonNode); + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } + + // when - 마지막 서브섹션 생성 + List checks = List.of(false, false, false, false, false); + sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_MEMBERS, 10L); + + // then - 상태가 WRITTEN_COMPLETED로 변경되어야 함 + verify(plan).updateStatus(starlight.domain.businessplan.enumerate.PlanStatus.WRITTEN_COMPLETED); + } + + @Test + @DisplayName("서브섹션 생성: 일부만 생성되면 상태가 변경되지 않는다") + void upsertSubSection_partialSubSections_noStatusChange() { + // given + BusinessPlan plan = spy(buildPlanWithSections(10L)); + doReturn(true).when(plan).isOwnedBy(10L); + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() + .createObjectNode(); + jsonNode.putArray("content"); + when(objectMapper.valueToTree(any())).thenReturn(jsonNode); + try { + when(objectMapper.writeValueAsString(eq(jsonNode))).thenReturn("{}"); + } catch (Exception ignored) { + } + + // when - 첫 번째 서브섹션만 생성 + List checks = List.of(false, false, false, false, false); + sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); + + // then - 상태가 변경되지 않아야 함 (모든 서브섹션이 생성되지 않았으므로) + verify(plan, never()).updateStatus(any()); + } + + @Test + @DisplayName("서브섹션 삭제: 삭제 시 상태 변경이 발생하지 않는다") + void deleteSubSection_noStatusChange() { + // given + BusinessPlan plan = spy(buildPlanWithSections(10L)); + doReturn(true).when(plan).isOwnedBy(10L); + + // 모든 서브섹션 생성 + for (SubSectionType type : SubSectionType.values()) { + SubSection sub = SubSection.create(type, "content", "{}", List.of(false, false, false, false, false)); + getSectionByPlanAndType(plan, type.getSectionType()).putSubSection(sub); + } + + when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.save(any(BusinessPlan.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when - 서브섹션 삭제 + sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); + + // then - 상태가 변경되지 않아야 함 + verify(plan, never()).updateStatus(any()); + } + + private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type) { + return switch (type) { + case OVERVIEW -> plan.getOverview(); + case PROBLEM_RECOGNITION -> plan.getProblemRecognition(); + case FEASIBILITY -> plan.getFeasibility(); + case GROWTH_STRATEGY -> plan.getGrowthTactic(); + case TEAM_COMPETENCE -> plan.getTeamCompetence(); + }; + } +} diff --git a/src/test/java/starlight/application/businessplan/util/PlainTextExtractUtilsTest.java b/src/test/java/starlight/application/businessplan/util/PlainTextExtractUtilsTest.java new file mode 100644 index 00000000..7b8e03ad --- /dev/null +++ b/src/test/java/starlight/application/businessplan/util/PlainTextExtractUtilsTest.java @@ -0,0 +1,114 @@ +package starlight.application.businessplan.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PlainTextExtractUtilsTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + @DisplayName("content 배열에서 text/image/table를 순서대로 줄글로 변환") + void extractPlainText_fromContentArray() { + String json = "{" + + "\"content\":[" + + "{\"type\":\"text\",\"value\":\"Hello\"}," + + "{\"type\":\"image\",\"caption\":\"cap\"}," + + "{\"type\":\"table\",\"columns\":[{\"width\":100},{\"width\":200}]," + + "\"rows\":[" + + "[{\"content\":[{\"type\":\"text\",\"value\":\"1\"}],\"rowspan\":1,\"colspan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"2\"}],\"rowspan\":1,\"colspan\":1}]," + + "[{\"content\":[{\"type\":\"text\",\"value\":\"3\"}],\"rowspan\":1,\"colspan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"4\"}],\"rowspan\":1,\"colspan\":1}]" + + "]" + + "}" + + "]}"; + + String result = PlainTextExtractUtils.extractPlainText(mapper, json); + + assertThat(result).isEqualTo(String.join("\n", + "Hello", + "[사진] cap", + "[2 columns]", + "[\"1\", \"2\"]", + "[\"3\", \"4\"]")); + } + + @Test + @DisplayName("blocks 내부 content를 모두 모아 순서대로 변환") + void extractPlainText_fromBlocks() { + String json = "{" + + "\"blocks\":[" + + "{\"content\":[{\"type\":\"text\",\"value\":\"A\"}]}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"B\"}]}" + + "]}"; + + String result = PlainTextExtractUtils.extractPlainText(mapper, json); + assertThat(result).isEqualTo("A\nB"); + } + + @Test + @DisplayName("image 캡션 없으면 [사진]만 출력") + void extractPlainText_imageWithoutCaption() { + String json = "{" + + "\"content\":[{\"type\":\"image\"}]" + + "}"; + + String result = PlainTextExtractUtils.extractPlainText(mapper, json); + assertThat(result).isEqualTo("[사진]"); + } + + @Test + @DisplayName("잘못된 JSON이면 예외") + void extractPlainText_invalidJson_throws() { + assertThrows(IllegalArgumentException.class, () -> PlainTextExtractUtils.extractPlainText(mapper, "not-json")); + } + + @Test + @DisplayName("테이블 셀에서 rowspan/colspan (lowercase) 지원") + void extractPlainText_tableWithLowercaseSpans() { + String json = "{" + + "\"content\":[" + + "{\"type\":\"table\",\"columns\":[{\"width\":100},{\"width\":200}]," + + "\"rows\":[" + + "[{\"content\":[{\"type\":\"text\",\"value\":\"A\"}],\"rowspan\":2,\"colspan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"B\"}],\"rowspan\":1,\"colspan\":1}]," + + "[]" + + "]" + + "}" + + "]}"; + + String result = PlainTextExtractUtils.extractPlainText(mapper, json); + + assertThat(result).isEqualTo(String.join("\n", + "[2 columns]", + "[\"A\", \"B\"]", + "[\"\", \"\"]")); + } + + @Test + @DisplayName("테이블 셀에서 rowSpan/colSpan (camelCase) 지원") + void extractPlainText_tableWithCamelCaseSpans() { + String json = "{" + + "\"content\":[" + + "{\"type\":\"table\",\"columns\":[{\"width\":100},{\"width\":200}]," + + "\"rows\":[" + + "[{\"content\":[{\"type\":\"text\",\"value\":\"X\"}],\"rowSpan\":2,\"colSpan\":1}," + + "{\"content\":[{\"type\":\"text\",\"value\":\"Y\"}],\"rowSpan\":1,\"colSpan\":1}]," + + "[{\"content\":[{\"type\":\"text\",\"value\":\"C\"}],\"rowSpan\":1,\"colSpan\":1}]" + + "]" + + "}" + + "]}"; + + String result = PlainTextExtractUtils.extractPlainText(mapper, json); + + assertThat(result).isEqualTo(String.join("\n", + "[2 columns]", + "[\"X\", \"Y\"]", + "[\"\", \"C\"]")); + } +} diff --git a/src/test/java/starlight/application/businessplan/util/SubSectionSupportUtilsTest.java b/src/test/java/starlight/application/businessplan/util/SubSectionSupportUtilsTest.java new file mode 100644 index 00000000..6ee2b4c0 --- /dev/null +++ b/src/test/java/starlight/application/businessplan/util/SubSectionSupportUtilsTest.java @@ -0,0 +1,44 @@ +package starlight.application.businessplan.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.domain.businessplan.exception.BusinessPlanException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SubSectionSupportUtilsTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + @DisplayName("JsonNode 직렬화 성공") + void serializeJsonNodeSafely_success() { + JsonNode node = mapper.createObjectNode().put("a", 1); + String json = SubSectionSupportUtils.serializeJsonNodeSafely(mapper, node); + assertThat(json).isEqualTo("{\"a\":1}"); + } + + @Test + @DisplayName("JsonNode null이면 예외") + void serializeJsonNodeSafely_null_throws() { + assertThrows(BusinessPlanException.class, () -> SubSectionSupportUtils.serializeJsonNodeSafely(mapper, null)); + } + + @Test + @DisplayName("체크리스트 크기 검증 성공") + void requireSize_ok() { + SubSectionSupportUtils.requireSize(List.of(true, false, true, false, true), 5); + } + + @Test + @DisplayName("체크리스트 크기 불일치 예외") + void requireSize_invalid_throws() { + assertThrows(BusinessPlanException.class, () -> SubSectionSupportUtils.requireSize(List.of(true, false), 5)); + } +} + diff --git a/src/test/java/starlight/application/expert/ExpertQueryServiceTest.java b/src/test/java/starlight/application/expert/ExpertQueryServiceTest.java new file mode 100644 index 00000000..b630f70f --- /dev/null +++ b/src/test/java/starlight/application/expert/ExpertQueryServiceTest.java @@ -0,0 +1,62 @@ +package starlight.application.expert; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import starlight.application.expert.required.ExpertQuery; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ExpertQueryServiceTest { + + @Mock ExpertQuery expertQueryPort; + @InjectMocks ExpertQueryService sut; // System Under Test + + @Test + @DisplayName("전체 조회는 포트의 findAllWithDetails를 호출한다") + void loadAll() throws Exception { + when(expertQueryPort.findAllWithDetails()).thenReturn(List.of(expert(1L))); + + var result = sut.loadAll(); + + assertThat(result).hasSize(1); + verify(expertQueryPort, times(1)).findAllWithDetails(); + } + + @Test + @DisplayName("카테고리 AND 매칭 조회는 포트의 findByAllCategories를 호출한다") + void findByAllCategories() throws Exception { + Set cats = Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY); + when(expertQueryPort.findByAllCategories(cats)).thenReturn(List.of(expert(2L))); + + var result = sut.findByAllCategories(cats); + + assertThat(result).hasSize(1); + ArgumentCaptor> captor = ArgumentCaptor.forClass(Set.class); + verify(expertQueryPort, times(1)).findByAllCategories(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrderElementsOf(cats); + } + + private Expert expert(Long id) throws Exception { + Constructor ctor = Expert.class.getDeclaredConstructor(); + ctor.setAccessible(true); + Expert e = ctor.newInstance(); + ReflectionTestUtils.setField(e, "id", id); + ReflectionTestUtils.setField(e, "name", "tester"); + ReflectionTestUtils.setField(e, "email", "t@example.com"); + return e; + } +} diff --git a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java new file mode 100644 index 00000000..9d441dbf --- /dev/null +++ b/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java @@ -0,0 +1,60 @@ +package starlight.application.member; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.member.persistence.CredentialRepository; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@Import({CredentialServiceImpl.class, CredentialServiceImplIntegrationTest.TestBeans.class}) +class CredentialServiceImplIntegrationTest { + + @TestConfiguration + static class TestBeans { + @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + } + + @Autowired CredentialServiceImpl sut; + @Autowired CredentialRepository credentialRepository; + @Autowired PasswordEncoder passwordEncoder; + + @Test + void createCredential_BCrypt로_해싱되고_DB에_저장된다() { + AuthRequest req = mock(AuthRequest.class); + when(req.password()).thenReturn("raw-pw"); + + Credential created = sut.createCredential(req); + assertNotNull(created.getPassword()); + assertTrue(passwordEncoder.matches("raw-pw", created.getPassword())); + + // 실제 DB에도 들어갔는지 확인 (id 존재 등) + assertNotNull(created.getId()); + assertTrue(credentialRepository.findById(created.getId()).isPresent()); + } + + @Test + void checkPassword_BCrypt_실제검증() { + // given: 해시를 먼저 만든 Credential + String hashed = passwordEncoder.encode("pw"); + Credential cred = Credential.create(hashed); + Member member = mock(Member.class); + when(member.getCredential()).thenReturn(cred); + + // when & then + assertDoesNotThrow(() -> sut.checkPassword(member, "pw")); + } +} diff --git a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java b/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java new file mode 100644 index 00000000..6ace923a --- /dev/null +++ b/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java @@ -0,0 +1,63 @@ +package starlight.application.member; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.member.persistence.CredentialRepository; +import starlight.domain.auth.exception.AuthException; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CredentialServiceImplUnitTest { + + @Mock PasswordEncoder passwordEncoder; + @Mock CredentialRepository credentialRepository; + + @InjectMocks CredentialServiceImpl sut; + + @Test + void createCredential_정상_해싱후_저장() { + AuthRequest req = mock(AuthRequest.class); + when(req.password()).thenReturn("raw-pw"); + when(passwordEncoder.encode("raw-pw")).thenReturn("HASHED"); + when(credentialRepository.save(any(Credential.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + Credential created = sut.createCredential(req); + + verify(passwordEncoder).encode("raw-pw"); + verify(credentialRepository).save(any(Credential.class)); + assertNotNull(created); + } + + @Test + void checkPassword_일치하면_예외없음() { + Member member = mock(Member.class); + Credential cred = mock(Credential.class); + when(member.getCredential()).thenReturn(cred); + when(cred.getPassword()).thenReturn("HASHED"); + when(passwordEncoder.matches("pw", "HASHED")).thenReturn(true); + + assertDoesNotThrow(() -> sut.checkPassword(member, "pw")); + verify(passwordEncoder).matches("pw", "HASHED"); + } + + @Test + void checkPassword_불일치면_AuthException() { + Member member = mock(Member.class); + Credential cred = mock(Credential.class); + when(member.getCredential()).thenReturn(cred); + when(cred.getPassword()).thenReturn("HASHED"); + when(passwordEncoder.matches("bad", "HASHED")).thenReturn(false); + + assertThrows(AuthException.class, () -> sut.checkPassword(member, "bad")); + } +} diff --git a/src/test/java/starlight/application/member/MemberServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/MemberServiceImplIntegrationTest.java new file mode 100644 index 00000000..ac7fc3dc --- /dev/null +++ b/src/test/java/starlight/application/member/MemberServiceImplIntegrationTest.java @@ -0,0 +1,69 @@ +package starlight.application.member; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.member.persistence.MemberRepository; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; +import starlight.domain.member.exception.MemberException; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@Import(MemberServiceImpl.class) +class MemberServiceImplIntegrationTest { + + @Autowired MemberServiceImpl sut; + @Autowired MemberRepository memberRepository; + + @Test + void getUserByEmail_DB저장후_정상조회() { + Member preSaved = Member.create("name", "hit@ex.com", null, MemberType.FOUNDER, null, "img.png"); + memberRepository.save(preSaved); + + Member found = sut.getUserByEmail("hit@ex.com"); + assertEquals("hit@ex.com", found.getEmail()); + } + + @Test + void getUserByEmail_DB에없으면_예외() { + assertThrows(MemberException.class, () -> sut.getUserByEmail("none@ex.com")); + } + + @Test + void createUser_중복이면_예외_바로발생() { + // 중복 방지 로직은 DB 유니크 제약과 별개로 서비스가 findByEmail로 막음 + memberRepository.save(Member.create("dup", "dup@ex.com", null, MemberType.FOUNDER, null, "img.png")); + + AuthRequest req = mock(AuthRequest.class); + when(req.email()).thenReturn("dup@ex.com"); + + Credential newCredential = Credential.create("anotherHashedPassword"); + assertThrows(MemberException.class, + () -> sut.createUser(newCredential, req)); + } + + @Test + void createUser_정상저장_DB반영() { + Credential cred = Credential.create("hashedPassword"); + AuthRequest req = mock(AuthRequest.class); + when(req.email()).thenReturn("ok@ex.com"); + when(req.toMember(cred)).thenReturn(Member.create("ok", "ok@ex.com", null, MemberType.FOUNDER, null, "img.png")); + + Member saved = sut.createUser(cred, req); + + Optional found = memberRepository.findByEmail("ok@ex.com"); + assertTrue(found.isPresent()); + assertEquals(saved.getId(), found.get().getId()); + } +} diff --git a/src/test/java/starlight/application/member/MemberServiceImplUnitTest.java b/src/test/java/starlight/application/member/MemberServiceImplUnitTest.java new file mode 100644 index 00000000..edabfdcd --- /dev/null +++ b/src/test/java/starlight/application/member/MemberServiceImplUnitTest.java @@ -0,0 +1,69 @@ +package starlight.application.member; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import starlight.adapter.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.member.persistence.MemberRepository; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.exception.MemberException; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MemberServiceImplUnitTest { + + @Mock MemberRepository memberRepository; + @InjectMocks MemberServiceImpl sut; + + @Test + void createUser_중복이메일이면_예외() { + AuthRequest req = mock(AuthRequest.class); + when(req.email()).thenReturn("dup@ex.com"); + when(memberRepository.findByEmail("dup@ex.com")) + .thenReturn(Optional.of(mock(Member.class))); + + assertThrows(MemberException.class, + () -> sut.createUser(mock(Credential.class), req)); + verify(memberRepository, never()).save(any()); + } + + @Test + void createUser_정상저장() { + AuthRequest req = mock(AuthRequest.class); + Credential cred = mock(Credential.class); + Member mapped = mock(Member.class); + Member saved = mock(Member.class); + + when(req.email()).thenReturn("ok@ex.com"); + when(memberRepository.findByEmail("ok@ex.com")) + .thenReturn(Optional.empty()); + when(req.toMember(cred)).thenReturn(mapped); + when(memberRepository.save(mapped)).thenReturn(saved); + + Member result = sut.createUser(cred, req); + + verify(memberRepository).save(mapped); + assertSame(saved, result); + } + + @Test + void getUserByEmail_없으면_예외() { + when(memberRepository.findByEmail("none@ex.com")).thenReturn(Optional.empty()); + assertThrows(MemberException.class, () -> sut.getUserByEmail("none@ex.com")); + } + + @Test + void getUserByEmail_정상반환() { + Member m = mock(Member.class); + when(memberRepository.findByEmail("hit@ex.com")).thenReturn(Optional.of(m)); + assertSame(m, sut.getUserByEmail("hit@ex.com")); + } +} diff --git a/src/test/java/starlight/application/member/required/ContextLoadTest.java b/src/test/java/starlight/application/member/required/ContextLoadTest.java new file mode 100644 index 00000000..215b46fc --- /dev/null +++ b/src/test/java/starlight/application/member/required/ContextLoadTest.java @@ -0,0 +1,27 @@ +package starlight.application.member.required; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +@SpringBootTest +class ContextLoadTest { + + @Test + @DisplayName("Spring ApplicationContext 로드 및 빈 확인") + void printAllBeans(@Autowired ApplicationContext ctx) { + System.out.println("=== ALL BEANS ==="); + for (String name : ctx.getBeanDefinitionNames()) { + Object bean = ctx.getBean(name); + System.out.println(name + " -> " + bean.getClass().getName()); + } + + // 특정 빈 확인 + System.out.println("\n=== CHECK SPECIFIC BEANS ==="); + System.out.println("spellCheckClient exists: " + ctx.containsBean("spellCheckClient")); + System.out.println("spellCheckUtil exists: " + ctx.containsBean("spellCheckUtil")); + System.out.println("daumSpellChecker exists: " + ctx.containsBean("daumSpellChecker")); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java b/src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java new file mode 100644 index 00000000..359575e7 --- /dev/null +++ b/src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java @@ -0,0 +1,116 @@ +package starlight.application.member.required; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import starlight.adapter.businessplan.spellcheck.DaumSpellChecker; +import starlight.adapter.businessplan.spellcheck.dto.Finding; +import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@RestClientTest(components = DaumSpellChecker.class) +@Import(DaumSpellCheckerHttpTest.TestConfig.class) +class DaumSpellCheckerHttpTest { + + @Autowired DaumSpellChecker daumSpellChecker; + @Autowired MockRestServiceServer server; + + // JPA가 올라오지 못하도록 + @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @TestConfiguration + static class TestConfig { + + @Bean SpellCheckUtil spellCheckUtil() { return new SpellCheckUtil(); } + + @Bean RestClient spellCheckClient(RestClient.Builder builder) { + return builder + .baseUrl("http://localhost") + .defaultHeader(HttpHeaders.USER_AGENT, "Mozilla/5.0") + .defaultHeader(HttpHeaders.ACCEPT_LANGUAGE, "ko,en;q=0.9") + .build(); + } + } + + @Test + @DisplayName("check - 유효한 응답 파싱") + void check_parsesValidResponse() { + + //given + String html = """ + + + + + + + """; + + server.expect(once(), requestTo("http://localhost/grammar_checker.do")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED)) + .andRespond(withSuccess(html, MediaType.TEXT_HTML)); + + // when + List findings = daumSpellChecker.check("안뇽하세요"); + + // then + assertThat(findings).hasSize(1); + Finding f = findings.get(0); + assertThat(f.token()).isEqualTo("안뇽하세요"); + assertThat(f.suggestions()).contains("안녕하세요"); + assertThat(f.severity()).isEqualTo("error"); + + server.verify(); + } + + @Test + @DisplayName("check - 429 Too Many Requests 처리") + void check_handlesRateLimitOrError() { + + //given + server.expect(once(), requestTo("http://localhost/grammar_checker.do")) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS)); + + // when // then + assertThatThrownBy(() -> daumSpellChecker.check("안뇽하세요")) + .isInstanceOf(RestClientException.class); + } +} diff --git a/src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java b/src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java new file mode 100644 index 00000000..97ceed6f --- /dev/null +++ b/src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java @@ -0,0 +1,59 @@ +package starlight.application.member.required; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClient; +import starlight.adapter.businessplan.spellcheck.DaumSpellChecker; +import starlight.adapter.businessplan.spellcheck.dto.Finding; +import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class DaumSpellCheckerLogicTest { + + @Mock RestClient restClient; + @Spy SpellCheckUtil spellCheckUtil = new SpellCheckUtil(); + @InjectMocks DaumSpellChecker daumSpellChecker; + + @Test + @DisplayName("applyTopSuggestions - 최상위 교정 제안 적용") + void applyTopSuggestions_picksTopCandidate() { + + // given + String original = "안뇽하세요"; + List findings = List.of( + new Finding("spell","error","안뇽하세요", + List.of("안녕하세요","안녕 하세요"), + "맞춤법 오류","안뇽하세요","안녕하세요","도움말", List.of()) + ); + + // when + String corrected = daumSpellChecker.applyTopSuggestions(original, findings); + + // then + assertThat(corrected).isEqualTo("안녕하세요"); + } + + @Test + @DisplayName("applyTopSuggestions - 교정 제안 없을 시 원문 반환") + void applyTopSuggestions_returnsOriginalWhenNoFindings() { + + // given + String original = "안녕하세요"; + + // when + String corrected = daumSpellChecker.applyTopSuggestions(original, List.of()); + + // then + assertThat(corrected).isEqualTo(original); + } +} + diff --git a/src/test/java/starlight/bootstrap/SecurityConfigTest.java b/src/test/java/starlight/bootstrap/SecurityConfigTest.java new file mode 100644 index 00000000..52c2e910 --- /dev/null +++ b/src/test/java/starlight/bootstrap/SecurityConfigTest.java @@ -0,0 +1,44 @@ +package starlight.bootstrap; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import starlight.adapter.auth.security.filter.ExceptionFilter; +import starlight.adapter.auth.security.filter.JwtFilter; +import starlight.adapter.auth.security.handler.JwtAccessDeniedHandler; +import starlight.adapter.auth.security.handler.JwtAuthenticationHandler; +import starlight.adapter.auth.security.oauth2.CustomOAuth2UserService; +import starlight.adapter.auth.security.oauth2.OAuth2SuccessHandler; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(SecurityConfig.class) +class SecurityConfigTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean private JwtFilter jwtFilter; + @MockitoBean private ExceptionFilter exceptionFilter; + @MockitoBean private JwtAccessDeniedHandler jwtAccessDeniedHandler; + @MockitoBean private JwtAuthenticationHandler jwtAuthenticationEntryPoint; + @MockitoBean private CustomOAuth2UserService oAuth2UserService; + @MockitoBean private OAuth2SuccessHandler oAuth2SuccessHandler; + + @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; + + @Test + @WithMockUser + void oauth2Login_실패_핸들러_401_반환() throws Exception { + mockMvc.perform(get("/login/oauth2/code/kakao") + .param("error", "invalid_token")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/starlight/domain/aireport/entity/AiReportTest.java b/src/test/java/starlight/domain/aireport/entity/AiReportTest.java new file mode 100644 index 00000000..ae4b0659 --- /dev/null +++ b/src/test/java/starlight/domain/aireport/entity/AiReportTest.java @@ -0,0 +1,198 @@ +package starlight.domain.aireport.entity; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.shared.valueobject.RawJson; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AiReportTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("AiReport를 정상적으로 생성할 수 있다") + void create_success() { + // given + Long businessPlanId = 1L; + String rawJson = "{\"totalScore\": 100, \"problemRecognitionScore\": 20}"; + + // when + AiReport aiReport = AiReport.create(businessPlanId, rawJson); + + // then + assertThat(aiReport).isNotNull(); + assertThat(aiReport.getBusinessPlanId()).isEqualTo(businessPlanId); + assertThat(aiReport.getRawJson()).isNotNull(); + assertThat(aiReport.getRawJson().getValue()).isEqualTo( + RawJson.create(rawJson).getValue() + ); + } + + @Test + @DisplayName("businessPlanId가 null이면 예외가 발생한다") + void create_withNullBusinessPlanId_throwsException() { + // given + String rawJson = "{\"totalScore\": 100}"; + + // when & then + assertThatThrownBy(() -> AiReport.create(null, rawJson)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("businessPlanId must not be null"); + } + + @Test + @DisplayName("rawJson이 null이면 예외가 발생한다") + void create_withNullRawJson_throwsException() { + // given + Long businessPlanId = 1L; + + // when & then + assertThatThrownBy(() -> AiReport.create(businessPlanId, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawJson은 null일 수 없습니다"); + } + + @Test + @DisplayName("복잡한 JSON 구조도 정상적으로 저장된다") + void create_withComplexJson_success() throws Exception { + // given + Long businessPlanId = 1L; + String complexJson = """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 30, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "strengths": [ + {"title": "강점 1", "content": "내용 1"}, + {"title": "강점 2", "content": "내용 2"}, + {"title": "강점 3", "content": "내용 3"} + ], + "weaknesses": [ + {"title": "약점 1", "content": "내용 1"}, + {"title": "약점 2", "content": "내용 2"}, + {"title": "약점 3", "content": "내용 3"} + ], + "sectionScores": [ + { + "sectionType": "PROBLEM_RECOGNITION", + "gradingListScores": "[{\\"item\\": \\"질문1\\", \\"score\\": 4}]" + } + ] + } + """; + + // when + AiReport aiReport = AiReport.create(businessPlanId, complexJson); + + // then + assertThat(aiReport).isNotNull(); + assertThat(aiReport.getRawJson()).isNotNull(); + + // RawJson이 정규화되었는지 확인 + RawJson rawJson = aiReport.getRawJson(); + assertThat(rawJson.asTree()).isNotNull(); + + // JSON 구조 검증 + var jsonNode = rawJson.asTree(); + assertThat(jsonNode.path("problemRecognitionScore").asInt()).isEqualTo(20); + assertThat(jsonNode.path("strengths").isArray()).isTrue(); + assertThat(jsonNode.path("strengths").size()).isEqualTo(3); + } + + @Test + @DisplayName("AiReport의 rawJson을 업데이트할 수 있다") + void update_success() { + // given + Long businessPlanId = 1L; + String initialJson = "{\"totalScore\": 100}"; + AiReport aiReport = AiReport.create(businessPlanId, initialJson); + + String updatedJson = "{\"totalScore\": 150, \"problemRecognitionScore\": 20}"; + + // when + aiReport.update(updatedJson); + + // then + assertThat(aiReport.getRawJson().getValue()).isEqualTo( + RawJson.create(updatedJson).getValue() + ); + } + + @Test + @DisplayName("update 시 rawJson이 null이면 예외가 발생한다") + void update_withNullRawJson_throwsException() { + // given + Long businessPlanId = 1L; + String initialJson = "{\"totalScore\": 100}"; + AiReport aiReport = AiReport.create(businessPlanId, initialJson); + + // when & then + assertThatThrownBy(() -> aiReport.update(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rawJson은 null일 수 없습니다"); + } + + @Test + @DisplayName("업데이트 후에도 JSON 구조가 올바르게 유지된다") + void update_preservesJsonStructure() throws Exception { + // given + Long businessPlanId = 1L; + String initialJson = "{\"totalScore\": 100}"; + AiReport aiReport = AiReport.create(businessPlanId, initialJson); + + String updatedJson = """ + { + "problemRecognitionScore": 18, + "feasibilityScore": 25, + "growthStrategyScore": 28, + "teamCompetenceScore": 19, + "sectionScores": [ + { + "sectionType": "FEASIBILITY", + "gradingListScores": "[{\\"item\\": \\"질문1\\", \\"score\\": 3}]" + } + ] + } + """; + + // when + aiReport.update(updatedJson); + + // then + var jsonNode = aiReport.getRawJson().asTree(); + assertThat(jsonNode.path("problemRecognitionScore").asInt()).isEqualTo(18); + assertThat(jsonNode.path("feasibilityScore").asInt()).isEqualTo(25); + assertThat(jsonNode.path("sectionScores").isArray()).isTrue(); + assertThat(jsonNode.path("sectionScores").get(0) + .path("sectionType").asText()).isEqualTo("FEASIBILITY"); + } + + @Test + @DisplayName("RawJson이 정규화되어 저장된다") + void create_normalizesJson() { + // given + Long businessPlanId = 1L; + // 공백과 들여쓰기가 다른 JSON + String rawJson = "{\"totalScore\":100,\"problemRecognitionScore\":20}"; + String formattedJson = """ + { + "totalScore": 100, + "problemRecognitionScore": 20 + } + """; + + // when + AiReport aiReport1 = AiReport.create(businessPlanId, rawJson); + AiReport aiReport2 = AiReport.create(businessPlanId, formattedJson); + + // then + // RawJson이 정규화되어 같은 값으로 저장됨 + assertThat(aiReport1.getRawJson().getValue()) + .isEqualTo(aiReport2.getRawJson().getValue()); + } +} + diff --git a/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java b/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java new file mode 100644 index 00000000..066a71b2 --- /dev/null +++ b/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java @@ -0,0 +1,175 @@ +package starlight.domain.expertReport.entity; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.domain.expertReport.enumerate.CommentType; +import starlight.domain.expertReport.enumerate.SubmitStatus; +import starlight.domain.expertReport.exception.ExpertReportException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class ExpertReportTest { + + @Test + @DisplayName("ExpertReport 생성 - 성공") + void createExpertReport_Success() { + // given + Long expertId = 1L; + Long businessPlanId = 10L; + String token = "abc123"; + LocalDateTime beforeCreation = LocalDateTime.now(); + + // when + ExpertReport report = ExpertReport.create(expertId, businessPlanId, token); + + // then + assertThat(report).isNotNull(); + assertThat(report.getExpertId()).isEqualTo(expertId); + assertThat(report.getBusinessPlanId()).isEqualTo(businessPlanId); + assertThat(report.getToken()).isEqualTo(token); + assertThat(report.getSubmitStatus()).isEqualTo(SubmitStatus.PENDING); + assertThat(report.getViewCount()).isEqualTo(0); + assertThat(report.getExpiredAt()).isAfter(beforeCreation); + } + + @Test + @DisplayName("ExpertReport 생성 - expertId null 예외") + void createExpertReport_NullExpertId_ThrowsException() { + // given + Long expertId = null; + Long businessPlanId = 10L; + String token = "abc123"; + + // when & then + assertThatThrownBy(() -> ExpertReport.create(expertId, businessPlanId, token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expertId는 필수입니다"); + } + + @Test + @DisplayName("ExpertReport 생성 - token empty 예외") + void createExpertReport_EmptyToken_ThrowsException() { + // given + Long expertId = 1L; + Long businessPlanId = 10L; + String token = ""; + + // when & then + assertThatThrownBy(() -> ExpertReport.create(expertId, businessPlanId, token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("token은 필수입니다"); + } + + @Test + @DisplayName("임시 저장 - 성공") + void temporarySave_Success() { + // given + ExpertReport report = ExpertReport.create(1L, 10L, "token"); + + // when + report.temporarySave(); + + // then + assertThat(report.getSubmitStatus()).isEqualTo(SubmitStatus.TEMPORARY_SAVED); + assertThat(report.canEdit()).isTrue(); + } + + @Test + @DisplayName("최종 제출 - 성공") + void submit_Success() { + // given + ExpertReport report = ExpertReport.create(1L, 10L, "token"); + + // when + report.submit(); + + // then + assertThat(report.getSubmitStatus()).isEqualTo(SubmitStatus.SUBMITTED); + assertThat(report.canEdit()).isFalse(); + } + + @Test + @DisplayName("제출 후 재편집 시도 - 예외") + void editAfterSubmit_ThrowsException() { + // given + ExpertReport report = ExpertReport.create(1L, 10L, "token"); + report.submit(); + + // when & then + assertThatThrownBy(() -> report.updateOverallComment("새로운 의견")) + .isInstanceOf(ExpertReportException.class); + } + + @Test + @DisplayName("Details 업데이트 - null 예외") + void updateDetails_Null_ThrowsException() { + // given + ExpertReport report = ExpertReport.create(1L, 10L, "token"); + + // when & then + assertThatThrownBy(() -> report.updateDetails(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("details는 null일 수 없습니다"); + } + + @Test + @DisplayName("Details 업데이트 - 성공") + void updateDetails_Success() { + // given + ExpertReport report = ExpertReport.create(1L, 10L, "token"); + List details = List.of( + ExpertReportDetail.create(CommentType.STRENGTH, "좋습니다"), + ExpertReportDetail.create(CommentType.WEAKNESS, "개선 필요") + ); + + // when + report.updateDetails(details); + + // then + assertThat(report.getDetails()).hasSize(2); + } + + @Test + @DisplayName("조회수 증가") + void incrementViewCount_Success() { + // given + ExpertReport report = ExpertReport.create(1L, 10L, "token"); + int initialCount = report.getViewCount(); + + // when + report.incrementViewCount(); + report.incrementViewCount(); + + // then + assertThat(report.getViewCount()).isEqualTo(initialCount + 2); + } + + @Test + @DisplayName("ExpertReportDetail 생성 - 성공") + void createDetail_Success() { + // given + CommentType type = CommentType.STRENGTH; + String content = "시장 분석이 우수합니다."; + + // when + ExpertReportDetail detail = ExpertReportDetail.create(type, content); + + // then + assertThat(detail).isNotNull(); + assertThat(detail.getCommentType()).isEqualTo(type); + assertThat(detail.getContent()).isEqualTo(content); + } + + @Test + @DisplayName("ExpertReportDetail 생성 - content empty 예외") + void createDetail_EmptyContent_ThrowsException() { + // when & then + assertThatThrownBy(() -> + ExpertReportDetail.create(CommentType.STRENGTH, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("content는 필수입니다"); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/shared/BaseAndAbstractEntityJpaTest.java b/src/test/java/starlight/shared/BaseAndAbstractEntityJpaTest.java new file mode 100644 index 00000000..3e21e682 --- /dev/null +++ b/src/test/java/starlight/shared/BaseAndAbstractEntityJpaTest.java @@ -0,0 +1,90 @@ +package starlight.shared; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +@Import(BaseAndAbstractEntityJpaTest.AuditingTestConfig.class) +class BaseAndAbstractEntityJpaTest { + + @TestConfiguration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "jpaAuditingHandler") + @EnableJpaAuditing + static class AuditingTestConfig {} + + @Autowired TestEntityManager entityManager; + + @Entity + @Table(name = "t_sample_for_base_abstract_test") + static class SampleEntity extends AbstractEntity { + @Column(nullable = false) + private String name; + protected SampleEntity() {} + public SampleEntity(String name) { this.name = name; } + public void setName(String name) { this.name = name; } + } + + @Test + void 새_엔티티_저장시_IDENTITY_전략으로_ID_부여() { + SampleEntity entity = new SampleEntity("first"); + assertThat(getId(entity)).isNull(); + + entityManager.persist(entity); + entityManager.flush(); + + assertThat(getId(entity)).isNotNull(); + } + + @Test + void 저장시_createdAt_modifiedAt_세팅되고_isDeleted_false_기본값() { + SampleEntity entity = new SampleEntity("audit"); + entityManager.persist(entity); + entityManager.flush(); + entityManager.clear(); + + SampleEntity found = entityManager.find(SampleEntity.class, getId(entity)); + assertThat(found.getCreatedAt()).isNotNull(); + assertThat(found.getModifiedAt()).isNotNull(); + assertThat(found.getIsDeleted()).isFalse(); + } + + @Test + void 수정시_modifiedAt_갱신된다() throws Exception { + SampleEntity entity = new SampleEntity("before"); + entityManager.persist(entity); + entityManager.flush(); + entityManager.clear(); + + SampleEntity f1 = entityManager.find(SampleEntity.class, getId(entity)); + LocalDateTime before = f1.getModifiedAt(); + + Thread.sleep(75); + + f1.setName("after"); + entityManager.merge(f1); + entityManager.flush(); + entityManager.clear(); + + SampleEntity f2 = entityManager.find(SampleEntity.class, getId(entity)); + assertThat(f2.getModifiedAt()).isAfter(before); + } + + private Long getId(SampleEntity entity) { + Object id = entityManager.getId(entity); + return (id == null) ? null : ((Number) id).longValue(); + } +} diff --git a/src/test/java/starlight/shared/apiPayload/ApiControllerAdviceUnitTest.java b/src/test/java/starlight/shared/apiPayload/ApiControllerAdviceUnitTest.java new file mode 100644 index 00000000..75484036 --- /dev/null +++ b/src/test/java/starlight/shared/apiPayload/ApiControllerAdviceUnitTest.java @@ -0,0 +1,149 @@ +package starlight.shared.apiPayload; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.exception.GlobalException; +import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.apiPayload.response.ResultType; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApiControllerAdviceUnitTest { + + private ApiControllerAdvice sut; + + @BeforeEach + void setUp() { + sut = new ApiControllerAdvice(); + } + + @Test + @DisplayName("일반 Exception 발생 시 INTERNAL_ERROR 응답") + void handleException() { + // given + Exception exception = new Exception("예상치 못한 에러"); + + // when + ResponseEntity> response = sut.handleException(exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(GlobalErrorType.INTERNAL_ERROR.getStatus()); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().result()).isEqualTo(ResultType.ERROR); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().error()).isNotNull(); + } + + @Test + @DisplayName("MethodArgumentNotValidException 발생 시 FAILED_REQUEST_VALIDATION 응답") + void handleMethodArgumentNotValidException() throws NoSuchMethodException { + // given + BindingResult bindingResult = mock(BindingResult.class); + when(bindingResult.getFieldError()).thenReturn( + new FieldError("object", "field", "validation failed") + ); + + Method method = this.getClass().getMethod("dummyMethod", String.class); + MethodParameter methodParameter = new MethodParameter(method, 0); + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); + + // when + ResponseEntity> response = sut.handleMethodArgumentNotValidException(exception); + + // then + assertThat(response.getStatusCode()) + .isEqualTo(GlobalErrorType.FAILED_REQUEST_VALIDATION.getStatus()); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().result()).isEqualTo(ResultType.ERROR); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().error()).isNotNull(); + } + + // MethodParameter 생성용 더미 메서드 + public void dummyMethod(String param) { + // 테스트용 더미 메서드 + } + + @Test + @DisplayName("IllegalArgumentException 발생 시 INVALID_REQUEST_ARGUMENT 응답") + void handleIllegalArgumentException() { + // given + IllegalArgumentException exception = new IllegalArgumentException("잘못된 인자"); + + // when + ResponseEntity> response = sut.handleIllegalArgumentException(exception); + + // then + assertThat(response.getStatusCode()) + .isEqualTo(GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus()); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().result()).isEqualTo(ResultType.ERROR); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().error()).isNotNull(); + } + + @Test + @DisplayName("GlobalException 발생 시 해당 ErrorType으로 응답") + void handleGlobalException() { + // given + GlobalException exception = new GlobalException(GlobalErrorType.INTERNAL_ERROR); + + // when + ResponseEntity> response = sut.handleGlobalException(exception); + + // then + assertThat(response.getStatusCode()) + .isEqualTo(GlobalErrorType.INTERNAL_ERROR.getStatus()); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().result()).isEqualTo(ResultType.ERROR); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().error()).isNotNull(); + } + + @Test + @DisplayName("GlobalException - 커스텀 ErrorType 처리") + void handleGlobalException_withCustomErrorType() { + // given + GlobalException exception = new GlobalException(GlobalErrorType.INVALID_REQUEST_ARGUMENT); + + // when + ResponseEntity> response = sut.handleGlobalException(exception); + + // then + assertThat(response.getStatusCode()) + .isEqualTo(GlobalErrorType.INVALID_REQUEST_ARGUMENT.getStatus()); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().result()).isEqualTo(ResultType.ERROR); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().error()).isNotNull(); + } + + @Test + @DisplayName("RuntimeException도 Exception 핸들러로 처리됨") + void handleException_withRuntimeException() { + // given + RuntimeException exception = new RuntimeException("런타임 에러"); + + // when + ResponseEntity> response = sut.handleException(exception); + + // then + assertThat(response.getStatusCode()) + .isEqualTo(GlobalErrorType.INTERNAL_ERROR.getStatus()); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().result()).isEqualTo(ResultType.ERROR); + assertThat(response.getBody().data()).isNull(); + assertThat(response.getBody().error()).isNotNull(); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/shared/apiPayload/exception/GlobalExceptionUnitTest.java b/src/test/java/starlight/shared/apiPayload/exception/GlobalExceptionUnitTest.java new file mode 100644 index 00000000..7e8d1bf7 --- /dev/null +++ b/src/test/java/starlight/shared/apiPayload/exception/GlobalExceptionUnitTest.java @@ -0,0 +1,17 @@ +package starlight.shared.apiPayload.exception; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionUnitTest { + + @Test + void 생성자_에러타입_메시지_정상_세팅() { + ErrorType errorType = GlobalErrorType.INTERNAL_ERROR; + GlobalException exception = new GlobalException(errorType); + + assertThat(exception.getErrorType()).isEqualTo(errorType); + assertThat(exception.getMessage()).isEqualTo(errorType.getMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/starlight/shared/apiPayload/response/ApiResponseUnitTest.java b/src/test/java/starlight/shared/apiPayload/response/ApiResponseUnitTest.java new file mode 100644 index 00000000..e79b744a --- /dev/null +++ b/src/test/java/starlight/shared/apiPayload/response/ApiResponseUnitTest.java @@ -0,0 +1,41 @@ +package starlight.shared.apiPayload.response; + +import org.junit.jupiter.api.Test; +import starlight.shared.apiPayload.exception.GlobalErrorType; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiResponseUnitTest { + + @Test + void success_without_data() { + + ApiResponse response = ApiResponse.success(); + + assertThat(response.result()).isEqualTo(ResultType.SUCCESS); + assertThat(response.data()).isNull(); + assertThat(response.error()).isNull(); + } + + @Test + void success_with_data() { + String data = "테스트 데이터"; + + ApiResponse response = ApiResponse.success(data); + + assertThat(response.result()).isEqualTo(ResultType.SUCCESS); + assertThat(response.data()).isEqualTo(data); + assertThat(response.error()).isNull(); + } + + @Test + void error_response() { + + ApiResponse response = ApiResponse.error(GlobalErrorType.INTERNAL_ERROR); + + assertThat(response.result()).isEqualTo(ResultType.ERROR); + assertThat(response.data()).isNull(); + assertThat(response.error()).isNotNull(); + assertThat(response.error().getCode()).isEqualTo(GlobalErrorType.INTERNAL_ERROR.name()); + } +} diff --git "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 00000000..e88e1454 --- /dev/null +++ "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,31 @@ +# StarLight 개발가이드 + +## 아키텍처 +- 헥사고날 아키텍처 +- 도메인 모델 패턴 + +### 계층 +- Domain Layer +- Application Layer +- Adapter Layer + +> 외부(Actor) -> 어댑터 -> 애플리케이션 -> 도메인 + +## 패키지 +- domain +- application + - required + - provided +- adapter + - webapi + - persistence + - integration + - security +- shared +- bootstrap + +## 유의사항 +- 어댑터는 항상 포트(인터페이스) 에만 의존한다. +- 어댑터 ↔ 어댑터 직접 의존 금지 (필요하면 새 포트를 애플리케이션에 정의). +- 공통 능력은 공용 포트 1개로 여러 서비스에서 재사용한다. + diff --git "a/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" "b/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" new file mode 100644 index 00000000..8e8e6277 --- /dev/null +++ "b/\353\217\204\353\251\224\354\235\270\353\252\250\353\215\270.md" @@ -0,0 +1,427 @@ +# StarLight 도메인 모델 + +## StarLight 도메인 +- StarLight는 창업자가 사업계획서를 작성하고, 전문가의 피드백과 AI 분석을 받을 수 있는 서비스이다. +- 창업자(Member with MemberType.FOUNDER)는 사업계획서를 작성할 수 있다. +- 사업계획서는 5개의 주요 섹션(개요, 문제인식, 실현가능성, 성장전략, 팀역량)으로 구성되며, 각 섹션은 여러 서브 섹션으로 세분화된다. +- 작성된 사업계획서에 대해 AI가 자동으로 분석 리포트를 생성할 수 있다. +- 창업자는 전문가에게 피드백을 신청할 수 있으며, 동일한 사업계획서에 대해 동일한 전문가에게는 1회만 신청이 가능하다. +- 전문가(Expert)는 피드백 신청을 받으면 7일 이내에 리포트를 작성하여 제출해야 한다. +- 전문가 피드백 신청은 결제를 통해 이루어지며, 토스페이먼츠를 통해 처리된다. + +## 도메인 모델 + +--- +### [ 회원 어그리거트 ] + +### 회원(Member) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `name`: `String` - 이름 +- `email`: `String` - 이메일 +- `profileImageUrl`: `String` - 프로필 이미지 URL +- `phoneNumber`: `String` - 전화번호 +- `memberType`: `MemberType` - 회원 타입 (FOUNDER, EXPERT) +- `credential`: `Credential` - 인증 정보 (1:1 관계) +- `provider`: `String` - 인증 제공자 (starlight, kakao, google 등) +- `providerId`: `String` - 제공자 고유 ID + +#### 행위 +- `static create()`: 일반 회원 생성 (이름, 이메일, 전화번호, 회원타입, 인증정보, 프로필이미지) +- `static newSocial()`: 소셜 로그인 회원 생성 (이름, 이메일, 제공자, 제공자ID, 전화번호, 회원타입, 프로필이미지) +- `updateProfileImage()`: 프로필 이미지 업데이트 + +#### 규칙 +- 회원은 FOUNDER(창업자) 또는 EXPERT(전문가) 타입 중 하나를 가진다. +- 소프트 삭제를 지원한다. + +### 인증 정보(Credential) +_Entity_ +#### 속성 +- `id`: `Long` +- `password`: `String` - 암호화된 비밀번호 + +#### 행위 +- `static create()`: 인증 정보 생성 (암호화된 비밀번호) + +### 회원 타입(MemberType) +_Enum_ +#### 상수 +- `FOUNDER`: 창업자 +- `EXPERT`: 전문가 + +--- + +### [ 전문가 어그리거트 ] + +### 전문가(Expert) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `name`: `String` - 이름 +- `email`: `String` - 이메일 +- `workedPeriod`: `Long` - 경력 기간 +- `profileImageUrl`: `String` - 프로필 이미지 URL +- `mentoringPriceWon`: `Integer` - 멘토링 가격 (원) +- `careers`: `List` - 경력 목록 +- `tags`: `Set` - 태그 목록 +- `categories`: `Set` - 전문 분야 카테고리 + +#### 규칙 +- Member와 독립적으로 존재하는 별도의 엔티티이다. + +### 전문가 태그 카테고리(TagCategory) +_Enum_ +#### 상수 +- `MARKET_BM`: 시장성/BM +- `TEAM_CAPABILITY`: 팀 역량 +- `PROBLEM_DEFINITION`: 문제 정의 +- `GROWTH_STRATEGY`: 성장 전략 +- `METRIC_DATA`: 지표/데이터 + +--- + +### [ 사업계획서 어그리거트 ] + +### 사업계획서(BusinessPlan) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `memberId`: `Long` - 작성자 회원 ID +- `title`: `String` - 제목 +- `pdfUrl`: `String` - PDF 파일 URL (선택) +- `planStatus`: `PlanStatus` - 사업계획서 상태 +- `overview`: `Overview` - 개요 섹션 (1:1 관계) +- `problemRecognition`: `ProblemRecognition` - 문제 인식 섹션 (1:1 관계) +- `feasibility`: `Feasibility` - 실현 가능성 섹션 (1:1 관계) +- `growthTactic`: `GrowthTactic` - 성장 전략 섹션 (1:1 관계) +- `teamCompetence`: `TeamCompetence` - 팀 역량 섹션 (1:1 관계) + +#### 행위 +- `static create()`: 사업계획서 생성 (제목, 회원ID) - STARTED 상태로 초기화 +- `static createWithPdf()`: PDF 기반 사업계획서 생성 (제목, 회원ID, PDF URL) - WRITTEN_COMPLETED 상태로 초기화 +- `isOwnedBy()`: 소유자 확인 +- `isPdfBased()`: PDF 기반 여부 확인 +- `updateTitle()`: 제목 업데이트 +- `updateStatus()`: 상태 업데이트 +- `areWritingCompleted()`: 모든 서브 섹션이 작성 완료되었는지 확인 + +#### 규칙 +- 사업계획서 생성 시 5개의 섹션이 자동으로 초기화된다. +- 모든 서브 섹션이 작성되면 작성 완료로 판단된다. +- PDF 기반 사업계획서는 별도로 생성 가능하다. + +### 섹션 타입(SectionType) +_Enum_ +#### 상수 +- `OVERVIEW`: 개요 +- `PROBLEM_RECOGNITION`: 문제 인식 +- `FEASIBILITY`: 실현 가능성 +- `GROWTH_STRATEGY`: 성장 전략 +- `TEAM_COMPETENCE`: 팀 역량 + +### 기본 섹션(BaseSection) +_MappedSuperclass (Abstract)_ +#### 속성 +- `id`: `Long` - BusinessPlan과 공유하는 기본키 +- `businessPlan`: `BusinessPlan` - 연관된 사업계획서 + +#### 행위 +- `attachBusinessPlan()`: 사업계획서 연결 +- `putSubSection()`: 서브 섹션 추가 +- `removeSubSection()`: 서브 섹션 제거 +- `getSubSectionByType()`: 타입별 서브 섹션 조회 (추상 메서드) +- `setSubSectionByType()`: 타입별 서브 섹션 설정 (추상 메서드) +- `areAllSubSectionsCreated()`: 모든 서브 섹션 생성 여부 (추상 메서드) + +### 개요(Overview) +_Entity (extends BaseSection)_ +#### 속성 +- `overviewBasic`: `SubSection` - 기본 개요 서브 섹션 (1:1 관계) + +#### 행위 +- `static create()`: 개요 섹션 생성 + +### 문제 인식(ProblemRecognition) +_Entity (extends BaseSection)_ +#### 속성 +- `problemBackground`: `SubSection` - 창업 배경 및 개발동기 (1:1 관계) +- `problemPurpose`: `SubSection` - 창업아이템의 목적 및 필요성 (1:1 관계) +- `problemMarket`: `SubSection` - 창업아이템의 목표시장 분석 (1:1 관계) + +#### 행위 +- `static create()`: 문제 인식 섹션 생성 + +### 실현 가능성(Feasibility) +_Entity (extends BaseSection)_ +#### 속성 +- `feasibilityStrategy`: `SubSection` - 사업화 전략 (1:1 관계) +- `feasibilityMarket`: `SubSection` - 시장분석 및 경쟁력 확보 방안 (1:1 관계) + +#### 행위 +- `static create()`: 실현 가능성 섹션 생성 + +### 성장 전략(GrowthTactic) +_Entity (extends BaseSection)_ +#### 속성 +- `growthModel`: `SubSection` - 비즈니스 모델 (1:1 관계) +- `growthFunding`: `SubSection` - 자금조달 계획 (1:1 관계) +- `growthEntry`: `SubSection` - 시장진입 및 성과창출 전략 (1:1 관계) + +#### 행위 +- `static create()`: 성장 전략 섹션 생성 + +### 팀 역량(TeamCompetence) +_Entity (extends BaseSection)_ +#### 속성 +- `teamFounder`: `SubSection` - 창업자의 역량 (1:1 관계) +- `teamMembers`: `SubSection` - 팀 역량 (1:1 관계) + +#### 행위 +- `static create()`: 팀 역량 섹션 생성 + +### 서브 섹션(SubSection) +_Entity_ +#### 속성 +- `id`: `Long` +- `subSectionType`: `SubSectionType` - 서브 섹션 타입 +- `content`: `String` - 내용 (TEXT) +- `rawJson`: `RawJson` - 원본 JSON 데이터 (TEXT) +- `checkFirst`: `boolean` - 체크리스트 1번 +- `checkSecond`: `boolean` - 체크리스트 2번 +- `checkThird`: `boolean` - 체크리스트 3번 +- `checkFourth`: `boolean` - 체크리스트 4번 +- `checkFifth`: `boolean` - 체크리스트 5번 + +#### 행위 +- `static create()`: 서브 섹션 생성 (타입, 내용, 원본JSON, 체크리스트) +- `update()`: 서브 섹션 업데이트 (내용, 원본JSON, 체크리스트) +- `getChecks()`: 체크리스트 조회 + +#### 규칙 +- 체크리스트는 항상 5개의 항목을 가진다. + +### 서브 섹션 타입(SubSectionType) +_Enum_ +#### 상수 (개요) +- `OVERVIEW_BASIC`: 개요 + +#### 상수 (문제 인식) +- `PROBLEM_BACKGROUND`: 창업 배경 및 개발동기 +- `PROBLEM_PURPOSE`: 창업아이템의 목적 및 필요성 +- `PROBLEM_MARKET`: 창업아이템의 목표시장 분석 + +#### 상수 (실현 가능성) +- `FEASIBILITY_STRATEGY`: 사업화 전략 +- `FEASIBILITY_MARKET`: 시장분석 및 경쟁력 확보 방안 + +#### 상수 (성장 전략) +- `GROWTH_MODEL`: 비즈니스 모델 +- `GROWTH_FUNDING`: 자금조달 계획 +- `GROWTH_ENTRY`: 시장진입 및 성과창출 전략 + +#### 상수 (팀 역량) +- `TEAM_FOUNDER`: 창업자의 역량 +- `TEAM_MEMBERS`: 팀 역량 + +### 사업계획서 상태(PlanStatus) +_Enum_ +#### 상수 +- `STARTED`: 시작됨 +- `WRITTEN_COMPLETED`: 작성 완료 +- `AI_REVIEWED`: AI 리뷰 완료 +- `EXPERT_MATCHED`: 전문가 매칭 완료 +- `FINALIZED`: 최종 완료 + +### 원시 JSON(RawJson) +_Value Object_ +#### 속성 +- `value`: `String` - JSON 문자열 + +#### 행위 +- `static create()`: RawJson 생성 + +--- + +### [ 피드백 신청 어그리거트 ] + +### 피드백 신청(ExpertApplication) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `businessPlanId`: `Long` - 사업계획서 ID +- `expertId`: `Long` - 전문가 ID + +#### 행위 +- `static create()`: 피드백 신청 생성 (사업계획서ID, 전문가ID) + +#### 규칙 +- 사업계획서 1개당 여러 전문가에게 피드백 신청이 가능하다. +- 동일한 사업계획서에 대해 동일한 전문가에게는 1회만 피드백 신청이 가능하다 (유니크 제약 조건). + +--- + +### [ 전문가 리포트 어그리거트 ] + +### 전문가 리포트(ExpertReport) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `expertId`: `Long` - 전문가 ID +- `businessPlanId`: `Long` - 사업계획서 ID +- `expiredAt`: `LocalDateTime` - 만료 일시 (생성 후 7일) +- `token`: `String` - 리포트 접근 토큰 (유니크) +- `viewCount`: `int` - 조회 횟수 +- `overallComment`: `String` - 전체 코멘트 (TEXT) +- `submitStatus`: `SubmitStatus` - 제출 상태 (기본값: PENDING) +- `details`: `List` - 리포트 상세 목록 (1:N 관계) + +#### 행위 +- `static create()`: 전문가 리포트 생성 (전문가ID, 사업계획서ID, 토큰) - 만료일시는 생성 후 7일 +- `isExpired()`: 만료 여부 확인 +- `syncStatus()`: 상태 동기화 (만료 확인 후 EXPIRED로 변경) +- `validateCanEdit()`: 수정 가능 여부 검증 +- `canEdit()`: 수정 가능 여부 확인 +- `temporarySave()`: 임시 저장 +- `submit()`: 리포트 제출 +- `updateOverallComment()`: 전체 코멘트 업데이트 +- `updateDetails()`: 리포트 상세 목록 업데이트 +- `incrementViewCount()`: 조회 횟수 증가 + +#### 규칙 +- 리포트는 생성 후 7일의 평가 기한을 가진다. +- PENDING, TEMPORARY_SAVED 상태에서만 수정 가능하다. +- PENDING, TEMPORARY_SAVED 상태에서 제출 가능하며, 제출 시 SUBMITTED 상태로 변경된다. +- SUBMITTED, EXPIRED 상태에서는 수정 불가하다. +- expiredAt가 현재 시간보다 이전이면 EXPIRED 상태로 변경된다. +- 동일한 사업계획서와 전문가 조합에 대해 1개의 리포트만 존재할 수 있다 (유니크 제약 조건). + +### 리포트 상세(ExpertReportDetail) +_Entity_ +#### 속성 +- `id`: `Long` +- `commentType`: `CommentType` - 코멘트 타입 (STRENGTH, WEAKNESS) +- `content`: `String` - 내용 (TEXT) + +#### 행위 +- `static create()`: 리포트 상세 생성 (코멘트타입, 내용) +- `update()`: 내용 업데이트 + +### 제출 상태(SubmitStatus) +_Enum_ +#### 상수 +- `PENDING`: 평가 전 +- `TEMPORARY_SAVED`: 임시 저장 +- `SUBMITTED`: 제출 완료 +- `EXPIRED`: 만료됨 + +### 코멘트 타입(CommentType) +_Enum_ +#### 상수 +- `STRENGTH`: 강점 +- `WEAKNESS`: 약점 + +--- + +### [ AI 리포트 어그리거트 ] + +### AI 리포트(AiReport) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `businessPlanId`: `Long` - 사업계획서 ID +- `rawJson`: `RawJson` - 원본 JSON 데이터 (TEXT) + +#### 행위 +- `static create()`: AI 리포트 생성 (사업계획서ID, 원본JSON) +- `update()`: AI 리포트 업데이트 (원본JSON) + +#### 규칙 +- 사업계획서가 작성 완료되지 않으면 AI 리포트를 생성할 수 없다. + +--- + +### [ 주문 어그리거트 ] + +### 주문(Orders) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `orderCode`: `String` - 주문 코드 (유니크) +- `buyerId`: `Long` - 구매자 회원 ID +- `status`: `OrderStatus` - 주문 상태 (기본값: NEW) +- `currency`: `String` - 통화 (기본값: "KRW") +- `price`: `Long` - 주문 금액 +- `usageProductCode`: `String` - 사용 상품 코드 +- `usageCount`: `Integer` - 이용권 횟수 (1회권, 2회권 등) +- `payments`: `List` - 결제 기록 목록 (1:N 관계) +- `version`: `Long` - 버전 (낙관적 locking) + +#### 행위 +- `static newUsageOrder()`: 이용권 주문 생성 (주문코드, 구매자ID, 금액, 상품타입) +- `validateSameBuyer()`: 동일 구매자 확인 +- `validateSameProduct()`: 동일 상품 확인 +- `addPaymentAttempt()`: 결제 시도 추가 +- `markPaid()`: 결제 완료 처리 +- `cancel()`: 주문/결제 취소 +- `getLatestRequestedOrThrow()`: 가장 최근 REQUESTED 상태 결제 시도 조회 +- `getLatestDoneOrThrow()`: 가장 최근 DONE 상태 결제 시도 조회 + +#### 규칙 +- 이미 결제 완료된 주문에는 결제 시도를 추가할 수 없다. +- 주문 금액과 결제 금액이 일치해야 한다. +- NEW 상태에서만 결제 승인이 가능하다. +- PAID 상태에서만 취소가 가능하다. + +### 결제 기록(PaymentRecords) +_Entity_ +#### 속성 +- `id`: `Long` +- `order`: `Orders` - 주문 (N:1 관계) +- `pg`: `String` - PG사 (기본값: "TOSS") +- `paymentKey`: `String` - 결제 키 (유니크) +- `method`: `String` - 결제 수단 +- `provider`: `String` - 결제 제공자 +- `price`: `Long` - 결제 금액 +- `status`: `String` - 결제 상태 (기본값: "REQUESTED") +- `receiptUrl`: `String` - 영수증 URL +- `approvedAt`: `Instant` - 승인 일시 +- `createdAt`: `Instant` - 생성 일시 + +#### 행위 +- `static requestedFor()`: 결제 요청 생성 (주문, 금액) +- `markDone()`: 결제 완료 처리 +- `markFailed()`: 결제 실패 처리 + +### 주문 상태(OrderStatus) +_Enum_ +#### 상수 +- `NEW`: 주문 생성됨 (결제 전) +- `PAID`: 결제 완료 +- `CANCELED`: 주문/결제 취소 + +### 사용 상품 타입(UsageProductType) +_Enum_ +#### 상수 +- 사용 크레딧 상품 타입 (1회권, 2회권 등) + +### 주문 코드(OrderCode) +_Value Object_ +#### 속성 +- `value`: `String` - 주문 코드 문자열 + +#### 행위 +- `static of()`: OrderCode 생성 + +### 금액(Money) +_Value Object_ +#### 속성 +- `amount`: `Long` - 금액 +- `currency`: `String` - 통화 + +#### 행위 +- `static of()`: Money 생성 (금액, 통화) +- `static krw()`: 한국 원화 Money 생성 + +--- \ No newline at end of file diff --git "a/\354\232\251\354\226\264\354\202\254\354\240\204.md" "b/\354\232\251\354\226\264\354\202\254\354\240\204.md" new file mode 100644 index 00000000..a046a8eb --- /dev/null +++ "b/\354\232\251\354\226\264\354\202\254\354\240\204.md" @@ -0,0 +1,30 @@ +# StarLight 용어 사전 + +| **한국어** | **영어** | **설명** | +|--------|---------|------------------------------------------| +| 창업자 | Founder | StarLight 서비스를 이용하는 사용자로 사업계획서를 작성할 수 있다. MemberType.FOUNDER를 가진 회원이다. | +| 전문가 | Expert | 사업계획서를 검토하고 피드백을 제공하는 회원. Member와 독립적으로 존재하는 별도의 엔티티이다. | +| 회원 | Member | StarLight 서비스를 이용하는 모든 사용자. | +| 사업계획서 | BusinessPlan | 창업자가 작성하는 사업 계획서. 5개의 주요 섹션(개요, 문제인식, 실현가능성, 성장전략, 팀역량)으로 구성된다. | +| 섹션 | Section | 사업계획서를 구성하는 5개의 주요 영역. Overview, ProblemRecognition, Feasibility, GrowthTactic, TeamCompetence가 있다. | +| 서브 섹션 | SubSection | 각 섹션의 세부 항목. 내용, 원본 JSON 데이터, 체크리스트(5개 항목)를 포함한다. | +| 개요 | Overview | 사업계획서의 첫 번째 섹션. 사업의 기본 정보를 담는다. | +| 문제 인식 | ProblemRecognition | 사업계획서의 두 번째 섹션. 창업 배경, 개발 동기, 목적 및 필요성, 목표시장 분석을 포함한다. | +| 실현 가능성 | Feasibility | 사업계획서의 세 번째 섹션. 사업화 전략, 시장분석 및 경쟁력 확보 방안을 포함한다. | +| 성장 전략 | GrowthTactic | 사업계획서의 네 번째 섹션. 비즈니스 모델, 자금조달 계획, 시장진입 및 성과창출 전략을 포함한다. | +| 팀 역량 | TeamCompetence | 사업계획서의 다섯 번째 섹션. 창업자의 역량, 팀 역량을 포함한다. | +| 피드백 신청 | ExpertApplication | 창업자가 특정 전문가에게 자신의 사업계획서에 대한 피드백을 요청하는 신청. 동일한 사업계획서에 대해 동일한 전문가에게는 1회만 신청 가능하다. | +| 전문가 리포트 | ExpertReport | 전문가가 사업계획서에 대한 피드백을 작성한 리포트. 전체 코멘트와 각 섹션별 상세 코멘트(강점/약점)를 포함한다. 7일의 평가 기한을 가진다. | +| 리포트 상세 | ExpertReportDetail | 전문가 리포트의 세부 내용. 강점(STRENGTH) 또는 약점(WEAKNESS) 타입의 코멘트를 포함한다. | +| AI 리포트 | AiReport | AI가 사업계획서를 분석하여 자동으로 생성한 리포트. JSON 형태로 저장된다. | +| 주문 | Orders | 전문가 피드백 신청을 위한 결제 주문. 토스페이먼츠를 통해 결제를 처리한다. | +| 결제 기록 | PaymentRecords | 주문에 대한 결제 시도 및 완료 기록. 여러 번의 결제 시도를 기록할 수 있다. | +| 사용 크레딧 | UsageCredit | 전문가 피드백 신청에 사용하는 크레딧(이용권). 1회권, 2회권 등의 상품이 있다. | +| 사업계획서 상태 | PlanStatus | 사업계획서의 진행 상태. STARTED(시작됨), WRITTEN_COMPLETED(작성 완료), AI_REVIEWED(AI 리뷰 완료), EXPERT_MATCHED(전문가 매칭 완료), FINALIZED(최종 완료)가 있다. | +| 제출 상태 | SubmitStatus | 전문가 리포트의 제출 상태. PENDING(평가 전), TEMPORARY_SAVED(임시 저장), SUBMITTED(제출 완료), EXPIRED(만료됨)가 있다. | +| 코멘트 타입 | CommentType | 전문가 리포트 상세의 코멘트 타입. STRENGTH(강점), WEAKNESS(약점)이 있다. | +| 전문가 태그 카테고리 | TagCategory | 전문가의 전문 분야 카테고리. MARKET_BM(시장성/BM), TEAM_CAPABILITY(팀 역량), PROBLEM_DEFINITION(문제 정의), GROWTH_STRATEGY(성장 전략), METRIC_DATA(지표/데이터)가 있다. | +| 주문 상태 | OrderStatus | 주문의 결제 상태. NEW(주문 생성됨, 결제 전), PAID(결제 완료), CANCELED(주문/결제 취소)가 있다. | +| 인증 정보 | Credential | 회원의 비밀번호 등 인증 관련 정보. Member와 1:1 관계이다. | +| 원시 JSON | RawJson | 원본 데이터를 JSON 형태로 저장하기 위한 값 객체. | +