diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 90659791..21a5a0a5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,25 @@ -## #️⃣연관된 이슈 -> close # +## 🚀 Why - 해결하려는 문제가 무엇인가요? -## 📝작업 내용 -> 작업한 내용을 작성해주세요. +- `어떤 문제를 해결하고자 하나요?` -## 🔎코드 설명 -> 코드에 대한 설명을 작성해주세요. +- `어떤 배경이 있었나요?` -## 💬고민사항 및 리뷰 요구사항 -> 고민사항 및 의견 받고 싶은 부분 있으면 적어두기 +## ✅ What - 무엇이 변경됐나요? -## 비고 (Optional) -> 참고했던 링크 등 참고 사항을 적어주세요. 코드 리뷰하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다. +- `구현한 기능 요약` + +- `주요 변경사항` + +## 🛠️ How - 어떻게 해결했나요? + +- `핵심 로직 설명` + +- `예외 사항, 고민 포인트 등` + +## 🖼️ Attachment + +- `화면 이미지, 결과 캡처 등 첨부` + +## 💬 기타 코멘트 + +- `리뷰어에게 전하고 싶은 말, 테스트 방법, 주의할 점 등` diff --git a/.github/workflows/ci-prd.yaml b/.github/workflows/ci-prd.yaml index cae027c1..e9eae462 100644 --- a/.github/workflows/ci-prd.yaml +++ b/.github/workflows/ci-prd.yaml @@ -13,7 +13,7 @@ jobs: SPRING_PROFILES_ACTIVE: test steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive token: ${{ secrets.PAT }} @@ -25,7 +25,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -34,46 +34,79 @@ jobs: run: ./gradlew clean build --info --stacktrace --no-daemon - name: Docker login - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - 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 }} + # Multi-architecture 빌드 및 푸시 + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: linux/amd64,linux/arm64 # 두 아키텍처 모두 빌드 + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} + cache-from: type=gha + cache-to: type=gha,mode=max - name: Checkout manifest repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: 'StartUpLight/STARLIGHT_MANIFEST' token: ${{ secrets.PAT }} path: 'manifest' - - name: Update deployment.yml + - name: Update deployment.yml and push manifest 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 + update_manifest() { + local dir="$1" + local file="$dir/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 + sed -i "s|image:.*|image: ${DOCKERHUB_USERNAME}/startuplight-be:${IMAGE_TAG}|g" "$file" + echo "Updated $file:" + cat "$file" + + git -C "$dir" config --local user.email "kjeng7897@gmail.com" + git -C "$dir" config --local user.name "SeongHo5356" + git -C "$dir" add production/deployment.yml + + if [ -z "$(git -C "$dir" status --porcelain)" ]; then + echo "No changes to commit in $dir" + return 0 + fi + + git -C "$dir" commit -m "Update image tag to $IMAGE_TAG" + + for i in {1..3}; do + echo "Push attempt $i for $dir" + git -C "$dir" pull --rebase origin main && \ + git -C "$dir" push && \ + echo "Successfully pushed $dir" && \ + break || { + echo "Push failed for $dir, retrying in 2 seconds..." + sleep 2 + } + done + + if ! git -C "$dir" diff --quiet origin/main HEAD; then + echo "ERROR: Failed to push $dir after 3 attempts" + return 1 + fi + } + + update_manifest manifest diff --git a/.github/workflows/ci-stg.yaml b/.github/workflows/ci-stg.yaml index 36655ffb..dcdeeb19 100644 --- a/.github/workflows/ci-stg.yaml +++ b/.github/workflows/ci-stg.yaml @@ -13,7 +13,7 @@ jobs: SPRING_PROFILES_ACTIVE: test steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive token: ${{ secrets.PAT }} @@ -25,7 +25,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -34,46 +34,87 @@ jobs: run: ./gradlew clean build --info --stacktrace --no-daemon - name: Docker login - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - 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 }} + # Multi-architecture 빌드 및 푸시 + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + platforms: linux/amd64,linux/arm64 # 두 아키텍처 모두 빌드 + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/startuplight-be:${{ env.IMAGE_TAG }} + cache-from: type=gha + cache-to: type=gha,mode=max - name: Checkout manifest repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: 'StartUpLight/STARLIGHT_MANIFEST' token: ${{ secrets.PAT }} path: 'manifest' - - name: Update deployment.yml + - name: Checkout manifest repository (oracle) + uses: actions/checkout@v6 + with: + repository: 'StartUpLight/STARLIGHT_MANIFEST_ORACLE' + token: ${{ secrets.PAT }} + path: 'manifest-oracle' + + - name: Update deployment.yml and push manifests 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 + update_manifest() { + local dir="$1" + local file="$dir/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 + sed -i "s|image:.*|image: ${DOCKERHUB_USERNAME}/startuplight-be:${IMAGE_TAG}|g" "$file" + echo "Updated $file:" + cat "$file" + + git -C "$dir" config --local user.email "kjeng7897@gmail.com" + git -C "$dir" config --local user.name "SeongHo5356" + git -C "$dir" add staging/deployment.yml + + if [ -z "$(git -C "$dir" status --porcelain)" ]; then + echo "No changes to commit in $dir" + return 0 + fi + + git -C "$dir" commit -m "Update image tag to $IMAGE_TAG" + + for i in {1..3}; do + echo "Push attempt $i for $dir" + git -C "$dir" pull --rebase origin main && \ + git -C "$dir" push && \ + echo "Successfully pushed $dir" && \ + break || { + echo "Push failed for $dir, retrying in 2 seconds..." + sleep 2 + } + done + + if ! git -C "$dir" diff --quiet origin/main HEAD; then + echo "ERROR: Failed to push $dir after 3 attempts" + return 1 + fi + } + + update_manifest manifest + update_manifest manifest-oracle diff --git a/.gitignore b/.gitignore index e4857ae4..735e7529 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,5 @@ out/ node_modules/ dist/ *.log +/docs/ +/AGENTS.md diff --git a/README.md b/README.md index 0ac83b82..bf65ebce 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ | Category | Technology | |----------------------|---------------------------------------------------------------------------| | **Language** | Java 21 | -| **Framework** | Spring Boot 3.3.10 | -| **Databases** | Postgresql, Redis | +| **Framework** | Spring Boot 3.4.10 | +| **Databases** | MySQL, Redis | | **Authentication** | JWT, Spring Security, OAuth2.0 | | **Development Tools**| Lombok | | **API Documentation**| Swagger UI (SpringDoc) | @@ -120,49 +120,3 @@ https://www.erdcloud.com/d/bEeEkcvDoau3kf7W5 - `컨벤션명/#이슈번호-작업내용` - 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/config b/config index 3a581e52..6e61b815 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 3a581e527b6117d5929ba57a0e2c8c8b90d9d14a +Subproject commit 6e61b8157725b24821dff68581c6a45dadad98d1 diff --git a/src/main/java/starlight/StarlightApplication.java b/src/main/java/starlight/StarlightApplication.java index d254238b..d5e94dbf 100644 --- a/src/main/java/starlight/StarlightApplication.java +++ b/src/main/java/starlight/StarlightApplication.java @@ -2,7 +2,6 @@ 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 diff --git a/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java b/src/main/java/starlight/adapter/aireport/infrastructure/clova/infra/ClovaStudioClient.java similarity index 64% rename from src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java rename to src/main/java/starlight/adapter/aireport/infrastructure/clova/infra/ClovaStudioClient.java index 07e35068..55a5a4ab 100644 --- a/src/main/java/starlight/adapter/ncp/clova/infra/ClovaStudioClient.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/clova/infra/ClovaStudioClient.java @@ -1,20 +1,27 @@ -package starlight.adapter.ncp.clova.infra; +package starlight.adapter.aireport.infrastructure.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 starlight.adapter.aireport.infrastructure.clova.util.ClovaUtil; import java.util.Map; +/** + * Clova Studio API 호출 클라이언트. + * + * @deprecated 1.4.0 현재 사용 경로가 없으며, 필요 시 재도입할 수 있어 유지 중입니다. + * 대체 구현체는 없습니다. + */ @Component +@Deprecated(since = "1.4.0", forRemoval = false) public class ClovaStudioClient { private final RestClient restClient; - public ClovaStudioClient(@Qualifier("clovaClient") RestClient restClient) { + public ClovaStudioClient(@Qualifier("clovaStudioRestClient") RestClient restClient) { this.restClient = restClient; } @@ -28,4 +35,4 @@ public ClovaStudioResponse check(String systemMsg, String userMsg, int criteriaS .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/aireport/infrastructure/clova/util/ClovaUtil.java similarity index 89% rename from src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java rename to src/main/java/starlight/adapter/aireport/infrastructure/clova/util/ClovaUtil.java index 3513f6a6..1a7cbb26 100644 --- a/src/main/java/starlight/adapter/ncp/clova/util/ClovaUtil.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/clova/util/ClovaUtil.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.clova.util; +package starlight.adapter.aireport.infrastructure.clova.util; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -8,6 +8,13 @@ import java.util.List; import java.util.Map; +/** + * Clova Studio API 요청/응답 보조 유틸리티. + * + * @deprecated 1.4.0 현재 사용 경로가 없으며, 필요 시 재도입할 수 있어 유지 중입니다. + * 대체 구현체는 없습니다. + */ +@Deprecated(since = "1.4.0", forRemoval = false) public final class ClovaUtil { public static Map buildClovaRequestBody(String systemMsg, String userMsg, int n){ diff --git a/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java similarity index 80% rename from src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java index 0711e00b..d36d7709 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/ClovaOcrProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java @@ -1,15 +1,15 @@ -package starlight.adapter.ncp.ocr; +package starlight.adapter.aireport.infrastructure.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.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.aireport.infrastructure.ocr.infra.ClovaOcrClient; +import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrResponseMerger; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrTextExtractor; +import starlight.adapter.aireport.infrastructure.ocr.util.PdfUtils; +import starlight.application.aireport.required.OcrProvider; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.ArrayList; diff --git a/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/dto/ClovaOcrRequest.java similarity index 95% rename from src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/dto/ClovaOcrRequest.java index 251a019f..c1ab7dcd 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/dto/ClovaOcrRequest.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/dto/ClovaOcrRequest.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr.dto; +package starlight.adapter.aireport.infrastructure.ocr.dto; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/exception/OcrErrorType.java similarity index 92% rename from src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/exception/OcrErrorType.java index 263e7180..b5dd6713 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrErrorType.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/exception/OcrErrorType.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr.exception; +package starlight.adapter.aireport.infrastructure.ocr.exception; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/exception/OcrException.java similarity index 79% rename from src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/exception/OcrException.java index 31128d49..3cdbfed9 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/exception/OcrException.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/exception/OcrException.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr.exception; +package starlight.adapter.aireport.infrastructure.ocr.exception; import starlight.shared.apiPayload.exception.ErrorType; import starlight.shared.apiPayload.exception.GlobalException; diff --git a/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/ClovaOcrClient.java similarity index 80% rename from src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/ClovaOcrClient.java index 9427738b..6b75a4c5 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClient.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/ClovaOcrClient.java @@ -1,12 +1,12 @@ -package starlight.adapter.ncp.ocr.infra; +package starlight.adapter.aireport.infrastructure.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.adapter.aireport.infrastructure.ocr.dto.ClovaOcrRequest; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; import starlight.shared.dto.infrastructure.OcrResponse; @Slf4j diff --git a/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java similarity index 88% rename from src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java index 188532bb..48f7f53f 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClient.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java @@ -1,12 +1,12 @@ -package starlight.adapter.ncp.ocr.infra; +package starlight.adapter.aireport.infrastructure.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 starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; import java.net.URI; @@ -18,7 +18,7 @@ public class PdfDownloadClient { private final RestClient pdfDownloadClient; - public PdfDownloadClient(@Qualifier("downloadClient") RestClient downloadClient) { + public PdfDownloadClient(@Qualifier("pdfDownloadRestClient") RestClient downloadClient) { this.pdfDownloadClient = downloadClient; } diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrResponseMerger.java similarity index 92% rename from src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrResponseMerger.java index 4c2f21cb..13829709 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/util/OcrResponseMerger.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrResponseMerger.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr.util; +package starlight.adapter.aireport.infrastructure.ocr.util; import starlight.shared.dto.infrastructure.OcrResponse; diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrTextExtractor.java similarity index 98% rename from src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrTextExtractor.java index e52f872a..54b2c684 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/util/OcrTextExtractor.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrTextExtractor.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr.util; +package starlight.adapter.aireport.infrastructure.ocr.util; import starlight.shared.dto.infrastructure.OcrResponse; diff --git a/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/PdfUtils.java similarity index 90% rename from src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java rename to src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/PdfUtils.java index c11eab05..b681d3b1 100644 --- a/src/main/java/starlight/adapter/ncp/ocr/util/PdfUtils.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/util/PdfUtils.java @@ -1,9 +1,9 @@ -package starlight.adapter.ncp.ocr.util; +package starlight.adapter.aireport.infrastructure.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 starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; diff --git a/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java similarity index 94% rename from src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java rename to src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java index eca9cb4c..9c04da39 100644 --- a/src/main/java/starlight/adapter/ncp/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.storage; +package starlight.adapter.aireport.infrastructure.storage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,7 +12,7 @@ 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.application.aireport.required.PresignedUrlProvider; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URLEncoder; @@ -25,7 +25,7 @@ public class NcpPresignedUrlProvider implements PresignedUrlProvider { private final S3Client ncpS3Client; - private final S3Presigner s3Presigner; + private final S3Presigner ncpS3Presigner; @Value("${cloud.ncp.object-storage.bucket-name}") private String bucket; @@ -55,7 +55,7 @@ public PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName .putObjectRequest(putObjectRequest) .build(); - PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + PresignedPutObjectRequest presignedRequest = ncpS3Presigner.presignPutObject(presignRequest); String presignedUrl = presignedRequest.url().toString(); String objectUrl = buildObjectUrl(key); diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index 0eb8ca06..a5513466 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java @@ -2,16 +2,23 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import starlight.adapter.ai.util.AiReportResponseParser; import starlight.application.aireport.required.AiReportQuery; +import starlight.application.expert.required.AiReportSummaryLookupPort; import starlight.domain.aireport.entity.AiReport; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; @Component @RequiredArgsConstructor -public class AiReportJpa implements AiReportQuery { +public class AiReportJpa implements AiReportQuery, AiReportSummaryLookupPort { private final AiReportRepository aiReportRepository; + private final AiReportResponseParser responseParser; @Override public AiReport save(AiReport aiReport) { @@ -22,5 +29,21 @@ public AiReport save(AiReport aiReport) { public Optional findByBusinessPlanId(Long businessPlanId) { return aiReportRepository.findByBusinessPlanId(businessPlanId); } -} + @Override + public Map findTotalScoresByBusinessPlanIds(List businessPlanIds) { + if (businessPlanIds == null || businessPlanIds.isEmpty()) { + return Collections.emptyMap(); + } + + List reports = aiReportRepository.findAllByBusinessPlanIdIn(businessPlanIds); + Map totalScoreMap = new HashMap<>(); + + for (AiReport report : reports) { + Integer totalScore = responseParser.toResponse(report).totalScore(); + totalScoreMap.put(report.getBusinessPlanId(), totalScore != null ? totalScore : 0); + } + + return totalScoreMap; + } +} diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java index 31c245e4..64cc5b0d 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportRepository.java @@ -3,10 +3,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import starlight.domain.aireport.entity.AiReport; +import java.util.Collection; import java.util.Optional; +import java.util.List; public interface AiReportRepository extends JpaRepository { Optional findByBusinessPlanId(Long businessPlanId); -} + List findAllByBusinessPlanIdIn(Collection businessPlanIds); +} diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java index b8f0e84c..7ff19d4e 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -7,8 +7,8 @@ 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.adapter.member.auth.security.auth.AuthDetails; import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.provided.AiReportService; import starlight.shared.apiPayload.response.ApiResponse; @@ -53,4 +53,3 @@ public ApiResponse getAiReport( return ApiResponse.success(aiReportService.getAiReport(planId, authDetails.getMemberId())); } } - diff --git a/src/main/java/starlight/adapter/ncp/webapi/ImageController.java b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java similarity index 73% rename from src/main/java/starlight/adapter/ncp/webapi/ImageController.java rename to src/main/java/starlight/adapter/aireport/webapi/ImageController.java index 7ff465b4..442d4993 100644 --- a/src/main/java/starlight/adapter/ncp/webapi/ImageController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java @@ -1,13 +1,13 @@ -package starlight.adapter.ncp.webapi; +package starlight.adapter.aireport.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.application.aireport.required.PresignedUrlProvider; +import starlight.adapter.aireport.webapi.swagger.ImageApiDoc; +import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @RestController @@ -19,10 +19,10 @@ public class ImageController implements ImageApiDoc { @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( - @AuthenticationPrincipal AuthDetails authDetails, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember, @RequestParam String fileName ) { - return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authDetails.getMemberId(), fileName)); + return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authenticatedMember.getMemberId(), fileName)); } @PostMapping("/upload-url/public") diff --git a/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java b/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java similarity index 95% rename from src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java rename to src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java index 8919e99e..a9bc0b1b 100644 --- a/src/main/java/starlight/adapter/ncp/webapi/swagger/ImageApiDoc.java +++ b/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.webapi.swagger; +package starlight.adapter.aireport.webapi.swagger; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -11,7 +11,7 @@ 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.auth.AuthenticatedMember; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import starlight.shared.apiPayload.response.ApiResponse; @@ -46,7 +46,7 @@ public interface ImageApiDoc { }) @GetMapping(value = "/v1/image/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) ApiResponse getPresignedUrl( - @AuthenticationPrincipal AuthDetails authDetails, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember, @io.swagger.v3.oas.annotations.Parameter(description = "파일명", required = true) @RequestParam String fileName ); @@ -77,4 +77,3 @@ 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/auth/security/handler/JwtAccessDeniedHandler.java b/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java deleted file mode 100644 index 00369de6..00000000 --- a/src/main/java/starlight/adapter/auth/security/handler/JwtAccessDeniedHandler.java +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index c4ba9344..00000000 --- a/src/main/java/starlight/adapter/auth/security/handler/JwtAuthenticationHandler.java +++ /dev/null @@ -1,20 +0,0 @@ -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/dto/TokenResponse.java b/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java deleted file mode 100644 index 3e7948fb..00000000 --- a/src/main/java/starlight/adapter/auth/security/jwt/dto/TokenResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -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/webapi/AuthController.java b/src/main/java/starlight/adapter/auth/webapi/AuthController.java deleted file mode 100644 index 06ebb10e..00000000 --- a/src/main/java/starlight/adapter/auth/webapi/AuthController.java +++ /dev/null @@ -1,54 +0,0 @@ -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/swagger/AuthApiDoc.java b/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java deleted file mode 100644 index dbc8b0fd..00000000 --- a/src/main/java/starlight/adapter/auth/webapi/swagger/AuthApiDoc.java +++ /dev/null @@ -1,154 +0,0 @@ -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 index ce461cc3..8966aef2 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java @@ -1,27 +1,37 @@ package starlight.adapter.businessplan.persistence; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.expert.required.BusinessPlanLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.exception.BusinessPlanErrorType; import starlight.domain.businessplan.exception.BusinessPlanException; +import java.util.List; + @Repository @RequiredArgsConstructor -public class BusinessPlanJpa implements BusinessPlanQuery { +public class BusinessPlanJpa implements BusinessPlanQuery, BusinessPlanLookupPort { private final BusinessPlanRepository businessPlanRepository; @Override - public BusinessPlan getOrThrow(Long id) { + public BusinessPlan findByIdOrThrow(Long id) { return businessPlanRepository.findById(id).orElseThrow( () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND) ); } + @Override + public BusinessPlan getOrThrowWithAllSubSections(Long id) { + return businessPlanRepository.findByIdWithAllSubSections(id).orElseThrow( + () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND) + ); + } + @Override public BusinessPlan save(BusinessPlan businessPlan) { return businessPlanRepository.save(businessPlan); @@ -36,4 +46,9 @@ public void delete(BusinessPlan businessPlan) { public Page findPreviewPage(Long memberId, Pageable pageable) { return businessPlanRepository.findAllByMemberIdOrderedByLastSavedAt(memberId, pageable); } + + @Override + public List findAllByMemberId(Long memberId) { + return businessPlanRepository.findAllByMemberIdOrderByLastSavedAt(memberId); + } } diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java index 10beb7a6..973da96d 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import starlight.domain.businessplan.entity.BusinessPlan; +import java.util.List; import java.util.Optional; public interface BusinessPlanRepository extends JpaRepository { @@ -16,10 +17,41 @@ public interface BusinessPlanRepository extends JpaRepository findById(Long id); @Query(""" - SELECT bp - FROM BusinessPlan bp - WHERE bp.memberId = :memberId - ORDER BY COALESCE(bp.modifiedAt, bp.createdAt) DESC, bp.id DESC - """) + 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); + + @Query(""" + SELECT bp + FROM BusinessPlan bp + WHERE bp.memberId = :memberId + ORDER BY COALESCE(bp.modifiedAt, bp.createdAt) DESC, bp.id DESC + """) + List findAllByMemberIdOrderByLastSavedAt(@Param("memberId") Long memberId); + + @Query(""" + SELECT DISTINCT bp + FROM BusinessPlan bp + LEFT JOIN FETCH bp.overview o + LEFT JOIN FETCH o.overviewBasic + LEFT JOIN FETCH bp.problemRecognition pr + LEFT JOIN FETCH pr.problemBackground + LEFT JOIN FETCH pr.problemPurpose + LEFT JOIN FETCH pr.problemMarket + LEFT JOIN FETCH bp.feasibility f + LEFT JOIN FETCH f.feasibilityStrategy + LEFT JOIN FETCH f.feasibilityMarket + LEFT JOIN FETCH bp.growthTactic gt + LEFT JOIN FETCH gt.growthModel + LEFT JOIN FETCH gt.growthFunding + LEFT JOIN FETCH gt.growthEntry + LEFT JOIN FETCH bp.teamCompetence tc + LEFT JOIN FETCH tc.teamFounder + LEFT JOIN FETCH tc.teamMembers + WHERE bp.id = :id + """) + Optional findByIdWithAllSubSections(@Param("id") Long id); } diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java index fcc9d431..d66732cd 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java @@ -12,7 +12,7 @@ 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.member.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; diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index e3a18e2b..92501813 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -3,13 +3,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import starlight.application.expert.required.ExpertQuery; +import starlight.application.expert.required.ExpertQueryPort; 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; @@ -19,50 +17,61 @@ @Slf4j @Component @RequiredArgsConstructor -public class ExpertJpa implements ExpertQuery { +public class ExpertJpa implements ExpertQueryPort, + starlight.application.expertReport.required.ExpertLookupPort, + starlight.application.expertApplication.required.ExpertLookupPort { private final ExpertRepository repository; @Override - public Expert findById(Long id) { + public Expert findByIdOrThrow(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() { + public Expert findByIdWithCareersAndTags(Long id) { try { - return repository.findAllWithDetails(); + List experts = repository.fetchExpertsWithCareersByIds(List.of(id)); + if (experts.isEmpty()) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } + + repository.fetchExpertsWithTagsByIds(List.of(id)); + + return experts.get(0); + } catch (ExpertException e) { + throw e; } catch (Exception e) { - log.error("전문가 목록 조회 중 오류가 발생했습니다.", e); + log.error("전문가 상세 조회 중 오류가 발생했습니다.", e); throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR); } } @Override - public List findByAllCategories(Collection categories) { + public List findAllWithCareersTagsCategories() { try { - return repository.findByAllCategories(categories, categories.size()); + List ids = repository.findAllIds(); + return fetchWithCollections(ids); } catch (Exception e) { - log.error("전문가 목록 필터링 중 오류가 발생했습니다.", e); + log.error("전문가 목록 조회 중 오류가 발생했습니다.", e); throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR); } } @Override - public Map findExpertMapByIds(Set expertIds) { - - List experts = repository.findAllWithDetailsByIds(expertIds); + public Map findByIds(Set expertIds) { + List experts = repository.findAllByIds(expertIds); return experts.stream() .collect(Collectors.toMap(Expert::getId, Function.identity())); } + + private List fetchWithCollections(List ids) { + List experts = repository.fetchExpertsWithCareersByIds(ids); + repository.fetchExpertsWithTagsByIds(ids); + repository.fetchExpertsWithCategoriesByIds(ids); + return experts; + } } diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java index dfe059d9..6d22b0d4 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertRepository.java @@ -1,46 +1,27 @@ 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 e.id from Expert e") + List findAllIds(); + + @Query("select distinct e from Expert e left join fetch e.careers where e.id in :ids") + List fetchExpertsWithCareersByIds(@Param("ids") List ids); + + @Query("select distinct e from Expert e left join fetch e.tags where e.id in :ids") + List fetchExpertsWithTagsByIds(@Param("ids") List ids); + + @Query("select distinct e from Expert e left join fetch e.categories where e.id in :ids") + List fetchExpertsWithCategoriesByIds(@Param("ids") List ids); @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); + List findAllByIds(Set expertIds); + } diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java index 4b5cf662..ce98ccd8 100644 --- a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java +++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java @@ -1,35 +1,49 @@ package starlight.adapter.expert.webapi; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.expert.webapi.dto.ExpertAiReportBusinessPlanResponse; import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; -import starlight.adapter.expert.webapi.swagger.ExpertQueryApiDoc; -import starlight.application.expert.provided.ExpertFinder; -import starlight.domain.expert.entity.Expert; -import starlight.domain.expert.enumerate.TagCategory; +import starlight.adapter.expert.webapi.dto.ExpertListResponse; +import starlight.adapter.expert.webapi.swagger.ExpertApiDoc; +import starlight.application.expert.provided.ExpertAiReportQueryUseCase; +import starlight.application.expert.provided.ExpertDetailQueryUseCase; +import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; import java.util.List; -import java.util.Set; @RestController @RequiredArgsConstructor @RequestMapping("/v1/experts") -public class ExpertController implements ExpertQueryApiDoc { +public class ExpertController implements ExpertApiDoc { - private final ExpertFinder expertFinder; + private final ExpertDetailQueryUseCase expertDetailQuery; + private final ExpertAiReportQueryUseCase expertAiReportQuery; @GetMapping - public ApiResponse> search( - @RequestParam(name = "categories", required = false) Set categories + public ApiResponse> search() { + return ApiResponse.success(ExpertListResponse.fromAll(expertDetailQuery.searchAll())); + } + + @GetMapping("/{expertId}") + public ApiResponse detail( + @PathVariable Long expertId ) { - List experts = (categories == null || categories.isEmpty()) - ? expertFinder.loadAll() - : expertFinder.findByAllCategories(categories); + return ApiResponse.success(ExpertDetailResponse.from(expertDetailQuery.findById(expertId))); + } - return ApiResponse.success(ExpertDetailResponse.fromAll(experts)); + @GetMapping("/{expertId}/business-plans/ai-reports") + public ApiResponse> aiReportBusinessPlans( + @PathVariable Long expertId, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ) { + return ApiResponse.success(ExpertAiReportBusinessPlanResponse.fromAll( + expertAiReportQuery.findAiReportBusinessPlans(expertId, authenticatedMember.getMemberId()) + )); } } diff --git a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertAiReportBusinessPlanResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertAiReportBusinessPlanResponse.java new file mode 100644 index 00000000..c6779d69 --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertAiReportBusinessPlanResponse.java @@ -0,0 +1,27 @@ +package starlight.adapter.expert.webapi.dto; + +import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; + +import java.util.List; + +public record ExpertAiReportBusinessPlanResponse( + Long businessPlanId, + String businessPlanTitle, + Long requestCount, + boolean isOver70 +) { + public static ExpertAiReportBusinessPlanResponse from(ExpertAiReportBusinessPlanResult result) { + return new ExpertAiReportBusinessPlanResponse( + result.businessPlanId(), + result.businessPlanTitle(), + result.requestCount(), + result.isOver70() + ); + } + + public static List fromAll(List results) { + return results.stream() + .map(ExpertAiReportBusinessPlanResponse::from) + .toList(); + } +} diff --git a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertCareerResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertCareerResponse.java new file mode 100644 index 00000000..68631303 --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertCareerResponse.java @@ -0,0 +1,30 @@ +package starlight.adapter.expert.webapi.dto; + +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.time.LocalDateTime; + +public record ExpertCareerResponse( + Long id, + + Integer orderIndex, + + String careerTitle, + + String careerExplanation, + + LocalDateTime careerStartedAt, + + LocalDateTime careerEndedAt +) { + public static ExpertCareerResponse from(ExpertCareerResult result) { + return new ExpertCareerResponse( + result.id(), + result.orderIndex(), + result.careerTitle(), + result.careerExplanation(), + result.careerStartedAt(), + result.careerEndedAt() + ); + } +} diff --git a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java index f02ce085..e1b956c3 100644 --- a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java +++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertDetailResponse.java @@ -1,17 +1,20 @@ package starlight.adapter.expert.webapi.dto; -import starlight.domain.expert.entity.Expert; -import starlight.domain.expert.enumerate.TagCategory; - -import java.util.Collection; +import starlight.application.expert.provided.dto.ExpertDetailResult; import java.util.List; public record ExpertDetailResponse( Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + + String detailedIntroduction, + String profileImageUrl, Long workedPeriod, @@ -20,32 +23,33 @@ public record ExpertDetailResponse( Integer mentoringPriceWon, - List careers, - - List tags, + List careers, - List categories + List tags ) { - public static ExpertDetailResponse from(Expert expert) { - List categories = expert.getCategories().stream() - .map(TagCategory::name) - .distinct() + public static ExpertDetailResponse from(ExpertDetailResult result) { + List careers = result.careers().stream() + .map(ExpertCareerResponse::from) .toList(); return new ExpertDetailResponse( - expert.getId(), - expert.getName(), - expert.getProfileImageUrl(), - expert.getWorkedPeriod(), - expert.getEmail(), - expert.getMentoringPriceWon(), - expert.getCareers(), - expert.getTags().stream().distinct().toList(), - categories + result.id(), + result.applicationCount(), + result.name(), + result.oneLineIntroduction(), + result.detailedIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.mentoringPriceWon(), + careers, + result.tags() ); } - public static List fromAll(Collection experts){ - return experts.stream().map(ExpertDetailResponse::from).toList(); + public static List fromAllResults(List results) { + return results.stream() + .map(ExpertDetailResponse::from) + .toList(); } } diff --git a/src/main/java/starlight/adapter/expert/webapi/dto/ExpertListResponse.java b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertListResponse.java new file mode 100644 index 00000000..f654c14e --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/dto/ExpertListResponse.java @@ -0,0 +1,57 @@ +package starlight.adapter.expert.webapi.dto; + +import starlight.application.expert.provided.dto.ExpertCareerResult; +import starlight.application.expert.provided.dto.ExpertDetailResult; + +import java.util.List; + +public record ExpertListResponse( + Long id, + String name, + String oneLineIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + List careers, + List tags, + List categories +) { + private static final int MAX_CAREERS = 3; + + public static ExpertListResponse from(ExpertDetailResult result) { + List careers = result.careers().stream() + .limit(MAX_CAREERS) + .map(ExpertCareerSummaryResponse::from) + .toList(); + + return new ExpertListResponse( + result.id(), + result.name(), + result.oneLineIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + careers, + result.tags(), + result.categories() + ); + } + + public static List fromAll(List results) { + return results.stream() + .map(ExpertListResponse::from) + .toList(); + } + + public record ExpertCareerSummaryResponse( + Integer orderIndex, + String careerTitle + ) { + public static ExpertCareerSummaryResponse from(ExpertCareerResult result) { + return new ExpertCareerSummaryResponse( + result.orderIndex(), + result.careerTitle() + ); + } + } +} diff --git a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertApiDoc.java b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertApiDoc.java new file mode 100644 index 00000000..7c748d6f --- /dev/null +++ b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertApiDoc.java @@ -0,0 +1,264 @@ +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import starlight.adapter.expert.webapi.dto.ExpertAiReportBusinessPlanResponse; +import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; +import starlight.adapter.expert.webapi.dto.ExpertListResponse; +import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.auth.AuthenticatedMember; + +import java.util.List; + +@Tag(name = "전문가", description = "전문가 관련 API") +public interface ExpertApiDoc { + + @Operation( + summary = "전문가 목록 조회", + description = "전체 전문가 목록을 반환합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = ExpertListResponse.class)), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "result": "SUCCESS", + "data": [ + { + "id": 1, + "name": "홍길동", + "oneLineIntroduction": "한 줄 소개", + "profileImageUrl": "https://cdn.example.com/profiles/1.png", + "workedPeriod": 6, + "email": "hong@example.com", + "careers": [ + { "orderIndex": 0, "careerTitle": "A사 PO (2019-2022)" }, + { "orderIndex": 1, "careerTitle": "B스타트업 PM (2023-)" } + ], + "tags": ["B2B", "SaaS", "PM"], + "categories": ["성장 전략","팀 역량"] + }, + { + "id": 2, + "name": "이영희", + "oneLineIntroduction": "한 줄 소개", + "profileImageUrl": "https://cdn.example.com/profiles/2.png", + "workedPeriod": 4, + "email": "lee@example.com", + "careers": [ + { "orderIndex": 0, "careerTitle": "C기업 데이터분석 (2020-)" } + ], + "tags": ["데이터", "분석"], + "categories": ["시장성/BM","지표/데이터"] + } + ], + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "신청 건수 조회 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping + ApiResponse> search(); + + @Operation(summary = "전문가 상세 조회") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ExpertDetailResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.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": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "신청 건수 조회 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping("/{expertId}") + ApiResponse detail( + @PathVariable Long expertId + ); + + @Operation( + summary = "전문가 상세 내 AI 리포트 보유 사업계획서 목록", + description = "지정된 전문가의 전문가 상세 페이지에서 로그인한 사용자의 사업계획서 중 AI 리포트가 생성된 항목만 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = ExpertAiReportBusinessPlanResponse.class)), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "result": "SUCCESS", + "data": [ + { + "businessPlanId": 10, + "businessPlanTitle": "테스트 사업계획서", + "requestCount": 2, + "isOver70": true + }, + { + "businessPlanId": 11, + "businessPlanTitle": "신규 사업계획서", + "requestCount": 0, + "isOver70": false + } + ], + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "전문가 신청 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ), + @ExampleObject( + name = "AI 리포트 파싱 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "AI_RESPONSE_PARSING_FAILED", + "message": "AI 응답 파싱에 실패했습니다." + } + } + """ + ) + } + ) + ) + }) + @GetMapping("/{expertId}/business-plans/ai-reports") + ApiResponse> aiReportBusinessPlans( + @PathVariable Long expertId, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ); +} diff --git a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java b/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java deleted file mode 100644 index 99187fc4..00000000 --- a/src/main/java/starlight/adapter/expert/webapi/swagger/ExpertQueryApiDoc.java +++ /dev/null @@ -1,77 +0,0 @@ -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 index fb7b2a2d..e1eca2a3 100644 --- a/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java +++ b/src/main/java/starlight/adapter/expertApplication/email/SMTPEmailSender.java @@ -12,7 +12,7 @@ import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; import starlight.application.expertApplication.required.EmailSender; -import starlight.application.expertApplication.event.FeedbackRequestDto; +import starlight.application.expertApplication.event.FeedbackRequestInput; import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; import starlight.domain.expertApplication.exception.ExpertApplicationException; @@ -28,7 +28,7 @@ public class SMTPEmailSender implements EmailSender { private String senderEmail; @Override - public void sendFeedbackRequestMail(FeedbackRequestDto dto) { + public void sendFeedbackRequestMail(FeedbackRequestInput dto) { try { MimeMessage message = javaMailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java deleted file mode 100644 index 5b57ab7c..00000000 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java +++ /dev/null @@ -1,44 +0,0 @@ -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/ExpertApplicationJpaPort.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java new file mode 100644 index 00000000..a9a9a860 --- /dev/null +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java @@ -0,0 +1,78 @@ +package starlight.adapter.expertApplication.persistence; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.application.expertApplication.required.ExpertApplicationQueryPort; +import starlight.domain.expertApplication.entity.ExpertApplication; +import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; +import starlight.domain.expertApplication.exception.ExpertApplicationException; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpertApplicationJpaPort implements ExpertApplicationQueryPort, + starlight.application.expert.required.ExpertApplicationCountLookupPort, + starlight.application.expertReport.required.ExpertApplicationCountLookupPort { + + 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 ExpertApplication save(ExpertApplication application) { + return repository.save(application); + } + + @Override + public Map countByExpertIds(List expertIds) { + try { + if (expertIds == null || expertIds.isEmpty()) { + return Collections.emptyMap(); + } + + return repository.countByExpertIds(expertIds).stream() + .collect(Collectors.toMap( + ExpertApplicationRepository.ExpertIdCountProjection::getExpertId, + p -> (long) p.getCount() + )); + } catch (Exception e) { + log.error("전문가별 신청 건수 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } + + @Override + public Map countByExpertIdAndBusinessPlanIds(Long expertId, List businessPlanIds) { + try { + if (expertId == null) { + return Collections.emptyMap(); + } + if (businessPlanIds == null || businessPlanIds.isEmpty()) { + return Collections.emptyMap(); + } + + return repository.countByExpertIdAndBusinessPlanIds(expertId, businessPlanIds).stream() + .collect(Collectors.toMap( + ExpertApplicationRepository.BusinessPlanIdCountProjection::getBusinessPlanId, + p -> (long) p.getCount() + )); + } catch (Exception e) { + log.error("사업계획서별 신청 건수 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } +} diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java index a912bd5b..22ef34d7 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationRepository.java @@ -11,10 +11,33 @@ public interface ExpertApplicationRepository extends JpaRepository countByExpertIds(@Param("expertIds") List expertIds); + + interface BusinessPlanIdCountProjection { + Long getBusinessPlanId(); + long getCount(); + } + @Query(""" - select distinct e.expertId - from ExpertApplication e - where e.businessPlanId = :businessPlanId - """) - List findRequestedExpertIdsByPlanId(@Param("businessPlanId") Long businessPlanId); + select e.businessPlanId as businessPlanId, count(e) as count + from ExpertApplication e + where e.expertId = :expertId + and e.businessPlanId in :businessPlanIds + group by e.businessPlanId + """) + List countByExpertIdAndBusinessPlanIds( + @Param("expertId") Long expertId, + @Param("businessPlanIds") List businessPlanIds + ); } diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java index 7d46e333..799ba9ea 100644 --- a/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java +++ b/src/main/java/starlight/adapter/expertApplication/webapi/ExpertApplicationController.java @@ -6,38 +6,27 @@ 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.application.expertApplication.provided.ExpertApplicationCommandUseCase; +import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; -import java.util.List; - @Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/v1/expert-applications") public class ExpertApplicationController implements ExpertApplicationApiDoc { - private final ExpertApplicationQuery finder; - private final ExpertApplicationService expertApplicationService; - - @GetMapping - public ApiResponse> search( - @RequestParam Long businessPlanId - ) { - return ApiResponse.success(finder.findRequestedExpertIds(businessPlanId)); - } + private final ExpertApplicationCommandUseCase applicationServiceUseCase; @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 + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) throws Exception { - expertApplicationService.requestFeedback(expertId, businessPlanId, file, auth.getUser().getName()); + applicationServiceUseCase.requestFeedback(expertId, businessPlanId, file, authenticatedMember.getMemberName()); return ApiResponse.success("피드백 요청이 전달되었습니다."); } } diff --git a/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java index b73f81b4..a492d420 100644 --- a/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java +++ b/src/main/java/starlight/adapter/expertApplication/webapi/swagger/ExpertApplicationApiDoc.java @@ -6,7 +6,6 @@ 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; @@ -14,111 +13,99 @@ 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; +import starlight.shared.auth.AuthenticatedMember; +import starlight.shared.apiPayload.response.ApiResponse; @Tag(name = "전문가", description = "전문가 관련 API") public interface ExpertApplicationApiDoc { @Operation( - summary = "피드백 요청한 전문가 목록 조회", - description = "특정 사업계획서에 피드백을 요청한 전문가들의 ID 목록을 조회합니다." + summary = "전문가에게 피드백 요청", + description = """ + 특정 전문가에게 사업계획서에 대한 피드백을 요청합니다. + + - 사업계획서 PDF 파일을 첨부하여 전문가 이메일로 발송합니다. + - 동일한 전문가에게 동일한 사업계획서로 중복 요청할 수 없습니다. + - 이메일 발송은 비동기로 처리되며, 요청 즉시 응답을 반환합니다. + """, + security = @SecurityRequirement(name = "Bearer Authentication") ) @ApiResponses({ - @ApiResponse( + @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "조회 성공", + description = "피드백 요청 성공", content = @Content( mediaType = "application/json", examples = @ExampleObject( value = """ { "result": "SUCCESS", - "data": [1, 3, 5, 7], + "data": "피드백 요청이 전달되었습니다.", "error": null } """ ) ) ), - @ApiResponse( - responseCode = "404", - description = "사업계획서를 찾을 수 없음", + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청 (파일 없음)", content = @Content( mediaType = "application/json", examples = @ExampleObject( + name = "빈 파일", value = """ { - "result": "ERROR", - "data": null, - "error": { - "code": "BUSINESS_PLAN_NOT_FOUND", - "message": "사업계획서를 찾을 수 없습니다." - } + "result": "ERROR", + "data": null, + "error": { + "code": "EMPTY_FILE", + "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 = "피드백 요청 성공", + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "413", + description = "파일 크기 초과", content = @Content( mediaType = "application/json", examples = @ExampleObject( value = """ { - "result": "SUCCESS", - "data": "피드백 요청이 전달되었습니다.", - "error": null + "result": "ERROR", + "data": null, + "error": { + "code": "FILE_SIZE_EXCEEDED", + "message": "파일 크기는 최대 20MB까지 업로드 가능합니다." + } } """ ) ) ), - @ApiResponse( - responseCode = "400", - description = "잘못된 요청 (파일 없음, 파일 형식 오류 등)", + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "415", + description = "지원하지 않는 파일 형식", content = @Content( mediaType = "application/json", examples = @ExampleObject( value = """ { - "result": "ERROR", - "data": null, - "error": { - "code": "INVALID_FILE", - "message": "유효하지 않은 파일입니다." - } + "result": "ERROR", + "data": null, + "error": { + "code": "UNSUPPORTED_FILE_TYPE", + "message": "지원되지 않는 파일 형식입니다." + } } """ ) ) ), - @ApiResponse( + @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", description = "전문가 또는 사업계획서를 찾을 수 없음", content = @Content( @@ -145,7 +132,7 @@ starlight.shared.apiPayload.response.ApiResponse> search( "data": null, "error": { "code": "BUSINESS_PLAN_NOT_FOUND", - "message": "사업계획서를 찾을 수 없습니다." + "message": "해당 사업계획서가 존재하지 않습니다." } } """ @@ -153,7 +140,7 @@ starlight.shared.apiPayload.response.ApiResponse> search( } ) ), - @ApiResponse( + @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "409", description = "이미 피드백을 요청한 전문가", content = @Content( @@ -172,23 +159,39 @@ starlight.shared.apiPayload.response.ApiResponse> search( ) ) ), - @ApiResponse( + @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "500", - description = "서버 오류 (파일 처리 실패, 이메일 발송 실패 등)", + description = "서버 오류 (파일 처리 실패 등)", content = @Content( mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "result": "ERROR", - "data": null, - "error": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 오류가 발생했습니다." + examples = { + @ExampleObject( + name = "파일 읽기 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "FILE_READ_ERROR", + "message": "파일을 읽는 중에 오류가 발생했습니다." + } + } + """ + ), + @ExampleObject( + name = "피드백 요청 처리 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_FEEDBACK_REQUEST_FAILED", + "message": "전문가 피드백 요청에 실패했습니다." + } + } + """ + ) } - } - """ - ) ) ) }) @@ -200,7 +203,7 @@ starlight.shared.apiPayload.response.ApiResponse> search( schema = @Schema(implementation = FeedbackRequestSchema.class) ) ) - starlight.shared.apiPayload.response.ApiResponse requestFeedback( + ApiResponse requestFeedback( @Parameter( description = "전문가 ID", required = true, @@ -223,7 +226,7 @@ starlight.shared.apiPayload.response.ApiResponse requestFeedback( @RequestParam("file") MultipartFile file, @Parameter(hidden = true) - @AuthenticationPrincipal AuthDetails auth + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) throws Exception; /** @@ -248,4 +251,4 @@ class FeedbackRequestSchema { ) 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 index 4cd23163..ce4a9306 100644 --- a/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java +++ b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportJpa.java @@ -3,7 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import starlight.application.expertReport.required.ExpertReportQuery; +import starlight.application.expertReport.required.ExpertReportCommandPort; +import starlight.application.expertReport.required.ExpertReportQueryPort; import starlight.domain.expertReport.entity.ExpertReport; import starlight.domain.expertReport.exception.ExpertReportErrorType; import starlight.domain.expertReport.exception.ExpertReportException; @@ -13,12 +14,12 @@ @Slf4j @Component @RequiredArgsConstructor -public class ExpertReportJpa implements ExpertReportQuery { +public class ExpertReportJpa implements ExpertReportQueryPort, ExpertReportCommandPort { private final ExpertReportRepository repository; @Override - public ExpertReport getOrThrow(Long id) { + public ExpertReport findByIdOrThrow(Long id) { return repository.findById(id).orElseThrow( () -> new ExpertReportException(ExpertReportErrorType.EXPERT_REPORT_NOT_FOUND) ); @@ -40,14 +41,14 @@ public boolean existsByToken(String token) { } @Override - public ExpertReport findByTokenWithDetails(String token) { - return repository.findByToken(token).orElseThrow( + public ExpertReport findByTokenWithCommentsOrThrow(String token) { + return repository.findByTokenWithComments(token).orElseThrow( () -> new ExpertReportException(ExpertReportErrorType.EXPERT_REPORT_NOT_FOUND) ); } @Override - public List findAllByBusinessPlanId(Long businessPlanId) { - return repository.findAllByBusinessPlanIdOrderByCreatedAtDesc(businessPlanId); + public List findAllByBusinessPlanIdWithCommentsOrderByCreatedAtDesc(Long businessPlanId) { + return repository.findAllByBusinessPlanIdWithCommentsOrderByCreatedAtDesc(businessPlanId); } } diff --git a/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java index b46a4b66..c969bcda 100644 --- a/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java +++ b/src/main/java/starlight/adapter/expertReport/persistence/ExpertReportRepository.java @@ -2,6 +2,8 @@ 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.expertReport.entity.ExpertReport; import java.util.List; @@ -11,9 +13,13 @@ public interface ExpertReportRepository extends JpaRepository findByToken(String token); + @EntityGraph(attributePaths = {"comments"}) + @Query("select er from ExpertReport er where er.token = :token") + Optional findByTokenWithComments(@Param("token") String token); - @EntityGraph(attributePaths = {"details"}) - List findAllByBusinessPlanIdOrderByCreatedAtDesc(Long businessPlanId); + @EntityGraph(attributePaths = {"comments"}) + @Query("select er from ExpertReport er where er.businessPlanId = :businessPlanId order by er.createdAt desc") + List findAllByBusinessPlanIdWithCommentsOrderByCreatedAtDesc( + @Param("businessPlanId") Long businessPlanId + ); } diff --git a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java index 5b62567c..1c400b76 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java @@ -1,17 +1,16 @@ 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.adapter.expertReport.webapi.swagger.ExpertReportApiDoc; +import starlight.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.expertReport.provided.dto.ExpertReportWithExpertResult; import starlight.domain.expertReport.entity.ExpertReport; -import starlight.domain.expertReport.entity.ExpertReportDetail; +import starlight.domain.expertReport.entity.ExpertReportComment; import starlight.shared.apiPayload.response.ApiResponse; import java.util.List; @@ -19,60 +18,58 @@ @RestController @RequestMapping("/v1/expert-reports") @RequiredArgsConstructor -@Tag(name = "전문가", description = "전문가 관련 API") -public class ExpertReportController { +public class ExpertReportController implements ExpertReportApiDoc { private final ExpertReportMapper mapper; - private final ExpertReportService expertReportService; + private final ExpertReportServiceUseCase expertReportService; - @Operation(summary = "전문가 리포트 목록을 조회합니다. (사용자 사용)") @GetMapping public ApiResponse> getExpertReports( @RequestParam Long businessPlanId ) { - List dtos = expertReportService + List dtos = expertReportService .getExpertReportsWithExpertByBusinessPlanId(businessPlanId); List responses = dtos.stream() .map(dto -> ExpertReportResponse.fromEntities( dto.report(), - dto.expert() + dto.expert(), + dto.applicationCount() )) .toList(); return ApiResponse.success(responses); } - @Operation(summary = "전문가 리포트를 조회합니다. (전문가 사용)") @GetMapping("/{token}") public ApiResponse getExpertReport( @PathVariable String token ) { - ExpertReportWithExpertDto dto = expertReportService.getExpertReportWithExpert(token); + ExpertReportWithExpertResult dto = expertReportService.getExpertReportWithExpert(token); ExpertReportResponse response = ExpertReportResponse.fromEntities( dto.report(), - dto.expert() + dto.expert(), + dto.applicationCount() ); return ApiResponse.success(response); } - @Operation(summary = "전문가 리포트를 저장합니다 (전문가 사용)") @PostMapping("/{token}") - public ApiResponse save( + public ApiResponse save( @PathVariable String token, @Valid @RequestBody UpsertExpertReportRequest request ) { - List details = mapper.toEntityList(request.details()); + List comments = mapper.toEntityList(request.comments()); ExpertReport report = expertReportService.saveReport( token, request.overallComment(), - details, + comments, 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/CreateExpertReportCommentRequest.java similarity index 80% rename from src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java rename to src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportCommentRequest.java index b18164ca..464f2ee7 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportDetailRequest.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/CreateExpertReportCommentRequest.java @@ -4,10 +4,10 @@ import jakarta.validation.constraints.NotNull; import starlight.domain.expertReport.enumerate.CommentType; -public record CreateExpertReportDetailRequest( +public record CreateExpertReportCommentRequest( @NotNull(message = "평가 타입은 필수입니다") - CommentType commentType, + CommentType type, @NotBlank(message = "내용은 필수입니다") String content -) { } \ No newline at end of file +) { } diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportCareerResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportCareerResponse.java new file mode 100644 index 00000000..213bab7a --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportCareerResponse.java @@ -0,0 +1,30 @@ +package starlight.adapter.expertReport.webapi.dto; + +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.time.LocalDateTime; + +public record ExpertReportCareerResponse( + Long id, + + Integer orderIndex, + + String careerTitle, + + String careerExplanation, + + LocalDateTime careerStartedAt, + + LocalDateTime careerEndedAt +) { + public static ExpertReportCareerResponse from(ExpertCareerResult result) { + return new ExpertReportCareerResponse( + result.id(), + result.orderIndex(), + result.careerTitle(), + result.careerExplanation(), + result.careerStartedAt(), + result.careerEndedAt() + ); + } +} diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportCommentResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportCommentResponse.java new file mode 100644 index 00000000..dcac60f5 --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportCommentResponse.java @@ -0,0 +1,17 @@ +package starlight.adapter.expertReport.webapi.dto; + +import starlight.domain.expertReport.entity.ExpertReportComment; +import starlight.domain.expertReport.enumerate.CommentType; + +public record ExpertReportCommentResponse( + CommentType type, + + String content +) { + public static ExpertReportCommentResponse from(ExpertReportComment comment) { + return new ExpertReportCommentResponse( + comment.getType(), + comment.getContent() + ); + } +} diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java deleted file mode 100644 index 0390448b..00000000 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportDetailResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -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/ExpertReportExpertResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportExpertResponse.java new file mode 100644 index 00000000..1b74b634 --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportExpertResponse.java @@ -0,0 +1,49 @@ +package starlight.adapter.expertReport.webapi.dto; + +import starlight.application.expert.provided.dto.ExpertDetailResult; + +import java.util.List; + +public record ExpertReportExpertResponse( + Long id, + + Long applicationCount, + + String name, + + String oneLineIntroduction, + + String detailedIntroduction, + + String profileImageUrl, + + Long workedPeriod, + + String email, + + Integer mentoringPriceWon, + + List careers, + + List tags +) { + public static ExpertReportExpertResponse from(ExpertDetailResult result) { + List careers = result.careers().stream() + .map(ExpertReportCareerResponse::from) + .toList(); + + return new ExpertReportExpertResponse( + result.id(), + result.applicationCount(), + result.name(), + result.oneLineIntroduction(), + result.detailedIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.mentoringPriceWon(), + careers, + result.tags() + ); + } +} diff --git a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java index d53d86c8..8dd599f6 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/ExpertReportResponse.java @@ -1,6 +1,6 @@ package starlight.adapter.expertReport.webapi.dto; -import starlight.adapter.expert.webapi.dto.ExpertDetailResponse; +import starlight.application.expert.provided.dto.ExpertDetailResult; import starlight.domain.expert.entity.Expert; import starlight.domain.expertReport.entity.ExpertReport; import starlight.domain.expertReport.enumerate.SubmitStatus; @@ -8,7 +8,7 @@ import java.util.List; public record ExpertReportResponse( - ExpertDetailResponse expertDetailResponse, + ExpertReportExpertResponse expertDetailResponse, SubmitStatus status, @@ -16,16 +16,16 @@ public record ExpertReportResponse( String overallComment, - List details + List comments ) { - public static ExpertReportResponse fromEntities(ExpertReport report, Expert expert) { + public static ExpertReportResponse fromEntities(ExpertReport report, Expert expert, Long applicationCount) { return new ExpertReportResponse( - ExpertDetailResponse.from(expert), + ExpertReportExpertResponse.from(ExpertDetailResult.from(expert, applicationCount)), report.getSubmitStatus(), report.canEdit(), report.getOverallComment(), - report.getDetails().stream() - .map(ExpertReportDetailResponse::from) + report.getComments().stream() + .map(ExpertReportCommentResponse::from) .toList() ); } @@ -36,9 +36,9 @@ public static ExpertReportResponse from(ExpertReport report) { report.getSubmitStatus(), report.canEdit(), report.getOverallComment(), - report.getDetails().stream() - .map(ExpertReportDetailResponse::from) + report.getComments().stream() + .map(ExpertReportCommentResponse::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 index 1f2c429c..93b49471 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/dto/UpsertExpertReportRequest.java @@ -3,6 +3,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import starlight.domain.expertReport.enumerate.SaveType; +import starlight.adapter.expertReport.webapi.dto.CreateExpertReportCommentRequest; import java.util.List; @@ -12,5 +13,5 @@ public record UpsertExpertReportRequest( String overallComment, - List<@Valid CreateExpertReportDetailRequest> details -) { } \ No newline at end of file + List<@Valid CreateExpertReportCommentRequest> comments +) { } diff --git a/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java index 89cb75e0..46410631 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/mapper/ExpertReportMapper.java @@ -1,21 +1,21 @@ 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 starlight.adapter.expertReport.webapi.dto.CreateExpertReportCommentRequest; +import starlight.domain.expertReport.entity.ExpertReportComment; import java.util.List; @Component public class ExpertReportMapper { - public ExpertReportDetail toEntity(CreateExpertReportDetailRequest dto) { - return ExpertReportDetail.create( - dto.commentType(), + public ExpertReportComment toEntity(CreateExpertReportCommentRequest dto) { + return ExpertReportComment.create( + dto.type(), dto.content() ); } - public List toEntityList(List dtos) { + public List toEntityList(List dtos) { return dtos.stream() .map(this::toEntity) .toList(); diff --git a/src/main/java/starlight/adapter/expertReport/webapi/swagger/ExpertReportApiDoc.java b/src/main/java/starlight/adapter/expertReport/webapi/swagger/ExpertReportApiDoc.java new file mode 100644 index 00000000..0ca877db --- /dev/null +++ b/src/main/java/starlight/adapter/expertReport/webapi/swagger/ExpertReportApiDoc.java @@ -0,0 +1,222 @@ +package starlight.adapter.expertReport.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 jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import starlight.adapter.expertReport.webapi.dto.ExpertReportResponse; +import starlight.adapter.expertReport.webapi.dto.UpsertExpertReportRequest; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "전문가 리포트", description = "전문가 피드백 리포트 API") +public interface ExpertReportApiDoc { + + @Operation(summary = "전문가 리포트 목록 조회 (사용자)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = ExpertReportResponse.class)) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping + ApiResponse> getExpertReports( + @RequestParam Long businessPlanId + ); + + @Operation(summary = "전문가 리포트 단건 조회 (전문가)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ExpertReportResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "리포트 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_REPORT_NOT_FOUND", + "message": "해당 전문가 리포트를 찾을 수 없습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping("/{token}") + ApiResponse getExpertReport( + @PathVariable String token + ); + + @Operation(summary = "전문가 리포트 저장 (전문가)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ExpertReportResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "이미 제출됨", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "ALREADY_SUBMITTED", + "message": "이미 전문가 피드백을 제출하였습니다." + } + } + """ + ), + @ExampleObject( + name = "요청 만료", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "REPORT_EXPIRED", + "message": "전문가 피드백 요청 기간이 만료되었습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "리포트 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_REPORT_NOT_FOUND", + "message": "해당 전문가 리포트를 찾을 수 없습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/{token}") + ApiResponse save( + @PathVariable String token, + @Valid @RequestBody UpsertExpertReportRequest request + ); +} diff --git a/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java b/src/main/java/starlight/adapter/member/auth/redis/RedisKeyValueMap.java similarity index 95% rename from src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java rename to src/main/java/starlight/adapter/member/auth/redis/RedisKeyValueMap.java index e6e69b97..1f59992b 100644 --- a/src/main/java/starlight/adapter/auth/redis/RedisKeyValueMap.java +++ b/src/main/java/starlight/adapter/member/auth/redis/RedisKeyValueMap.java @@ -1,10 +1,10 @@ -package starlight.adapter.auth.redis; +package starlight.adapter.member.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.application.member.auth.required.KeyValueMap; import starlight.shared.apiPayload.exception.GlobalErrorType; import starlight.shared.apiPayload.exception.GlobalException; diff --git a/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java b/src/main/java/starlight/adapter/member/auth/security/auth/AuthDetails.java similarity index 88% rename from src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java rename to src/main/java/starlight/adapter/member/auth/security/auth/AuthDetails.java index 3f5dc2cd..f22f1910 100644 --- a/src/main/java/starlight/adapter/auth/security/auth/AuthDetails.java +++ b/src/main/java/starlight/adapter/member/auth/security/auth/AuthDetails.java @@ -1,15 +1,17 @@ -package starlight.adapter.auth.security.auth; +package starlight.adapter.member.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 starlight.shared.auth.AuthenticatedMember; import java.util.*; import java.util.stream.Collectors; -public record AuthDetails(Member member, Map attributes, String nameAttributeKey) implements UserDetails, OAuth2User { +public record AuthDetails(Member member, Map attributes, String nameAttributeKey) + implements UserDetails, OAuth2User, AuthenticatedMember { // 폼 로그인 호환용 보조 생성자 public AuthDetails(Member member) { @@ -34,6 +36,11 @@ public Long getMemberId() { return member.getId(); } + @Override + public String getMemberName() { + return member.getName(); + } + @Override public String getPassword() { return ""; @@ -80,4 +87,3 @@ public static AuthDetails of(Member member, Map attrs, String nam 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/member/auth/security/auth/AuthDetailsService.java similarity index 71% rename from src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java rename to src/main/java/starlight/adapter/member/auth/security/auth/AuthDetailsService.java index 4488825d..e1280f0d 100644 --- a/src/main/java/starlight/adapter/auth/security/auth/AuthDetailsService.java +++ b/src/main/java/starlight/adapter/member/auth/security/auth/AuthDetailsService.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.auth; +package starlight.adapter.member.auth.security.auth; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; @@ -6,7 +6,7 @@ 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.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; import starlight.domain.member.exception.MemberErrorType; import starlight.domain.member.exception.MemberException; @@ -16,12 +16,13 @@ @RequiredArgsConstructor public class AuthDetailsService implements UserDetailsService { - private final MemberRepository memberRepository; + private final MemberQueryPort memberQueryPort; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - Member member = memberRepository.findByEmail(email).orElseThrow(() - -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND)); + Member member = memberQueryPort.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/member/auth/security/filter/ExceptionFilter.java similarity index 96% rename from src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java rename to src/main/java/starlight/adapter/member/auth/security/filter/ExceptionFilter.java index a27f629d..39d4547e 100644 --- a/src/main/java/starlight/adapter/auth/security/filter/ExceptionFilter.java +++ b/src/main/java/starlight/adapter/member/auth/security/filter/ExceptionFilter.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.filter; +package starlight.adapter.member.auth.security.filter; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.FilterChain; diff --git a/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java b/src/main/java/starlight/adapter/member/auth/security/filter/JwtFilter.java similarity index 73% rename from src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java rename to src/main/java/starlight/adapter/member/auth/security/filter/JwtFilter.java index f02ee3f1..f22c4c2f 100644 --- a/src/main/java/starlight/adapter/auth/security/filter/JwtFilter.java +++ b/src/main/java/starlight/adapter/member/auth/security/filter/JwtFilter.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.filter; +package starlight.adapter.member.auth.security.filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -12,8 +12,10 @@ 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 starlight.adapter.member.auth.security.auth.AuthDetailsService; +import starlight.adapter.member.auth.webapi.AuthTokenResolver; +import starlight.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.required.TokenProvider; import java.io.IOException; @@ -23,15 +25,17 @@ public class JwtFilter extends OncePerRequestFilter { private final TokenProvider tokenProvider; + private final KeyValueMap redisClient; private final AuthDetailsService authDetailsService; + private final AuthTokenResolver tokenResolver; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = getTokenFromRequest(request); + String token = tokenResolver.resolveAccessToken(request); - if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { + if (StringUtils.hasText(token) && redisClient.getValue(token) == null && tokenProvider.validateToken(token)) { String email = tokenProvider.getEmail(token); UserDetails userDetails = authDetailsService.loadUserByUsername(email); @@ -43,12 +47,4 @@ protected void doFilterInternal(HttpServletRequest request, } 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/member/auth/security/handler/JwtAccessDeniedHandler.java b/src/main/java/starlight/adapter/member/auth/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..a9b7e8eb --- /dev/null +++ b/src/main/java/starlight/adapter/member/auth/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package starlight.adapter.member.auth.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException{ + if (response.isCommitted()) { + return; + } + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + ApiResponse errorResponse = ApiResponse.error(GlobalErrorType.FORBIDDEN); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/starlight/adapter/member/auth/security/handler/JwtAuthenticationHandler.java b/src/main/java/starlight/adapter/member/auth/security/handler/JwtAuthenticationHandler.java new file mode 100644 index 00000000..3d8b7e78 --- /dev/null +++ b/src/main/java/starlight/adapter/member/auth/security/handler/JwtAuthenticationHandler.java @@ -0,0 +1,31 @@ +package starlight.adapter.member.auth.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 starlight.shared.apiPayload.exception.GlobalErrorType; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationHandler implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException{ + if (response.isCommitted()) { + return; + } + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + ApiResponse errorResponse = ApiResponse.error(GlobalErrorType.UNAUTHORIZED); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java b/src/main/java/starlight/adapter/member/auth/security/jwt/JwtTokenProvider.java similarity index 74% rename from src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java rename to src/main/java/starlight/adapter/member/auth/security/jwt/JwtTokenProvider.java index 3ac31b2f..cd40e104 100644 --- a/src/main/java/starlight/adapter/auth/security/jwt/JwtTokenProvider.java +++ b/src/main/java/starlight/adapter/member/auth/security/jwt/JwtTokenProvider.java @@ -1,7 +1,6 @@ -package starlight.adapter.auth.security.jwt; +package starlight.adapter.member.auth.security.jwt; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -13,9 +12,9 @@ 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.application.member.auth.provided.dto.AuthTokenResult; +import starlight.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.required.TokenProvider; import starlight.domain.member.entity.Member; import starlight.shared.apiPayload.exception.GlobalErrorType; import starlight.shared.apiPayload.exception.GlobalException; @@ -38,6 +37,9 @@ public class JwtTokenProvider implements TokenProvider { @Value("${jwt.token.refresh-expiration-time}") private long refreshTokenExpirationTime; + @Value("${jwt.prefix:Bearer}") + private String jwtPrefix; + private final KeyValueMap redisClient; @PostConstruct @@ -85,11 +87,11 @@ private String createRefreshToken(Member member) { * AccessToken과 RefreshToken을 생성하는 메서드 * * @param member - * @return TokenResponse + * @return AuthTokenResult */ @Override - public TokenResponse createToken(Member member) { - return TokenResponse.of( + public AuthTokenResult issueTokens(Member member) { + return AuthTokenResult.of( createAccessToken(member), createRefreshToken(member) ); @@ -100,16 +102,16 @@ public TokenResponse createToken(Member member) { * * @param member * @param refreshToken - * @return TokenResponse + * @return AuthTokenResult */ @Override - public TokenResponse recreate(Member member, String refreshToken) { + public AuthTokenResult reissueTokens(Member member, String refreshToken) { String accessToken = createAccessToken(member); if(getExpirationTime(refreshToken) <= getExpirationTime(accessToken)) { refreshToken = createRefreshToken(member); } - return TokenResponse.of(accessToken, refreshToken); + return AuthTokenResult.of(accessToken, refreshToken); } /** @@ -167,14 +169,16 @@ private Claims getClaims(Member member) { /** * Bearer Token에서 RefreshToken을 추출하는 메서드 * - * @param request - * @return String + * @deprecated Authorization 헤더 파싱은 {@code AuthTokenResolver}를 사용하세요. + * 1.4.0부터 Deprecated 처리되었고, 추후 제거될 수 있습니다. */ + @Deprecated(since = "1.4.0", forRemoval = false) @Override public String resolveRefreshToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); - if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); + String prefix = normalizePrefix(jwtPrefix); + if (bearerToken != null && bearerToken.toLowerCase().startsWith(prefix)) { + return bearerToken.substring(prefix.length()).trim(); } return null; } @@ -182,14 +186,16 @@ public String resolveRefreshToken(HttpServletRequest request) { /** * Bearer Token에서 AccessToken을 추출하는 메서드 * - * @param request - * @return String + * @deprecated Authorization 헤더 파싱은 {@code AuthTokenResolver}를 사용하세요. + * 1.4.0부터 Deprecated 처리되었고, 추후 제거될 수 있습니다. */ + @Deprecated(since = "1.4.0", forRemoval = false) @Override public String resolveAccessToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); - if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); + String prefix = normalizePrefix(jwtPrefix); + if (bearerToken != null && bearerToken.toLowerCase().startsWith(prefix)) { + return bearerToken.substring(prefix.length()).trim(); } return null; } @@ -203,11 +209,19 @@ public String resolveAccessToken(HttpServletRequest request) { */ @Override @Transactional - public void invalidateTokens(String refreshToken, String accessToken) { + public void logoutTokens(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 + + private String normalizePrefix(String prefix) { + if (prefix == null || prefix.isBlank()) { + return "bearer "; + } + String normalized = prefix.trim().toLowerCase(); + return normalized.endsWith(" ") ? normalized : normalized + " "; + } +} diff --git a/src/main/java/starlight/adapter/member/auth/security/jwt/dto/TokenResponse.java b/src/main/java/starlight/adapter/member/auth/security/jwt/dto/TokenResponse.java new file mode 100644 index 00000000..458c9f06 --- /dev/null +++ b/src/main/java/starlight/adapter/member/auth/security/jwt/dto/TokenResponse.java @@ -0,0 +1,13 @@ +package starlight.adapter.member.auth.security.jwt.dto; + +import starlight.application.member.auth.provided.dto.AuthTokenResult; + +public record TokenResponse( + String accessToken, + + String refreshToken +) { + public static TokenResponse from(AuthTokenResult result) { + return new TokenResponse(result.accessToken(), result.refreshToken()); + } +} diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java b/src/main/java/starlight/adapter/member/auth/security/oauth2/CustomOAuth2UserService.java similarity index 56% rename from src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java rename to src/main/java/starlight/adapter/member/auth/security/oauth2/CustomOAuth2UserService.java index 99fa1cc3..4cf507fe 100644 --- a/src/main/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserService.java +++ b/src/main/java/starlight/adapter/member/auth/security/oauth2/CustomOAuth2UserService.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.auth.security.oauth2; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -7,8 +7,9 @@ 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.adapter.member.auth.security.auth.AuthDetails; +import starlight.application.member.required.MemberCommandPort; +import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -17,11 +18,13 @@ @Service public class CustomOAuth2UserService implements OAuth2UserService { - private final MemberRepository memberRepository; + private final MemberQueryPort memberQueryPort; + private final MemberCommandPort memberCommandPort; private final OAuth2UserService delegate; - public CustomOAuth2UserService(MemberRepository memberRepository) { - this.memberRepository = memberRepository; + public CustomOAuth2UserService(MemberQueryPort memberQueryPort, MemberCommandPort memberCommandPort) { + this.memberQueryPort = memberQueryPort; + this.memberCommandPort = memberCommandPort; this.delegate = new DefaultOAuth2UserService(); } @@ -31,20 +34,30 @@ public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2Authenticatio OAuth2User oAuth2User = delegate.loadUser(request); OAuth2Attributes.Parsed parsed = OAuth2Attributes.parse(request, oAuth2User); - Optional found = memberRepository.findByProviderAndProviderId(parsed.provider(), parsed.providerId()); + Optional found = memberQueryPort.findByProviderAndProviderId(parsed.provider(), parsed.providerId()); if (found.isEmpty() && parsed.email() != null) { - found = memberRepository.findByEmail(parsed.email()); + found = memberQueryPort.findByEmail(parsed.email()); } Member member = found.orElseGet(() -> - memberRepository.save(Member.newSocial(parsed.name(), parsed.email(), parsed.provider(), parsed.providerId(), null, MemberType.FOUNDER, parsed.profileImageUrl())) + memberCommandPort.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); + memberCommandPort.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/member/auth/security/oauth2/OAuth2Attributes.java similarity index 75% rename from src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java rename to src/main/java/starlight/adapter/member/auth/security/oauth2/OAuth2Attributes.java index 2232b637..6763361f 100644 --- a/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2Attributes.java +++ b/src/main/java/starlight/adapter/member/auth/security/oauth2/OAuth2Attributes.java @@ -1,6 +1,8 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.auth.security.oauth2; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.Map; @@ -31,6 +33,9 @@ public static Parsed parse(OAuth2UserRequest req, OAuth2User o) { String name = (String) (response.getOrDefault("name", response.getOrDefault("nickname", ""))); String profileImage = (String) response.getOrDefault("profile_image", ""); + if (id.isBlank()) { + throw new OAuth2AuthenticationException(new OAuth2Error("invalid_provider_id"), "Missing provider id"); + } yield new Parsed("naver", id, email, name, profileImage, attributes, "id"); } case "kakao" -> { @@ -42,9 +47,13 @@ public static Parsed parse(OAuth2UserRequest req, OAuth2User o) { String name = (String) ((Map) response.getOrDefault("profile", Map.of())).getOrDefault("nickname", ""); String profileImage = (String) ((Map) response.getOrDefault("profile", Map.of())).getOrDefault("profile_image_url", ""); + if (id.isBlank()) { + throw new OAuth2AuthenticationException(new OAuth2Error("invalid_provider_id"), "Missing provider id"); + } yield new Parsed("kakao", id, email, name, profileImage, attributes, "id"); } - default -> new Parsed(registrationId, null, null, null, " ", attributes, "id"); + default -> throw new OAuth2AuthenticationException( + new OAuth2Error("unsupported_provider"), "Unsupported registrationId: " + registrationId); }; } } diff --git a/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java b/src/main/java/starlight/adapter/member/auth/security/oauth2/OAuth2SuccessHandler.java similarity index 80% rename from src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java rename to src/main/java/starlight/adapter/member/auth/security/oauth2/OAuth2SuccessHandler.java index 70569cb5..ddc38416 100644 --- a/src/main/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandler.java +++ b/src/main/java/starlight/adapter/member/auth/security/oauth2/OAuth2SuccessHandler.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.auth.security.oauth2; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -8,10 +8,10 @@ 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 starlight.adapter.member.auth.security.auth.AuthDetails; +import starlight.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.required.TokenProvider; +import starlight.application.member.auth.provided.dto.AuthTokenResult; import java.io.IOException; import java.net.URLEncoder; @@ -35,7 +35,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { public void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse res, Authentication auth) throws IOException { AuthDetails principal = (AuthDetails) auth.getPrincipal(); - TokenResponse tokens = tokenProvider.createToken(principal.getUser()); + AuthTokenResult tokens = tokenProvider.issueTokens(principal.getUser()); String access = tokens.accessToken(); String refresh = tokens.refreshToken(); diff --git a/src/main/java/starlight/adapter/member/auth/webapi/AuthController.java b/src/main/java/starlight/adapter/member/auth/webapi/AuthController.java new file mode 100644 index 00000000..271e035a --- /dev/null +++ b/src/main/java/starlight/adapter/member/auth/webapi/AuthController.java @@ -0,0 +1,49 @@ +package starlight.adapter.member.auth.webapi; + +import jakarta.servlet.http.HttpServletRequest; +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.member.auth.security.auth.AuthDetails; +import starlight.adapter.member.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.member.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.member.auth.webapi.dto.request.SignInRequest; +import starlight.adapter.member.auth.webapi.dto.response.MemberResponse; +import starlight.adapter.member.auth.webapi.swagger.AuthApiDoc; +import starlight.application.member.auth.provided.AuthUseCase; +import starlight.shared.apiPayload.response.ApiResponse; + +@RestController +@RequestMapping("/v1/auth") +@RequiredArgsConstructor +public class AuthController implements AuthApiDoc { + + private final AuthUseCase authUseCase; + private final AuthTokenResolver tokenResolver; + + @PostMapping("/sign-up") + public ApiResponse signUp(@Validated @RequestBody AuthRequest authRequest) { + return ApiResponse.success(MemberResponse.from(authUseCase.signUp(authRequest.toInput()))); + } + + @PostMapping("/sign-in") + public ApiResponse signIn(@Validated @RequestBody SignInRequest signInRequest) { + return ApiResponse.success(TokenResponse.from(authUseCase.signIn(signInRequest.toInput()))); + } + + @PostMapping("/sign-out") + public ApiResponse signOut(HttpServletRequest request) { + String refreshToken = tokenResolver.resolveRefreshToken(request); + String accessToken = tokenResolver.resolveAccessToken(request); + + authUseCase.signOut(refreshToken, accessToken); + return ApiResponse.success("로그아웃 성공"); + } + + @GetMapping("/recreate") + public ApiResponse reissue(HttpServletRequest request, @AuthenticationPrincipal AuthDetails authDetails) { + String token = tokenResolver.resolveRefreshToken(request); + return ApiResponse.success(TokenResponse.from(authUseCase.reissue(token, authDetails.getMemberId()))); + } +} diff --git a/src/main/java/starlight/adapter/member/auth/webapi/AuthTokenResolver.java b/src/main/java/starlight/adapter/member/auth/webapi/AuthTokenResolver.java new file mode 100644 index 00000000..311de6f2 --- /dev/null +++ b/src/main/java/starlight/adapter/member/auth/webapi/AuthTokenResolver.java @@ -0,0 +1,46 @@ +package starlight.adapter.member.auth.webapi; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AuthTokenResolver { + + private final String authHeaderName; + private final String bearerPrefix; + + public AuthTokenResolver(@Value("${jwt.header}") String authHeaderName, + @Value("${jwt.prefix:Bearer}") String prefix) { + this.authHeaderName = authHeaderName; + this.bearerPrefix = normalizePrefix(prefix); + } + + public String resolveAccessToken(HttpServletRequest request) { + return extractToken(request.getHeader(authHeaderName)); + } + + public String resolveRefreshToken(HttpServletRequest request) { + return extractToken(request.getHeader(authHeaderName)); + } + + private String extractToken(String raw) { + if (raw == null) { + return null; + } + String trimmed = raw.trim(); + if (trimmed.toLowerCase().startsWith(bearerPrefix)) { + String token = trimmed.substring(bearerPrefix.length()).trim(); + return token.isEmpty() ? null : token; + } + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizePrefix(String prefix) { + if (prefix == null || prefix.isBlank()) { + return "bearer "; + } + String normalized = prefix.trim().toLowerCase(); + return normalized.endsWith(" ") ? normalized : normalized + " "; + } +} diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java b/src/main/java/starlight/adapter/member/auth/webapi/dto/request/AuthRequest.java similarity index 77% rename from src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java rename to src/main/java/starlight/adapter/member/auth/webapi/dto/request/AuthRequest.java index ebf134e8..faf49bc9 100644 --- a/src/main/java/starlight/adapter/auth/webapi/dto/request/AuthRequest.java +++ b/src/main/java/starlight/adapter/member/auth/webapi/dto/request/AuthRequest.java @@ -1,13 +1,11 @@ -package starlight.adapter.auth.webapi.dto.request; +package starlight.adapter.member.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; +import starlight.application.member.auth.provided.dto.SignUpInput; public record AuthRequest( @@ -30,7 +28,7 @@ public record AuthRequest( @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password ) { - public Member toMember(Credential credential) { - return Member.create(name, email, phoneNumber, MemberType.FOUNDER, credential, null); + public SignUpInput toInput() { + return new SignUpInput(name, email, phoneNumber, password); } -} \ 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/member/auth/webapi/dto/request/SignInRequest.java similarity index 71% rename from src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java rename to src/main/java/starlight/adapter/member/auth/webapi/dto/request/SignInRequest.java index dbf7277c..ed192128 100644 --- a/src/main/java/starlight/adapter/auth/webapi/dto/request/SignInRequest.java +++ b/src/main/java/starlight/adapter/member/auth/webapi/dto/request/SignInRequest.java @@ -1,8 +1,9 @@ -package starlight.adapter.auth.webapi.dto.request; +package starlight.adapter.member.auth.webapi.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import starlight.application.member.auth.provided.dto.SignInInput; public record SignInRequest( @@ -14,4 +15,8 @@ public record SignInRequest( @Schema(description = "비밀번호", example = "password123") @NotBlank(message = "비밀번호는 필수입니다") String password -) { } \ No newline at end of file +) { + public SignInInput toInput() { + return new SignInInput(email, password); + } +} diff --git a/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java b/src/main/java/starlight/adapter/member/auth/webapi/dto/response/MemberResponse.java similarity index 63% rename from src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java rename to src/main/java/starlight/adapter/member/auth/webapi/dto/response/MemberResponse.java index 6d7ef416..d4819d47 100644 --- a/src/main/java/starlight/adapter/auth/webapi/dto/response/MemberResponse.java +++ b/src/main/java/starlight/adapter/member/auth/webapi/dto/response/MemberResponse.java @@ -1,7 +1,7 @@ -package starlight.adapter.auth.webapi.dto.response; +package starlight.adapter.member.auth.webapi.dto.response; import io.swagger.v3.oas.annotations.media.Schema; -import starlight.domain.member.entity.Member; +import starlight.application.member.auth.provided.dto.AuthMemberResult; import starlight.domain.member.enumerate.MemberType; public record MemberResponse( @@ -17,12 +17,12 @@ public record MemberResponse( @Schema(description = "회원 타입", example = "FOUNDER | EXPERT") MemberType memberType ) { - public static MemberResponse of(Member member) { + public static MemberResponse from(AuthMemberResult result) { return new MemberResponse( - member.getId(), - member.getEmail(), - member.getPhoneNumber(), - member.getMemberType() + result.id(), + result.email(), + result.phoneNumber(), + result.memberType() ); } } diff --git a/src/main/java/starlight/adapter/member/auth/webapi/swagger/AuthApiDoc.java b/src/main/java/starlight/adapter/member/auth/webapi/swagger/AuthApiDoc.java new file mode 100644 index 00000000..02db8b5a --- /dev/null +++ b/src/main/java/starlight/adapter/member/auth/webapi/swagger/AuthApiDoc.java @@ -0,0 +1,333 @@ +package starlight.adapter.member.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.member.auth.security.auth.AuthDetails; +import starlight.adapter.member.auth.security.jwt.dto.TokenResponse; +import starlight.adapter.member.auth.webapi.dto.request.AuthRequest; +import starlight.adapter.member.auth.webapi.dto.request.SignInRequest; +import starlight.adapter.member.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( + mediaType = "application/json", + 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( + mediaType = "application/json", + schema = @Schema(implementation = TokenResponse.class), + examples = @ExampleObject( + name = "로그인 성공", + value = """ + { + "result": "SUCCESS", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NTk2OTA5MDB9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NjAyOTIxMDB9..." + }, + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "비밀번호 불일치", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "비밀번호 불일치", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "PASSWORD_MISMATCH", + "message": "비밀번호가 일치하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사용자 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "사용자 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MEMBER_NOT_FOUND", + "message": "존재하지 않는 사용자입니다." + } + } + """ + ) + ) + ) + }) + @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 = "사용자 로그아웃 기능" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "로그아웃 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "로그아웃 성공", + value = """ + { + "result": "SUCCESS", + "data": "로그아웃 성공", + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "토큰 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "토큰 유효하지 않음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "TOKEN_INVALID", + "message": "토큰이 유효하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "토큰 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "토큰 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "TOKEN_NOT_FOUND", + "message": "토큰이 존재하지 않습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/sign-out") + ApiResponse signOut(HttpServletRequest request); + + @Operation( + summary = "토큰 재발급", + description = "AccessToken 만료 시 RefreshToken으로 AccessToken 재발급" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "재발급 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = TokenResponse.class), + examples = @ExampleObject( + name = "재발급 성공", + value = """ + { + "result": "SUCCESS", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NTk2OTA5MDB9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdGFyTGlnaHRAZ21haWwuY29tIiwiaWF0IjoxNzU5Njg3MzAwLCJleHAiOjE3NjAyOTIxMDB9..." + }, + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "토큰 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "토큰 유효하지 않음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "TOKEN_INVALID", + "message": "토큰이 유효하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "토큰/사용자 없음", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "토큰 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "TOKEN_NOT_FOUND", + "message": "토큰이 존재하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "사용자 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MEMBER_NOT_FOUND", + "message": "존재하지 않는 사용자입니다." + } + } + """ + ) + } + ) + ) + }) + @GetMapping("/recreate") + ApiResponse reissue(HttpServletRequest request, @AuthenticationPrincipal AuthDetails authDetails); +} diff --git a/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java b/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java deleted file mode 100644 index 9e3f7638..00000000 --- a/src/main/java/starlight/adapter/member/persistence/CredentialRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -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 index 3d5280aa..79e6b1a4 100644 --- a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -2,21 +2,46 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import starlight.application.member.required.MemberQuery; +import starlight.application.member.required.MemberCommandPort; +import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; import starlight.domain.member.exception.MemberErrorType; import starlight.domain.member.exception.MemberException; +import java.util.Optional; + @Repository @RequiredArgsConstructor -public class MemberJpa implements MemberQuery { +public class MemberJpa implements MemberQueryPort, MemberCommandPort { private final MemberRepository memberRepository; @Override - public Member getOrThrow(Long id) { + public Member findByIdOrThrow(Long id) { return memberRepository.findById(id).orElseThrow( () -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND) ); } + + @Override + public Optional findByEmail(String email) { + return memberRepository.findByEmail(email); + } + + @Override + public Optional findByProviderAndProviderId(String provider, String providerId) { + return memberRepository.findByProviderAndProviderId(provider, providerId); + } + + @Override + public Member findByProviderAndProviderIdOrThrow(String provider, String providerId) { + return memberRepository.findByProviderAndProviderId(provider, providerId).orElseThrow( + () -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND) + ); + } + + @Override + public Member save(Member member) { + return memberRepository.save(member); + } } diff --git a/src/main/java/starlight/adapter/member/webapi/MemberController.java b/src/main/java/starlight/adapter/member/webapi/MemberController.java index f5e8b042..bfa19d73 100644 --- a/src/main/java/starlight/adapter/member/webapi/MemberController.java +++ b/src/main/java/starlight/adapter/member/webapi/MemberController.java @@ -1,29 +1,31 @@ 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.swagger.MemberApiDoc; import starlight.adapter.member.webapi.dto.MemberDetailResponse; +import starlight.application.member.provided.MemberQueryUseCase; +import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @Slf4j @RestController @RequiredArgsConstructor -@Tag(name = "사용자", description = "사용자 관련 API") @RequestMapping("/v1/members") -public class MemberController { +public class MemberController implements MemberApiDoc { + + private final MemberQueryUseCase memberQueryUseCase; @GetMapping - @Operation(summary = "멤버 정보를 조회합니다.") public ApiResponse getMemberDetail( - @AuthenticationPrincipal AuthDetails authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { - return ApiResponse.success(MemberDetailResponse.from(authDetails.getUser())); + return ApiResponse.success(MemberDetailResponse.fromMember( + memberQueryUseCase.getUserById(authenticatedMember.getMemberId()) + )); } } diff --git a/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java b/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java index 1d123553..bd7e5342 100644 --- a/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java +++ b/src/main/java/starlight/adapter/member/webapi/dto/MemberDetailResponse.java @@ -15,7 +15,7 @@ public record MemberDetailResponse ( String profileImageUrl ){ - public static MemberDetailResponse from(Member member) { + public static MemberDetailResponse fromMember(Member member) { return new MemberDetailResponse( member.getId(), member.getName(), @@ -25,4 +25,4 @@ public static MemberDetailResponse from(Member member) { member.getProfileImageUrl() ); } -} \ No newline at end of file +} diff --git a/src/main/java/starlight/adapter/member/webapi/swagger/MemberApiDoc.java b/src/main/java/starlight/adapter/member/webapi/swagger/MemberApiDoc.java new file mode 100644 index 00000000..9295dbf5 --- /dev/null +++ b/src/main/java/starlight/adapter/member/webapi/swagger/MemberApiDoc.java @@ -0,0 +1,70 @@ +package starlight.adapter.member.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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import starlight.adapter.member.webapi.dto.MemberDetailResponse; +import starlight.shared.auth.AuthenticatedMember; +import starlight.shared.apiPayload.response.ApiResponse; + +@Tag(name = "사용자", description = "사용자 관련 API") +public interface MemberApiDoc { + + @Operation(summary = "멤버 정보를 조회합니다.", security = @SecurityRequirement(name = "Bearer Authentication")) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = MemberDetailResponse.class), + examples = @ExampleObject( + name = "성공 예시", + value = """ + { + "result": "SUCCESS", + "data": { + "id": 1, + "name": "홍길동", + "email": "hong@example.com", + "phoneNumber": "010-1234-5678", + "provider": "KAKAO", + "profileImageUrl": "https://cdn.example.com/profile/1.png" + }, + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "멤버 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MEMBER_NOT_FOUND", + "message": "존재하지 않는 사용자입니다." + } + } + """ + ) + ) + ) + }) + @GetMapping + ApiResponse getMemberDetail( + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ); +} diff --git a/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java index f4cf0f5d..976c7fa4 100644 --- a/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java +++ b/src/main/java/starlight/adapter/order/persistence/OrderRepositoryJpa.java @@ -2,8 +2,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import starlight.application.order.provided.OrdersQuery; -import starlight.domain.expertReport.entity.ExpertReport; +import starlight.application.order.required.OrderCommandPort; +import starlight.application.order.required.OrderQueryPort; import starlight.domain.order.exception.OrderErrorType; import starlight.domain.order.exception.OrderException; import starlight.domain.order.order.Orders; @@ -13,7 +13,7 @@ @Repository @RequiredArgsConstructor -public class OrderRepositoryJpa implements OrdersQuery { +public class OrderRepositoryJpa implements OrderQueryPort, OrderCommandPort { private final OrdersRepository repository; diff --git a/src/main/java/starlight/adapter/order/toss/TossClient.java b/src/main/java/starlight/adapter/order/toss/TossClient.java index ee8640ea..4ba2d08e 100644 --- a/src/main/java/starlight/adapter/order/toss/TossClient.java +++ b/src/main/java/starlight/adapter/order/toss/TossClient.java @@ -5,7 +5,8 @@ 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.application.order.provided.dto.TossClientResult; +import starlight.application.order.required.PaymentGatewayPort; import starlight.domain.order.exception.OrderErrorType; import starlight.domain.order.exception.OrderException; @@ -14,7 +15,7 @@ @Slf4j @Component -public class TossClient { +public class TossClient implements PaymentGatewayPort { private final RestClient restClient; @@ -22,20 +23,21 @@ public TossClient(@Qualifier("tossRestClient") RestClient restClient) { this.restClient = restClient; } - public TossClientResponse.Confirm confirm(String orderCode, String paymentKey, Long price) { + @Override + public TossClientResult.Confirm confirm(String orderCode, String paymentKey, Long price) { Map body = Map.of( "paymentKey", paymentKey, "orderId", orderCode, "amount", price ); try { - TossClientResponse.Confirm response = restClient.post() + TossClientResult.Confirm response = restClient.post() .uri("/v1/payments/confirm") .header("Idempotency-Key", orderCode) .contentType(MediaType.APPLICATION_JSON) .body(body) .retrieve() - .body(TossClientResponse.Confirm.class); + .body(TossClientResult.Confirm.class); // 응답 검증 validateConfirmResponse(response, orderCode, price); @@ -48,17 +50,18 @@ public TossClientResponse.Confirm confirm(String orderCode, String paymentKey, L } } - public TossClientResponse.Cancel cancel(String paymentKey, String reason) { + @Override + public TossClientResult.Cancel cancel(String paymentKey, String reason) { Map body = Map.of( "cancelReason", reason != null ? reason : "user_request" ); try { - TossClientResponse.Cancel response = restClient.post() + TossClientResult.Cancel response = restClient.post() .uri("/v1/payments/{paymentKey}/cancel", paymentKey) .contentType(MediaType.APPLICATION_JSON) .body(body) .retrieve() - .body(TossClientResponse.Cancel.class); + .body(TossClientResult.Cancel.class); // 응답 검증 validateCancelResponse(response); @@ -68,7 +71,7 @@ public TossClientResponse.Cancel cancel(String paymentKey, String reason) { } catch (Exception e) { log.error("토스 환불 요청 중 에러발생: {}", e.getMessage(), e); log.error("paymentKey: {}, reason: {}", paymentKey, reason); - throw new OrderException(OrderErrorType.TOSS_CLIENT_CONFIRM_ERROR); + throw new OrderException(OrderErrorType.TOSS_CLIENT_CANCEL_ERROR); } } @@ -76,7 +79,7 @@ public TossClientResponse.Cancel cancel(String paymentKey, String reason) { /** * confirm 응답 검증 */ - private void validateConfirmResponse(TossClientResponse.Confirm response, String expectedOrderId, Long expectedAmount) { + private void validateConfirmResponse(TossClientResult.Confirm response, String expectedOrderId, Long expectedAmount) { if (response == null) { throw new IllegalStateException("PG 응답이 null입니다."); } @@ -105,7 +108,7 @@ private void validateConfirmResponse(TossClientResponse.Confirm response, String /** * cancel 응답 검증 */ - private void validateCancelResponse(TossClientResponse.Cancel response) { + private void validateCancelResponse(TossClientResult.Cancel response) { if (response == null) { throw new IllegalStateException("PG 취소 응답이 null입니다."); } diff --git a/src/main/java/starlight/adapter/order/webapi/OrderController.java b/src/main/java/starlight/adapter/order/webapi/OrderController.java index 46829fee..c9edc481 100644 --- a/src/main/java/starlight/adapter/order/webapi/OrderController.java +++ b/src/main/java/starlight/adapter/order/webapi/OrderController.java @@ -1,48 +1,40 @@ 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.swagger.OrderApiDoc; +import starlight.application.order.provided.dto.TossClientResult; 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.application.order.provided.OrderPaymentServiceUseCase; +import starlight.application.order.provided.dto.PaymentHistoryItemResult; import starlight.domain.order.order.Orders; +import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; import java.util.List; @RestController @RequiredArgsConstructor -@Tag(name = "결제", description = "결제 관련 API") @RequestMapping("/v1/orders") -public class OrderController { +public class OrderController implements OrderApiDoc { - private final OrderPaymentService orderPaymentService; - private final UsageCreditPort usageCreditPort; + private final OrderPaymentServiceUseCase orderPaymentService; - /** - * 결제 준비 (주문 생성) - * POST /api/toss/request - */ @PostMapping("/request") public ApiResponse prepareOrder( @Valid @RequestBody OrderPrepareRequest request, - @AuthenticationPrincipal AuthDetails authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { Orders order = orderPaymentService.prepare( request.orderCode(), - authDetails.getMemberId(), + authenticatedMember.getMemberId(), request.productCode() ); @@ -51,19 +43,15 @@ public ApiResponse prepareOrder( return ApiResponse.success(response); } - /** - * 결제 승인 - * POST /api/toss/confirm - */ @PostMapping("/confirm") public ApiResponse confirmPayment( @Valid @RequestBody OrderConfirmRequest request, - @AuthenticationPrincipal AuthDetails authDetails + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { Orders order = orderPaymentService.confirm( request.orderCode(), request.paymentKey(), - authDetails.getMemberId() + authenticatedMember.getMemberId() ); OrderConfirmResponse response = OrderConfirmResponse.from(order); @@ -71,31 +59,26 @@ public ApiResponse confirmPayment( return ApiResponse.success(response); } - /** - * 결제 취소 - * POST /api/toss/cancel - */ @PostMapping("/cancel") public ApiResponse cancelPayment( @Valid @RequestBody OrderCancelRequest request ) { - TossClientResponse.Cancel tossResponse = orderPaymentService.cancel(request); + TossClientResult.Cancel tossResponse = orderPaymentService.cancel( + request.orderCode(), + request.reason() + ); OrderCancelResponse response = OrderCancelResponse.from(tossResponse); return ApiResponse.success(response); } - /** - * 나의 결제 내역 조회 - * GET /api/orders - */ @GetMapping - public ApiResponse> getMyPayments( - @AuthenticationPrincipal AuthDetails authDetails + public ApiResponse> getMyPayments( + @AuthenticationPrincipal AuthenticatedMember authenticatedMember ) { - Long memberId = authDetails.getMemberId(); - List history = orderPaymentService.getPaymentHistory(memberId); + Long memberId = authenticatedMember.getMemberId(); + List history = orderPaymentService.getPaymentHistory(memberId); return ApiResponse.success(history); } 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 index 6ffc25cd..e8975bde 100644 --- a/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java +++ b/src/main/java/starlight/adapter/order/webapi/dto/response/OrderCancelResponse.java @@ -1,6 +1,6 @@ package starlight.adapter.order.webapi.dto.response; -import starlight.application.order.provided.dto.TossClientResponse; +import starlight.application.order.provided.dto.TossClientResult; public record OrderCancelResponse( String orderId, @@ -8,7 +8,7 @@ public record OrderCancelResponse( String status, Integer totalAmount ) { - public static OrderCancelResponse from(TossClientResponse.Cancel tossResponse) { + public static OrderCancelResponse from(TossClientResult.Cancel tossResponse) { return new OrderCancelResponse( tossResponse.orderId(), tossResponse.paymentKey(), diff --git a/src/main/java/starlight/adapter/order/webapi/swagger/OrderApiDoc.java b/src/main/java/starlight/adapter/order/webapi/swagger/OrderApiDoc.java new file mode 100644 index 00000000..8c406bc7 --- /dev/null +++ b/src/main/java/starlight/adapter/order/webapi/swagger/OrderApiDoc.java @@ -0,0 +1,333 @@ +package starlight.adapter.order.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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +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.RequestBody; +import starlight.shared.auth.AuthenticatedMember; +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.application.order.provided.dto.PaymentHistoryItemResult; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "결제", description = "결제 관련 API") +public interface OrderApiDoc { + + @Operation(summary = "결제 준비", security = @SecurityRequirement(name = "Bearer Authentication")) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = OrderPrepareResponse.class), + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": { + "orderCode": "O-20250101-0001", + "amount": 150000, + "productCode": "USAGE_10" + }, + "error": null + } + """ + ) + ) + ) + , + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "유효하지 않은 이용권", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_USAGE_PRODUCT", + "message": "유효하지 않은 이용권 금액입니다." + } + } + """ + ), + @ExampleObject( + name = "주문번호-구매자 불일치", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "ORDER_CODE_BUYER_MISMATCH", + "message": "이미 존재하는 주문번호입니다. (구매자 상이)" + } + } + """ + ), + @ExampleObject( + name = "주문번호-이용권 불일치", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "ORDER_PRODUCT_MISMATCH", + "message": "이미 존재하는 주문번호입니다. (이용권 금액 상이)" + } + } + """ + ), + @ExampleObject( + name = "이미 결제된 주문", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "ALREADY_PAID", + "message": "이미 결제가 완료된 주문입니다." + } + } + """ + ) + } + ) + ) + }) + @PostMapping("/request") + ApiResponse prepareOrder( + @Valid @RequestBody OrderPrepareRequest request, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ); + + @Operation(summary = "결제 승인", security = @SecurityRequirement(name = "Bearer Authentication")) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = OrderConfirmResponse.class) + ) + ) + , + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "결제 금액 불일치", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "PAYMENT_AMOUNT_MISMATCH", + "message": "주문 금액과 결제 금액이 일치하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "승인 가능한 결제 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "NO_REQUESTED_PAYMENT", + "message": "승인 가능한 결제 시도가 존재하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "결제 상태 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_ORDER_STATE_FOR_PAYMENT", + "message": "주문 생성 상태에서만 결제 가능합니다." + } + } + """ + ), + @ExampleObject( + name = "PG 승인 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "TOSS_CLIENT_CONFIRM_ERROR", + "message": "토스 결제 요청 중 오류가 발생했습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "주문 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "ORDER_NOT_FOUND", + "message": "주문을 찾을 수 없습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/confirm") + ApiResponse confirmPayment( + @Valid @RequestBody OrderConfirmRequest request, + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ); + + @Operation(summary = "결제 취소") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = OrderCancelResponse.class) + ) + ) + , + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "취소 가능한 결제 이력 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "NO_PAYMENT_RECORDS", + "message": "주문에 결제 이력이 존재하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "paymentKey 누락", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "NO_PAYMENT_KEY", + "message": "paymentKey가 없어 PG 취소를 수행할 수 없습니다." + } + } + """ + ), + @ExampleObject( + name = "결제 상태 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_ORDER_STATE_FOR_CANCEL", + "message": "결제 완료 상태에서만 취소 가능합니다." + } + } + """ + ), + @ExampleObject( + name = "PG 취소 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "TOSS_CLIENT_CANCEL_ERROR", + "message": "토스 결제 취소 요청 중 오류가 발생했습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "주문 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "ORDER_NOT_FOUND", + "message": "주문을 찾을 수 없습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/cancel") + ApiResponse cancelPayment( + @Valid @RequestBody OrderCancelRequest request + ); + + @Operation(summary = "내 결제 내역 조회", security = @SecurityRequirement(name = "Bearer Authentication")) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = PaymentHistoryItemResult.class)) + ) + ) + }) + @GetMapping + ApiResponse> getMyPayments( + @AuthenticationPrincipal AuthenticatedMember authenticatedMember + ); +} diff --git a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryJpa.java similarity index 63% rename from src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java rename to src/main/java/starlight/adapter/usage/persistence/UsageHistoryJpa.java index 803689b9..55583b5b 100644 --- a/src/main/java/starlight/adapter/usage/persistence/UsageHistoryRepositoryJpa.java +++ b/src/main/java/starlight/adapter/usage/persistence/UsageHistoryJpa.java @@ -2,17 +2,17 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import starlight.application.usage.provided.UsageHistoryQuery; +import starlight.application.usage.required.UsageHistoryCommandPort; import starlight.domain.order.wallet.UsageHistory; @Repository @RequiredArgsConstructor -public class UsageHistoryRepositoryJpa implements UsageHistoryQuery { +public class UsageHistoryJpa implements UsageHistoryCommandPort { private final UsageHistoryRepository repository; @Override - public UsageHistory save(UsageHistory usageHistory){ + 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/UsageWalletRepositoryJpa.java b/src/main/java/starlight/adapter/usage/persistence/UsageWalletJpa.java similarity index 56% rename from src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java rename to src/main/java/starlight/adapter/usage/persistence/UsageWalletJpa.java index c8bdf4c6..df5dbbb2 100644 --- a/src/main/java/starlight/adapter/usage/persistence/UsageWalletRepositoryJpa.java +++ b/src/main/java/starlight/adapter/usage/persistence/UsageWalletJpa.java @@ -2,24 +2,25 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import starlight.application.usage.provided.UsageWalletQuery; +import starlight.application.usage.required.UsageWalletCommandPort; +import starlight.application.usage.required.UsageWalletQueryPort; import starlight.domain.order.wallet.UsageWallet; import java.util.Optional; @Repository @RequiredArgsConstructor -public class UsageWalletRepositoryJpa implements UsageWalletQuery { +public class UsageWalletJpa implements UsageWalletQueryPort, UsageWalletCommandPort { private final UsageWalletRepository repository; @Override - public Optional findByUserId(Long userId){ + public Optional findByUserId(Long userId) { return repository.findByUserId(userId); } @Override - public UsageWallet save(UsageWallet usageWallet){ + 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 index 0db896a8..6a7e123f 100644 --- a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java +++ b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java @@ -15,7 +15,7 @@ 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.application.aireport.required.OcrProvider; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -41,7 +41,7 @@ public class AiReportServiceImpl implements AiReportService { @Override public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.getOrThrow(planId); + BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); checkBusinessPlanWritingCompleted(plan); @@ -63,7 +63,7 @@ public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUr memberId ); Long businessPlanId = businessPlanResult.businessPlanId(); - BusinessPlan plan = businessPlanQuery.getOrThrow(businessPlanId); + BusinessPlan plan = businessPlanQuery.findByIdOrThrow(businessPlanId); String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); @@ -79,7 +79,7 @@ public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUr @Override @Transactional(readOnly = true) public AiReportResponse getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.getOrThrow(planId); + BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); AiReport aiReport = aiReportQuery.findByBusinessPlanId(planId) diff --git a/src/main/java/starlight/application/infrastructure/provided/OcrProvider.java b/src/main/java/starlight/application/aireport/required/OcrProvider.java similarity index 76% rename from src/main/java/starlight/application/infrastructure/provided/OcrProvider.java rename to src/main/java/starlight/application/aireport/required/OcrProvider.java index e3a25fbd..0e8050d5 100644 --- a/src/main/java/starlight/application/infrastructure/provided/OcrProvider.java +++ b/src/main/java/starlight/application/aireport/required/OcrProvider.java @@ -1,4 +1,4 @@ -package starlight.application.infrastructure.provided; +package starlight.application.aireport.required; import starlight.shared.dto.infrastructure.OcrResponse; diff --git a/src/main/java/starlight/application/infrastructure/provided/PresignedUrlProvider.java b/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java similarity index 80% rename from src/main/java/starlight/application/infrastructure/provided/PresignedUrlProvider.java rename to src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java index 78f060e7..0a262be0 100644 --- a/src/main/java/starlight/application/infrastructure/provided/PresignedUrlProvider.java +++ b/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java @@ -1,4 +1,4 @@ -package starlight.application.infrastructure.provided; +package starlight.application.aireport.required; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/application/auth/AuthServiceImpl.java b/src/main/java/starlight/application/auth/AuthServiceImpl.java deleted file mode 100644 index bc7baa2b..00000000 --- a/src/main/java/starlight/application/auth/AuthServiceImpl.java +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 1aa8056f..00000000 --- a/src/main/java/starlight/application/auth/provided/AuthService.java +++ /dev/null @@ -1,19 +0,0 @@ -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/TokenProvider.java b/src/main/java/starlight/application/auth/required/TokenProvider.java deleted file mode 100644 index e84b5daf..00000000 --- a/src/main/java/starlight/application/auth/required/TokenProvider.java +++ /dev/null @@ -1,26 +0,0 @@ -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 index ba5247fa..157b89b7 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java @@ -16,7 +16,7 @@ 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.application.member.required.MemberQueryPort; import starlight.domain.businessplan.entity.*; import starlight.domain.businessplan.enumerate.PlanStatus; import starlight.domain.member.entity.Member; @@ -35,13 +35,13 @@ public class BusinessPlanServiceImpl implements BusinessPlanService { private final BusinessPlanQuery businessPlanQuery; - private final MemberQuery memberQuery; + private final MemberQueryPort memberQuery; private final ChecklistGrader checklistGrader; private final ObjectMapper objectMapper; @Override public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { - Member member = memberQuery.getOrThrow(memberId); + Member member = memberQuery.findByIdOrThrow(memberId); String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; @@ -72,7 +72,10 @@ public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberI @Override @Transactional(readOnly = true) public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + BusinessPlan plan = businessPlanQuery.getOrThrowWithAllSubSections(planId); + if (!plan.isOwnedBy(memberId)) { + throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); + } List subSectionDetailList = Arrays.stream(SubSectionType.values()) .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type)) @@ -233,7 +236,7 @@ private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List findAiReportBusinessPlans(Long expertId, Long memberId) { + + List plans = businessPlanLookupPort.findAllByMemberId(memberId); + if (plans.isEmpty()) { + return List.of(); + } + + List planIds = plans.stream() + .map(BusinessPlan::getId) + .toList(); + + Map totalScoreMap = aiReportSummaryLookupPort.findTotalScoresByBusinessPlanIds(planIds); + if (totalScoreMap.isEmpty()) { + return List.of(); + } + + List aiReportPlanIds = totalScoreMap.keySet().stream().toList(); + Map requestCountMap = expertApplicationCountLookupPort.countByExpertIdAndBusinessPlanIds(expertId, aiReportPlanIds); + + return plans.stream() + .filter(plan -> totalScoreMap.containsKey(plan.getId())) + .map(plan -> { + Integer totalScore = totalScoreMap.getOrDefault(plan.getId(), 0); + boolean isOver70 = totalScore >= 70; + Long requestCount = requestCountMap.getOrDefault(plan.getId(), 0L); + return new ExpertAiReportBusinessPlanResult( + plan.getId(), + plan.getTitle(), + requestCount, + isOver70 + ); + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java new file mode 100644 index 00000000..88edceaa --- /dev/null +++ b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java @@ -0,0 +1,45 @@ +package starlight.application.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.expert.provided.ExpertDetailQueryUseCase; +import starlight.application.expert.provided.dto.ExpertDetailResult; +import starlight.application.expert.required.ExpertApplicationCountLookupPort; +import starlight.application.expert.required.ExpertQueryPort; +import starlight.domain.expert.entity.Expert; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ExpertDetailQueryService implements ExpertDetailQueryUseCase { + + private final ExpertQueryPort expertQueryPort; + private final ExpertApplicationCountLookupPort expertApplicationLookupPort; + + @Override + public List searchAll() { + List experts = expertQueryPort.findAllWithCareersTagsCategories(); + + List expertIds = experts.stream() + .map(Expert::getId) + .toList(); + + Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); + + return experts.stream() + .map(expert -> ExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) + .toList(); + } + + @Override + public ExpertDetailResult findById(Long expertId) { + Expert expert = expertQueryPort.findByIdWithCareersAndTags(expertId); + Map countMap = expertApplicationLookupPort.countByExpertIds(List.of(expertId)); + long count = countMap.getOrDefault(expertId, 0L); + return ExpertDetailResult.from(expert, count); + } +} diff --git a/src/main/java/starlight/application/expert/ExpertQueryService.java b/src/main/java/starlight/application/expert/ExpertQueryService.java deleted file mode 100644 index c21c258e..00000000 --- a/src/main/java/starlight/application/expert/ExpertQueryService.java +++ /dev/null @@ -1,47 +0,0 @@ -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/ExpertAiReportQueryUseCase.java b/src/main/java/starlight/application/expert/provided/ExpertAiReportQueryUseCase.java new file mode 100644 index 00000000..3a07be11 --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/ExpertAiReportQueryUseCase.java @@ -0,0 +1,10 @@ +package starlight.application.expert.provided; + +import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; + +import java.util.List; + +public interface ExpertAiReportQueryUseCase { + + List findAiReportBusinessPlans(Long expertId, Long memberId); +} diff --git a/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java new file mode 100644 index 00000000..705b16ee --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java @@ -0,0 +1,12 @@ +package starlight.application.expert.provided; + +import starlight.application.expert.provided.dto.ExpertDetailResult; + +import java.util.List; + +public interface ExpertDetailQueryUseCase { + + List searchAll(); + + ExpertDetailResult findById(Long expertId); +} diff --git a/src/main/java/starlight/application/expert/provided/ExpertFinder.java b/src/main/java/starlight/application/expert/provided/ExpertFinder.java deleted file mode 100644 index 5fa1a25a..00000000 --- a/src/main/java/starlight/application/expert/provided/ExpertFinder.java +++ /dev/null @@ -1,21 +0,0 @@ -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/provided/dto/ExpertAiReportBusinessPlanResult.java b/src/main/java/starlight/application/expert/provided/dto/ExpertAiReportBusinessPlanResult.java new file mode 100644 index 00000000..82fa46cf --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/dto/ExpertAiReportBusinessPlanResult.java @@ -0,0 +1,9 @@ +package starlight.application.expert.provided.dto; + +public record ExpertAiReportBusinessPlanResult( + Long businessPlanId, + String businessPlanTitle, + Long requestCount, + boolean isOver70 +) { +} diff --git a/src/main/java/starlight/application/expert/provided/dto/ExpertCareerResult.java b/src/main/java/starlight/application/expert/provided/dto/ExpertCareerResult.java new file mode 100644 index 00000000..839e8454 --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/dto/ExpertCareerResult.java @@ -0,0 +1,25 @@ +package starlight.application.expert.provided.dto; + +import starlight.domain.expert.entity.ExpertCareer; + +import java.time.LocalDateTime; + +public record ExpertCareerResult( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { + public static ExpertCareerResult from(ExpertCareer expertCareer) { + return new ExpertCareerResult( + expertCareer.getId(), + expertCareer.getOrderIndex(), + expertCareer.getCareerTitle(), + expertCareer.getCareerExplanation(), + expertCareer.getCareerStartedAt(), + expertCareer.getCareerEndedAt() + ); + } +} diff --git a/src/main/java/starlight/application/expert/provided/dto/ExpertDetailResult.java b/src/main/java/starlight/application/expert/provided/dto/ExpertDetailResult.java new file mode 100644 index 00000000..0c0a8a38 --- /dev/null +++ b/src/main/java/starlight/application/expert/provided/dto/ExpertDetailResult.java @@ -0,0 +1,51 @@ +package starlight.application.expert.provided.dto; + +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record ExpertDetailResult( + Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + String detailedIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + Integer mentoringPriceWon, + List careers, + List tags, + List categories +) { + public static ExpertDetailResult from(Expert expert, long applicationCount) { + List careers = expert.getCareers().stream() + .map(ExpertCareerResult::from) + .toList(); + + List categories = expert.getCategories().stream() + .map(TagCategory::name) + .distinct() + .toList(); + + List tags = expert.getTags().stream() + .distinct() + .toList(); + + return new ExpertDetailResult( + expert.getId(), + applicationCount, + expert.getName(), + expert.getOneLineIntroduction(), + expert.getDetailedIntroduction(), + expert.getProfileImageUrl(), + expert.getWorkedPeriod(), + expert.getEmail(), + expert.getMentoringPriceWon(), + careers, + tags, + categories + ); + } +} diff --git a/src/main/java/starlight/application/expert/required/AiReportSummaryLookupPort.java b/src/main/java/starlight/application/expert/required/AiReportSummaryLookupPort.java new file mode 100644 index 00000000..00db8d52 --- /dev/null +++ b/src/main/java/starlight/application/expert/required/AiReportSummaryLookupPort.java @@ -0,0 +1,9 @@ +package starlight.application.expert.required; + +import java.util.List; +import java.util.Map; + +public interface AiReportSummaryLookupPort { + + Map findTotalScoresByBusinessPlanIds(List businessPlanIds); +} diff --git a/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java b/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java new file mode 100644 index 00000000..63a11a09 --- /dev/null +++ b/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java @@ -0,0 +1,10 @@ +package starlight.application.expert.required; + +import starlight.domain.businessplan.entity.BusinessPlan; + +import java.util.List; + +public interface BusinessPlanLookupPort { + + List findAllByMemberId(Long memberId); +} diff --git a/src/main/java/starlight/application/expert/required/ExpertApplicationCountLookupPort.java b/src/main/java/starlight/application/expert/required/ExpertApplicationCountLookupPort.java new file mode 100644 index 00000000..14850807 --- /dev/null +++ b/src/main/java/starlight/application/expert/required/ExpertApplicationCountLookupPort.java @@ -0,0 +1,11 @@ +package starlight.application.expert.required; + +import java.util.List; +import java.util.Map; + +public interface ExpertApplicationCountLookupPort { + + Map countByExpertIds(List expertIds); + + Map countByExpertIdAndBusinessPlanIds(Long expertId, List businessPlanIds); +} diff --git a/src/main/java/starlight/application/expert/required/ExpertQuery.java b/src/main/java/starlight/application/expert/required/ExpertQuery.java deleted file mode 100644 index 925dcd7a..00000000 --- a/src/main/java/starlight/application/expert/required/ExpertQuery.java +++ /dev/null @@ -1,22 +0,0 @@ -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/expert/required/ExpertQueryPort.java b/src/main/java/starlight/application/expert/required/ExpertQueryPort.java new file mode 100644 index 00000000..62d3ef26 --- /dev/null +++ b/src/main/java/starlight/application/expert/required/ExpertQueryPort.java @@ -0,0 +1,12 @@ +package starlight.application.expert.required; + +import starlight.domain.expert.entity.Expert; +import java.util.List; + +public interface ExpertQueryPort { + + Expert findByIdWithCareersAndTags(Long id); + + List findAllWithCareersTagsCategories(); + +} diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java similarity index 81% rename from src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java rename to src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java index 2f6e3e6e..82e00040 100644 --- a/src/main/java/starlight/application/expertApplication/ExpertApplicationServiceImpl.java +++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java @@ -8,14 +8,16 @@ 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.application.expertApplication.event.FeedbackRequestInput; +import starlight.application.expertApplication.provided.ExpertApplicationCommandUseCase; +import starlight.application.expertApplication.required.ExpertLookupPort; +import starlight.application.expertApplication.required.ExpertApplicationQueryPort; +import starlight.application.expertReport.provided.ExpertReportServiceUseCase; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.domain.businessplan.exception.BusinessPlanException; import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.exception.ExpertException; import starlight.domain.expertApplication.entity.ExpertApplication; import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; import starlight.domain.expertApplication.exception.ExpertApplicationException; @@ -27,13 +29,13 @@ @Slf4j @Service @RequiredArgsConstructor -public class ExpertApplicationServiceImpl implements ExpertApplicationService { +public class ExpertApplicationCommandService implements ExpertApplicationCommandUseCase { - private final ExpertQuery expertQuery; + private final ExpertLookupPort expertLookupPort; private final BusinessPlanQuery planQuery; - private final ExpertApplicationQuery applicationQuery; + private final ExpertApplicationQueryPort applicationQueryPort; private final ApplicationEventPublisher eventPublisher; - private final ExpertReportService expertReportService; + private final ExpertReportServiceUseCase expertReportUseCase; private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB private static final String ALLOWED_CONTENT_TYPE = "application/pdf"; @@ -47,14 +49,16 @@ public void requestFeedback(Long expertId, Long planId, MultipartFile file, Stri try { validateFile(file); - BusinessPlan plan = planQuery.getOrThrow(planId); - Expert expert = expertQuery.findById(expertId); + BusinessPlan plan = planQuery.findByIdOrThrow(planId); + Expert expert = expertLookupPort.findByIdOrThrow(expertId); plan.updateStatus(PlanStatus.EXPERT_MATCHED); registerApplicationRecord(expertId, planId); publishEmailEvent(expert, plan, file, menteeName); + } catch (ExpertApplicationException | BusinessPlanException | ExpertException e) { + throw e; } catch (Exception e) { log.error("Failed to request Feedback. planId={}, expertId={}", planId, expertId, e); throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_FEEDBACK_REQUEST_FAILED); @@ -77,12 +81,12 @@ private void validateFile(MultipartFile file) { } public void registerApplicationRecord(Long expertId, Long planId) { - if (applicationQuery.existsByExpertIdAndBusinessPlanId(expertId, planId)) { + if (applicationQueryPort.existsByExpertIdAndBusinessPlanId(expertId, planId)) { throw new ExpertApplicationException(ExpertApplicationErrorType.APPLICATION_ALREADY_EXISTS); } ExpertApplication application = ExpertApplication.create(planId, expertId); - applicationQuery.save(application); + applicationQueryPort.save(application); } private String generateFilename(MultipartFile file, BusinessPlan plan, String menteeName) { @@ -101,7 +105,7 @@ protected void publishEmailEvent(Expert expert, BusinessPlan plan, MultipartFile String filename = generateFilename(file, plan, menteeName); String feedbackUrl = buildFeedbackRequestUrl(expert.getId(), plan.getId()); - FeedbackRequestDto event = FeedbackRequestDto.of( + FeedbackRequestInput event = FeedbackRequestInput.of( expert.getEmail(), expert.getName(), menteeName, @@ -122,6 +126,6 @@ protected void publishEmailEvent(Expert expert, BusinessPlan plan, MultipartFile } private String buildFeedbackRequestUrl(Long expertId, Long planId) { - return expertReportService.createExpertReportLink(expertId, planId); + return expertReportUseCase.createExpertReportLink(expertId, planId); } } diff --git a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java index fff777a7..b3ceb740 100644 --- a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java +++ b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.java @@ -25,7 +25,7 @@ public class FeedbackRequestEventListener { backoff = @Backoff(delay = 2000, multiplier = 2), retryFor = {Exception.class} ) - public void handleFeedbackRequestEvent(FeedbackRequestDto event) { + public void handleFeedbackRequestEvent(FeedbackRequestInput event) { log.info("[EMAIL] listener triggered menteeName={}, businessPlanTitle={}", event.menteeName(), event.businessPlanTitle()); try { emailSender.sendFeedbackRequestMail(event); @@ -41,7 +41,7 @@ public void handleFeedbackRequestEvent(FeedbackRequestDto event) { } @Recover - public void recoverEmailSend(Exception e, FeedbackRequestDto event) { + public void recoverEmailSend(Exception e, FeedbackRequestInput event) { log.error("[EMAIL FINAL FAILURE] ... menteeName={}, businessPlanTitle={}", event.menteeName(), event.businessPlanTitle(), e); @@ -50,4 +50,4 @@ public void recoverEmailSend(Exception e, FeedbackRequestDto event) { // 2. 관리자에게 알림 전송 // 3. DB에 실패 기록 저장 } -} \ No newline at end of file +} diff --git a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestInput.java similarity index 84% rename from src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java rename to src/main/java/starlight/application/expertApplication/event/FeedbackRequestInput.java index 2226a90c..d159f829 100644 --- a/src/main/java/starlight/application/expertApplication/event/FeedbackRequestDto.java +++ b/src/main/java/starlight/application/expertApplication/event/FeedbackRequestInput.java @@ -1,6 +1,6 @@ package starlight.application.expertApplication.event; -public record FeedbackRequestDto( +public record FeedbackRequestInput( String mentorEmail, String mentorName, @@ -17,11 +17,11 @@ public record FeedbackRequestDto( String filename ) { - public static FeedbackRequestDto of( + public static FeedbackRequestInput of( String mentorEmail, String mentorName, String menteeName, String businessPlanTitle, String feedbackDeadline, String feedbackUrl, byte[] attachedFile, String filename ) { - return new FeedbackRequestDto( + return new FeedbackRequestInput( mentorEmail, mentorName, menteeName, businessPlanTitle, feedbackDeadline, feedbackUrl, attachedFile, filename ); diff --git a/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java b/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationCommandUseCase.java similarity index 83% rename from src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java rename to src/main/java/starlight/application/expertApplication/provided/ExpertApplicationCommandUseCase.java index 17e63974..7820c594 100644 --- a/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationService.java +++ b/src/main/java/starlight/application/expertApplication/provided/ExpertApplicationCommandUseCase.java @@ -4,7 +4,7 @@ import java.io.IOException; -public interface ExpertApplicationService { +public interface ExpertApplicationCommandUseCase { 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 index 87a97d5e..1d56550a 100644 --- a/src/main/java/starlight/application/expertApplication/required/EmailSender.java +++ b/src/main/java/starlight/application/expertApplication/required/EmailSender.java @@ -1,8 +1,8 @@ package starlight.application.expertApplication.required; -import starlight.application.expertApplication.event.FeedbackRequestDto; +import starlight.application.expertApplication.event.FeedbackRequestInput; public interface EmailSender { - void sendFeedbackRequestMail(FeedbackRequestDto dto); + void sendFeedbackRequestMail(FeedbackRequestInput dto); } diff --git a/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQueryPort.java similarity index 68% rename from src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java rename to src/main/java/starlight/application/expertApplication/required/ExpertApplicationQueryPort.java index 33ad33ed..a954c8ef 100644 --- a/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQuery.java +++ b/src/main/java/starlight/application/expertApplication/required/ExpertApplicationQueryPort.java @@ -2,12 +2,8 @@ import starlight.domain.expertApplication.entity.ExpertApplication; -import java.util.List; - -public interface ExpertApplicationQuery { +public interface ExpertApplicationQueryPort { Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId); - List findRequestedExpertIds(Long businessPlanId); - ExpertApplication save(ExpertApplication application); } diff --git a/src/main/java/starlight/application/expertApplication/required/ExpertLookupPort.java b/src/main/java/starlight/application/expertApplication/required/ExpertLookupPort.java new file mode 100644 index 00000000..cd912dae --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/required/ExpertLookupPort.java @@ -0,0 +1,8 @@ +package starlight.application.expertApplication.required; + +import starlight.domain.expert.entity.Expert; + +public interface ExpertLookupPort { + + Expert findByIdOrThrow(Long id); +} diff --git a/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java b/src/main/java/starlight/application/expertReport/ExpertReportService.java similarity index 55% rename from src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java rename to src/main/java/starlight/application/expertReport/ExpertReportService.java index 44295a5d..37d77dee 100644 --- a/src/main/java/starlight/application/expertReport/ExpertReportServiceImpl.java +++ b/src/main/java/starlight/application/expertReport/ExpertReportService.java @@ -3,18 +3,21 @@ 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.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.expertReport.provided.dto.ExpertReportWithExpertResult; +import starlight.application.expertReport.required.ExpertApplicationCountLookupPort; +import starlight.application.expertReport.required.ExpertLookupPort; +import starlight.application.expertReport.required.ExpertReportCommandPort; +import starlight.application.expertReport.required.ExpertReportQueryPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; import starlight.domain.expertReport.entity.ExpertReport; -import starlight.domain.expertReport.entity.ExpertReportDetail; +import starlight.domain.expertReport.entity.ExpertReportComment; import starlight.domain.expertReport.enumerate.SaveType; import java.security.SecureRandom; @@ -26,7 +29,7 @@ @Service @RequiredArgsConstructor @Transactional -public class ExpertReportServiceImpl implements ExpertReportService { +public class ExpertReportService implements ExpertReportServiceUseCase { @Value("${feedback-token.token-length}") private int tokenLength; @@ -37,8 +40,10 @@ public class ExpertReportServiceImpl implements ExpertReportService { @Value("${feedback-token.base-url}") private String feedbackBaseUrl; - private final ExpertReportQuery expertReportQuery; - private final ExpertFinder expertFinder; + private final ExpertReportQueryPort expertReportQuery; + private final ExpertReportCommandPort expertReportCommand; + private final ExpertLookupPort expertLookupPort; + private final ExpertApplicationCountLookupPort expertApplicationLookupPort; private final BusinessPlanQuery businessPlanQuery; private final SecureRandom secureRandom = new SecureRandom(); @@ -50,7 +55,7 @@ public String createExpertReportLink( String token = generateToken(); ExpertReport report = ExpertReport.create(expertId, businessPlanId, token); - expertReportQuery.save(report); + expertReportCommand.save(report); return feedbackBaseUrl + token; } @@ -59,13 +64,13 @@ public String createExpertReportLink( public ExpertReport saveReport( String token, String overallComment, - List details, + List comments, SaveType saveType ) { - ExpertReport report = expertReportQuery.findByTokenWithDetails(token); + ExpertReport report = expertReportQuery.findByTokenWithCommentsOrThrow(token); report.updateOverallComment(overallComment); - report.updateDetails(details); + report.updateComments(comments); switch (saveType) { case TEMPORARY -> { @@ -73,40 +78,53 @@ public ExpertReport saveReport( } case FINAL -> { report.submit(); - BusinessPlan plan = businessPlanQuery.getOrThrow(report.getBusinessPlanId()); + BusinessPlan plan = businessPlanQuery.findByIdOrThrow(report.getBusinessPlanId()); plan.updateStatus(PlanStatus.FINALIZED); } } - return expertReportQuery.save(report); + return expertReportCommand.save(report); } @Override - public ExpertReportWithExpertDto getExpertReportWithExpert(String token) { - ExpertReport report = expertReportQuery.findByTokenWithDetails(token); + public ExpertReportWithExpertResult getExpertReportWithExpert(String token) { + ExpertReport report = expertReportQuery.findByTokenWithCommentsOrThrow(token); report.incrementViewCount(); - Expert expert = expertFinder.findByIdWithDetails(report.getExpertId()); + Expert expert = expertLookupPort.findByIdWithCareersAndTags(report.getExpertId()); - return ExpertReportWithExpertDto.of(report, expert); + Map countMap = expertApplicationLookupPort.countByExpertIds(List.of(report.getExpertId())); + Long applicationCount = countMap.getOrDefault(report.getExpertId(), 0L); + + return ExpertReportWithExpertResult.of(report, expert, applicationCount); } @Override @Transactional(readOnly = true) - public List getExpertReportsWithExpertByBusinessPlanId(Long businessPlanId) { - List reports = expertReportQuery.findAllByBusinessPlanId(businessPlanId); + public List getExpertReportsWithExpertByBusinessPlanId(Long businessPlanId) { + businessPlanQuery.findByIdOrThrow(businessPlanId); + + List reports = expertReportQuery.findAllByBusinessPlanIdWithCommentsOrderByCreatedAtDesc( + businessPlanId + ); Set expertIds = reports.stream() .map(ExpertReport::getExpertId) .collect(Collectors.toSet()); - Map expertsMap = expertFinder.findByIds(expertIds); + Map expertsMap = expertLookupPort.findByIds(expertIds); + if (!expertIds.isEmpty() && expertsMap.size() != expertIds.size()) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } + + Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds.stream().toList()); return reports.stream() .map(report -> { Expert expert = expertsMap.get(report.getExpertId()); - return ExpertReportWithExpertDto.of(report, expert); + Long applicationCount = countMap.getOrDefault(report.getExpertId(), 0L); + return ExpertReportWithExpertResult.of(report, expert, applicationCount); }) .toList(); } @@ -125,4 +143,4 @@ private String generateToken() { 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/ExpertReportServiceUseCase.java similarity index 51% rename from src/main/java/starlight/application/expertReport/provided/ExpertReportService.java rename to src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java index 1a6242e4..6e6e9f29 100644 --- a/src/main/java/starlight/application/expertReport/provided/ExpertReportService.java +++ b/src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java @@ -1,19 +1,19 @@ package starlight.application.expertReport.provided; -import starlight.application.expertReport.provided.dto.ExpertReportWithExpertDto; +import starlight.application.expertReport.provided.dto.ExpertReportWithExpertResult; import starlight.domain.expertReport.entity.ExpertReport; -import starlight.domain.expertReport.entity.ExpertReportDetail; +import starlight.domain.expertReport.entity.ExpertReportComment; import starlight.domain.expertReport.enumerate.SaveType; import java.util.List; -public interface ExpertReportService{ +public interface ExpertReportServiceUseCase{ String createExpertReportLink(Long expertId, Long businessPlanId); - ExpertReport saveReport(String token, String overallComment, List details, SaveType saveType); + ExpertReport saveReport(String token, String overallComment, List comments, SaveType saveType); - ExpertReportWithExpertDto getExpertReportWithExpert(String token); + ExpertReportWithExpertResult getExpertReportWithExpert(String token); - List getExpertReportsWithExpertByBusinessPlanId(Long businessPlanId); -} \ No newline at end of file + List getExpertReportsWithExpertByBusinessPlanId(Long businessPlanId); +} diff --git a/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertDto.java b/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertDto.java deleted file mode 100644 index 8983f3b3..00000000 --- a/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertDto.java +++ /dev/null @@ -1,14 +0,0 @@ -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/provided/dto/ExpertReportWithExpertResult.java b/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertResult.java new file mode 100644 index 00000000..f6d420ae --- /dev/null +++ b/src/main/java/starlight/application/expertReport/provided/dto/ExpertReportWithExpertResult.java @@ -0,0 +1,16 @@ +package starlight.application.expertReport.provided.dto; + +import starlight.domain.expert.entity.Expert; +import starlight.domain.expertReport.entity.ExpertReport; + +public record ExpertReportWithExpertResult( + ExpertReport report, + + Expert expert, + + Long applicationCount +) { + public static ExpertReportWithExpertResult of(ExpertReport report, Expert expert, Long applicationCount) { + return new ExpertReportWithExpertResult(report, expert, applicationCount); + } +} diff --git a/src/main/java/starlight/application/expertReport/required/ExpertApplicationCountLookupPort.java b/src/main/java/starlight/application/expertReport/required/ExpertApplicationCountLookupPort.java new file mode 100644 index 00000000..31d3429c --- /dev/null +++ b/src/main/java/starlight/application/expertReport/required/ExpertApplicationCountLookupPort.java @@ -0,0 +1,9 @@ +package starlight.application.expertReport.required; + +import java.util.List; +import java.util.Map; + +public interface ExpertApplicationCountLookupPort { + + Map countByExpertIds(List expertIds); +} diff --git a/src/main/java/starlight/application/expertReport/required/ExpertLookupPort.java b/src/main/java/starlight/application/expertReport/required/ExpertLookupPort.java new file mode 100644 index 00000000..fef360ac --- /dev/null +++ b/src/main/java/starlight/application/expertReport/required/ExpertLookupPort.java @@ -0,0 +1,13 @@ +package starlight.application.expertReport.required; + +import starlight.domain.expert.entity.Expert; + +import java.util.Map; +import java.util.Set; + +public interface ExpertLookupPort { + + Expert findByIdWithCareersAndTags(Long id); + + Map findByIds(Set expertIds); +} diff --git a/src/main/java/starlight/application/expertReport/required/ExpertReportCommandPort.java b/src/main/java/starlight/application/expertReport/required/ExpertReportCommandPort.java new file mode 100644 index 00000000..6e6bcf01 --- /dev/null +++ b/src/main/java/starlight/application/expertReport/required/ExpertReportCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.expertReport.required; + +import starlight.domain.expertReport.entity.ExpertReport; + +public interface ExpertReportCommandPort { + + ExpertReport save(ExpertReport expertReport); + + void delete(ExpertReport expertReport); +} diff --git a/src/main/java/starlight/application/expertReport/required/ExpertReportQuery.java b/src/main/java/starlight/application/expertReport/required/ExpertReportQuery.java deleted file mode 100644 index 6611e5dd..00000000 --- a/src/main/java/starlight/application/expertReport/required/ExpertReportQuery.java +++ /dev/null @@ -1,20 +0,0 @@ -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/expertReport/required/ExpertReportQueryPort.java b/src/main/java/starlight/application/expertReport/required/ExpertReportQueryPort.java new file mode 100644 index 00000000..1f115cf7 --- /dev/null +++ b/src/main/java/starlight/application/expertReport/required/ExpertReportQueryPort.java @@ -0,0 +1,16 @@ +package starlight.application.expertReport.required; + +import starlight.domain.expertReport.entity.ExpertReport; + +import java.util.List; + +public interface ExpertReportQueryPort { + + ExpertReport findByIdOrThrow(Long id); + + boolean existsByToken(String token); + + ExpertReport findByTokenWithCommentsOrThrow(String token); + + List findAllByBusinessPlanIdWithCommentsOrderByCreatedAtDesc(Long businessPlanId); +} diff --git a/src/main/java/starlight/application/member/CredentialServiceImpl.java b/src/main/java/starlight/application/member/CredentialServiceImpl.java index c3d1d069..04977d25 100644 --- a/src/main/java/starlight/application/member/CredentialServiceImpl.java +++ b/src/main/java/starlight/application/member/CredentialServiceImpl.java @@ -3,11 +3,9 @@ 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.auth.exception.AuthErrorType; +import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; @@ -16,19 +14,16 @@ public class CredentialServiceImpl implements CredentialService { private final PasswordEncoder passwordEncoder; - private final CredentialRepository credentialRepository; /** - * Credential을 생성하고 저장하는 메서드 - * @param authRequest - * @return Credential + * Credential을 생성하는 메서드 + * @param rawPassword + * @return 저장되지 않은 Credential */ - public Credential createCredential(AuthRequest authRequest) { + public Credential createCredential(String rawPassword) { - String hashedPassword = passwordEncoder.encode(authRequest.password()); - Credential credential = Credential.create(hashedPassword); - - return credentialRepository.save(credential); + String hashedPassword = passwordEncoder.encode(rawPassword); + return Credential.create(hashedPassword); } /** diff --git a/src/main/java/starlight/application/member/MemberQueryService.java b/src/main/java/starlight/application/member/MemberQueryService.java new file mode 100644 index 00000000..d635f8f0 --- /dev/null +++ b/src/main/java/starlight/application/member/MemberQueryService.java @@ -0,0 +1,49 @@ +package starlight.application.member; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.required.MemberCommandPort; +import starlight.application.member.required.MemberQueryPort; +import starlight.domain.member.entity.Credential; +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; +import starlight.domain.member.exception.MemberErrorType; +import starlight.domain.member.exception.MemberException; + +@Service +@RequiredArgsConstructor +public class MemberQueryService implements MemberQueryUseCase { + + private final MemberQueryPort memberQueryPort; + private final MemberCommandPort memberCommandPort; + + /** + * Credential을 생성하고 저장하는 메서드 + * @param credential + * @param authRequest + * @return Member + */ + public Member createUser(Credential credential, String name, String email, String phoneNumber) { + memberQueryPort.findByEmail(email).ifPresent(existingUser -> { + throw new MemberException(MemberErrorType.MEMBER_ALREADY_EXISTS); + }); + Member member = Member.create(name, email, phoneNumber, MemberType.FOUNDER, credential, null); + return memberCommandPort.save(member); + } + + /** + * 이메일로 사용자를 조회하는 메서드 + * @param email + * @return Member + */ + public Member getUserByEmail(String email) { + return memberQueryPort.findByEmail(email) + .orElseThrow(() -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND)); + } + + @Override + public Member getUserById(Long id) { + return memberQueryPort.findByIdOrThrow(id); + } +} diff --git a/src/main/java/starlight/application/member/MemberServiceImpl.java b/src/main/java/starlight/application/member/MemberServiceImpl.java deleted file mode 100644 index 8e0c8f97..00000000 --- a/src/main/java/starlight/application/member/MemberServiceImpl.java +++ /dev/null @@ -1,43 +0,0 @@ -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/auth/AuthServiceImpl.java b/src/main/java/starlight/application/member/auth/AuthServiceImpl.java new file mode 100644 index 00000000..e5fd1d0e --- /dev/null +++ b/src/main/java/starlight/application/member/auth/AuthServiceImpl.java @@ -0,0 +1,147 @@ +package starlight.application.member.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.member.auth.provided.AuthUseCase; +import starlight.application.member.auth.provided.dto.AuthMemberResult; +import starlight.application.member.auth.provided.dto.AuthTokenResult; +import starlight.application.member.auth.provided.dto.SignInInput; +import starlight.application.member.auth.provided.dto.SignUpInput; +import starlight.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.required.TokenProvider; +import starlight.application.member.provided.CredentialService; +import starlight.application.member.provided.MemberQueryUseCase; +import starlight.domain.member.auth.exception.AuthErrorType; +import starlight.domain.member.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 AuthUseCase { + + private final MemberQueryUseCase memberQueryUseCase; + private final CredentialService credentialService; + private final TokenProvider tokenProvider; + private final KeyValueMap redisClient; + + @Value("${jwt.token.refresh-expiration-time}") + private Long refreshTokenExpirationTime; + + /** + * 회원가입 메서드 + * + * @param input 회원가입 입력값 + * @return AuthMemberResult + */ + @Override + @Transactional + public AuthMemberResult signUp(SignUpInput input) { + Credential credential = credentialService.createCredential(input.password()); + Member member = memberQueryUseCase.createUser( + credential, + input.name(), + input.email(), + input.phoneNumber() + ); + + return AuthMemberResult.from(member); + } + + /** + * 로그인 메서드 + * + * @param input 로그인 입력값 + * @return AuthTokenResult + */ + @Override + @Transactional + public AuthTokenResult signIn(SignInInput input) { + Member member = memberQueryUseCase.getUserByEmail(input.email()); + credentialService.checkPassword(member, input.password()); + + AuthTokenResult tokenResponse = tokenProvider.issueTokens(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); + } + if (!tokenProvider.validateToken(refreshToken)) { + throw new AuthException(AuthErrorType.TOKEN_INVALID); + } + tokenProvider.logoutTokens(refreshToken, accessToken); + } + + /** + * 토큰 재발급 메서드 + * + * @param token + * @param memberId + * @return AuthTokenResult + */ + @Override + public AuthTokenResult reissue(String token, Long memberId) { + if (token == null) { + throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND); + } + if (memberId == null) { + throw new MemberException(MemberErrorType.MEMBER_NOT_FOUND); + } + Member member = memberQueryUseCase.getUserById(memberId); + + String refreshToken = extractToken(token); + boolean isValid = tokenProvider.validateToken(refreshToken); + + if (!isValid) { + throw new AuthException(AuthErrorType.TOKEN_INVALID); + } + + String email = tokenProvider.getEmail(refreshToken); + if (!email.equals(member.getEmail())) { + throw new AuthException(AuthErrorType.TOKEN_INVALID); + } + String redisRefreshToken = redisClient.getValue(email); + + if (refreshToken.isEmpty() || redisRefreshToken == null || redisRefreshToken.isEmpty() + || !redisRefreshToken.equals(refreshToken)) { + throw new AuthException(AuthErrorType.TOKEN_NOT_FOUND); + } + + return tokenProvider.reissueTokens(member, refreshToken); + } + + private String extractToken(String token) { + String trimmed = token.trim(); + if (trimmed.startsWith("Bearer ")) { + String rawToken = trimmed.substring(7).trim(); + if (rawToken.isEmpty()) { + throw new AuthException(AuthErrorType.TOKEN_INVALID); + } + return rawToken; + } + if (trimmed.isEmpty()) { + throw new AuthException(AuthErrorType.TOKEN_INVALID); + } + return trimmed; + } +} diff --git a/src/main/java/starlight/application/member/auth/provided/AuthUseCase.java b/src/main/java/starlight/application/member/auth/provided/AuthUseCase.java new file mode 100644 index 00000000..a9e0014a --- /dev/null +++ b/src/main/java/starlight/application/member/auth/provided/AuthUseCase.java @@ -0,0 +1,17 @@ +package starlight.application.member.auth.provided; + +import starlight.application.member.auth.provided.dto.AuthMemberResult; +import starlight.application.member.auth.provided.dto.AuthTokenResult; +import starlight.application.member.auth.provided.dto.SignInInput; +import starlight.application.member.auth.provided.dto.SignUpInput; + +public interface AuthUseCase { + + AuthMemberResult signUp(SignUpInput input); + + AuthTokenResult signIn(SignInInput input); + + void signOut(String refreshToken, String accessToken); + + AuthTokenResult reissue(String token, Long memberId); +} diff --git a/src/main/java/starlight/application/member/auth/provided/dto/AuthMemberResult.java b/src/main/java/starlight/application/member/auth/provided/dto/AuthMemberResult.java new file mode 100644 index 00000000..ebb3ef73 --- /dev/null +++ b/src/main/java/starlight/application/member/auth/provided/dto/AuthMemberResult.java @@ -0,0 +1,20 @@ +package starlight.application.member.auth.provided.dto; + +import starlight.domain.member.entity.Member; +import starlight.domain.member.enumerate.MemberType; + +public record AuthMemberResult( + Long id, + String email, + String phoneNumber, + MemberType memberType +) { + public static AuthMemberResult from(Member member) { + return new AuthMemberResult( + member.getId(), + member.getEmail(), + member.getPhoneNumber(), + member.getMemberType() + ); + } +} diff --git a/src/main/java/starlight/application/member/auth/provided/dto/AuthTokenResult.java b/src/main/java/starlight/application/member/auth/provided/dto/AuthTokenResult.java new file mode 100644 index 00000000..e0ba3570 --- /dev/null +++ b/src/main/java/starlight/application/member/auth/provided/dto/AuthTokenResult.java @@ -0,0 +1,10 @@ +package starlight.application.member.auth.provided.dto; + +public record AuthTokenResult( + String accessToken, + String refreshToken +) { + public static AuthTokenResult of(String accessToken, String refreshToken) { + return new AuthTokenResult(accessToken, refreshToken); + } +} diff --git a/src/main/java/starlight/application/member/auth/provided/dto/SignInInput.java b/src/main/java/starlight/application/member/auth/provided/dto/SignInInput.java new file mode 100644 index 00000000..ca767f51 --- /dev/null +++ b/src/main/java/starlight/application/member/auth/provided/dto/SignInInput.java @@ -0,0 +1,7 @@ +package starlight.application.member.auth.provided.dto; + +public record SignInInput( + String email, + String password +) { +} diff --git a/src/main/java/starlight/application/member/auth/provided/dto/SignUpInput.java b/src/main/java/starlight/application/member/auth/provided/dto/SignUpInput.java new file mode 100644 index 00000000..d1a3a9e6 --- /dev/null +++ b/src/main/java/starlight/application/member/auth/provided/dto/SignUpInput.java @@ -0,0 +1,9 @@ +package starlight.application.member.auth.provided.dto; + +public record SignUpInput( + String name, + String email, + String phoneNumber, + String password +) { +} diff --git a/src/main/java/starlight/application/auth/required/KeyValueMap.java b/src/main/java/starlight/application/member/auth/required/KeyValueMap.java similarity index 79% rename from src/main/java/starlight/application/auth/required/KeyValueMap.java rename to src/main/java/starlight/application/member/auth/required/KeyValueMap.java index 4dc1b121..0e4c3a74 100644 --- a/src/main/java/starlight/application/auth/required/KeyValueMap.java +++ b/src/main/java/starlight/application/member/auth/required/KeyValueMap.java @@ -1,4 +1,4 @@ -package starlight.application.auth.required; +package starlight.application.member.auth.required; public interface KeyValueMap { diff --git a/src/main/java/starlight/application/member/auth/required/TokenProvider.java b/src/main/java/starlight/application/member/auth/required/TokenProvider.java new file mode 100644 index 00000000..f210a874 --- /dev/null +++ b/src/main/java/starlight/application/member/auth/required/TokenProvider.java @@ -0,0 +1,36 @@ +package starlight.application.member.auth.required; + +import jakarta.servlet.http.HttpServletRequest; +import starlight.application.member.auth.provided.dto.AuthTokenResult; +import starlight.domain.member.entity.Member; + +public interface TokenProvider { + + String createAccessToken(Member member); + + AuthTokenResult issueTokens(Member member); + + AuthTokenResult reissueTokens(Member member, String refreshToken); + + boolean validateToken(String token); + + String getEmail(String token); + + Long getExpirationTime(String token); + + /** + * @deprecated Authorization 헤더 파싱은 {@code AuthTokenResolver}를 사용하세요. + * 1.4.0부터 Deprecated 처리되었고, 추후 제거될 수 있습니다. + */ + @Deprecated(since = "1.4.0", forRemoval = false) + String resolveRefreshToken(HttpServletRequest request); + + /** + * @deprecated Authorization 헤더 파싱은 {@code AuthTokenResolver}를 사용하세요. + * 1.4.0부터 Deprecated 처리되었고, 추후 제거될 수 있습니다. + */ + @Deprecated(since = "1.4.0", forRemoval = false) + String resolveAccessToken(HttpServletRequest request); + + void logoutTokens(String refreshToken, String accessToken); +} diff --git a/src/main/java/starlight/application/member/provided/CredentialService.java b/src/main/java/starlight/application/member/provided/CredentialService.java index 0f2c0792..d67b5e3d 100644 --- a/src/main/java/starlight/application/member/provided/CredentialService.java +++ b/src/main/java/starlight/application/member/provided/CredentialService.java @@ -1,12 +1,11 @@ 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); + Credential createCredential(String rawPassword); /** * 비밀번호를 확인하는 메서드 diff --git a/src/main/java/starlight/application/member/provided/MemberService.java b/src/main/java/starlight/application/member/provided/MemberQueryUseCase.java similarity index 53% rename from src/main/java/starlight/application/member/provided/MemberService.java rename to src/main/java/starlight/application/member/provided/MemberQueryUseCase.java index 4b19773f..6977ddf8 100644 --- a/src/main/java/starlight/application/member/provided/MemberService.java +++ b/src/main/java/starlight/application/member/provided/MemberQueryUseCase.java @@ -1,12 +1,13 @@ 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 { +public interface MemberQueryUseCase { - Member createUser(Credential credential, AuthRequest authRequest); + Member createUser(Credential credential, String name, String email, String phoneNumber); Member getUserByEmail(String email); + + Member getUserById(Long id); } diff --git a/src/main/java/starlight/application/member/required/MemberQuery.java b/src/main/java/starlight/application/member/required/MemberCommandPort.java similarity index 58% rename from src/main/java/starlight/application/member/required/MemberQuery.java rename to src/main/java/starlight/application/member/required/MemberCommandPort.java index 02691c80..b225d769 100644 --- a/src/main/java/starlight/application/member/required/MemberQuery.java +++ b/src/main/java/starlight/application/member/required/MemberCommandPort.java @@ -2,7 +2,7 @@ import starlight.domain.member.entity.Member; -public interface MemberQuery { +public interface MemberCommandPort { - Member getOrThrow(Long id); + Member save(Member member); } diff --git a/src/main/java/starlight/application/member/required/MemberQueryPort.java b/src/main/java/starlight/application/member/required/MemberQueryPort.java new file mode 100644 index 00000000..71fe15a8 --- /dev/null +++ b/src/main/java/starlight/application/member/required/MemberQueryPort.java @@ -0,0 +1,16 @@ +package starlight.application.member.required; + +import starlight.domain.member.entity.Member; + +import java.util.Optional; + +public interface MemberQueryPort { + + Member findByIdOrThrow(Long id); + + Optional findByEmail(String email); + + Optional findByProviderAndProviderId(String provider, String providerId); + + Member findByProviderAndProviderIdOrThrow(String provider, String providerId); +} diff --git a/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java b/src/main/java/starlight/application/order/OrderPaymentService.java similarity index 72% rename from src/main/java/starlight/application/order/OrderPaymentServiceImpl.java rename to src/main/java/starlight/application/order/OrderPaymentService.java index 489cb499..fa8eee2b 100644 --- a/src/main/java/starlight/application/order/OrderPaymentServiceImpl.java +++ b/src/main/java/starlight/application/order/OrderPaymentService.java @@ -4,13 +4,13 @@ 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.application.order.provided.dto.PaymentHistoryItemResult; +import starlight.application.order.provided.OrderPaymentServiceUseCase; +import starlight.application.order.provided.dto.TossClientResult; +import starlight.application.order.required.OrderCommandPort; +import starlight.application.order.required.OrderQueryPort; +import starlight.application.order.required.PaymentGatewayPort; +import starlight.application.order.required.UsageCreditChargePort; import starlight.domain.order.enumerate.OrderStatus; import starlight.domain.order.enumerate.UsageProductType; import starlight.domain.order.exception.OrderErrorType; @@ -28,11 +28,12 @@ @Service @RequiredArgsConstructor @Transactional -public class OrderPaymentServiceImpl implements OrderPaymentService { +public class OrderPaymentService implements OrderPaymentServiceUseCase { - private final TossClient tossClient; - private final OrdersQuery ordersQuery; - private final UsageCreditPort usageCreditPort; + private final PaymentGatewayPort paymentGatewayPort; + private final OrderQueryPort orderQueryPort; + private final OrderCommandPort orderCommandPort; + private final UsageCreditChargePort usageCreditChargePort; /** * 결제 전 주문 준비 @@ -50,7 +51,7 @@ public Orders prepare(String orderCodeStr, Long buyerId, String productCode) { Money money = Money.krw(product.getPrice()); OrderCode orderCode = OrderCode.of(orderCodeStr); - return ordersQuery.findByOrderCode(orderCodeStr) + return orderQueryPort.findByOrderCode(orderCodeStr) .map(existing -> { existing.validateSameBuyer(buyerId); existing.validateSameProduct(product); @@ -60,7 +61,7 @@ public Orders prepare(String orderCodeStr, Long buyerId, String productCode) { .orElseGet(() -> { Orders newOrder = Orders.newUsageOrder(orderCode, buyerId, money, product); newOrder.addPaymentAttempt(money); - return ordersQuery.save(newOrder); + return orderCommandPort.save(newOrder); }); } @@ -75,7 +76,7 @@ public Orders prepare(String orderCodeStr, Long buyerId, String productCode) { @Override public Orders confirm(String orderCodeStr, String paymentKey, Long buyerId) { - Orders order = ordersQuery.getByOrderCodeOrThrow(orderCodeStr); + Orders order = orderQueryPort.getByOrderCodeOrThrow(orderCodeStr); UsageProductType product = UsageProductType.fromCode(order.getUsageProductCode()); long expectedAmount = product.getPrice(); @@ -86,7 +87,7 @@ public Orders confirm(String orderCodeStr, String paymentKey, Long buyerId) { PaymentRecords payment = order.getLatestRequestedOrThrow(); - TossClientResponse.Confirm response = tossClient.confirm( + TossClientResult.Confirm response = paymentGatewayPort.confirm( orderCodeStr, paymentKey, expectedAmount ); @@ -99,44 +100,45 @@ public Orders confirm(String orderCodeStr, String paymentKey, Long buyerId) { ); order.markPaid(); - usageCreditPort.chargeForOrder( + usageCreditChargePort.chargeForOrder( order.getBuyerId(), order.getId(), product.getUsageCount() ); - return ordersQuery.save(order); + return orderCommandPort.save(order); } /** * 결제 취소 * - * @param request 취소 요청 - * @return TossClientResponse.Cancel 취소 응답 + * @param orderCode 주문번호 + * @param reason 취소 사유 + * @return TossClientResult.Cancel 취소 응답 */ @Override - public TossClientResponse.Cancel cancel(OrderCancelRequest request) { + public TossClientResult.Cancel cancel(String orderCode, String reason) { - Orders order = ordersQuery.getByOrderCodeOrThrow(request.orderCode()); + Orders order = orderQueryPort.getByOrderCodeOrThrow(orderCode); PaymentRecords payment = order.getLatestDoneOrThrow(); payment.ensureHasPaymentKey(); - TossClientResponse.Cancel response = tossClient.cancel( - payment.getPaymentKey(), request.reason() + TossClientResult.Cancel response = paymentGatewayPort.cancel( + payment.getPaymentKey(), reason ); payment.markCanceled(); order.cancel(); - ordersQuery.save(order); + orderCommandPort.save(order); return response; } - public List getPaymentHistory(Long buyerId) { + public List getPaymentHistory(Long buyerId) { // 1) 이 회원(buyer)의 주문 전체를 최신순으로 가져오기 - List orders = ordersQuery.findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(buyerId); + List orders = orderQueryPort.findAllWithPaymentsByBuyerIdOrderByCreatedAtDesc(buyerId); return orders.stream() // 결제완료(PAID) 주문만 @@ -154,7 +156,7 @@ public List getPaymentHistory(Long buyerId) { ? payment.getApprovedAt() : payment.getCreatedAt(); - return PaymentHistoryItemDto.of( + return PaymentHistoryItemResult.of( productName, payment.getMethod(), payment.getPrice(), @@ -164,4 +166,4 @@ public List getPaymentHistory(Long buyerId) { }) .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/OrderPaymentServiceUseCase.java similarity index 51% rename from src/main/java/starlight/application/order/provided/OrderPaymentService.java rename to src/main/java/starlight/application/order/provided/OrderPaymentServiceUseCase.java index ff293f2a..ccdff27e 100644 --- a/src/main/java/starlight/application/order/provided/OrderPaymentService.java +++ b/src/main/java/starlight/application/order/provided/OrderPaymentServiceUseCase.java @@ -1,19 +1,18 @@ 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.application.order.provided.dto.PaymentHistoryItemResult; +import starlight.application.order.provided.dto.TossClientResult; import starlight.domain.order.order.Orders; import java.util.List; -public interface OrderPaymentService{ +public interface OrderPaymentServiceUseCase { Orders prepare(String orderCodeStr, Long buyerId, String productCode); Orders confirm(String orderCodeStr, String paymentKey, Long buyerId); - TossClientResponse.Cancel cancel(OrderCancelRequest request); + TossClientResult.Cancel cancel(String orderCode, String reason); - List getPaymentHistory(Long buyerId); + List getPaymentHistory(Long buyerId); } diff --git a/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java b/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemResult.java similarity index 79% rename from src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java rename to src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemResult.java index 1e0f3e71..2eebb977 100644 --- a/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemDto.java +++ b/src/main/java/starlight/application/order/provided/dto/PaymentHistoryItemResult.java @@ -2,21 +2,21 @@ import java.time.Instant; -public record PaymentHistoryItemDto( +public record PaymentHistoryItemResult( String productName, String paymentMethod, Long price, Instant paidAt, String receiptUrl ) { - public static PaymentHistoryItemDto of( + public static PaymentHistoryItemResult of( String productName, String paymentMethod, Long price, Instant paidAt, String receiptUrl ) { - return new PaymentHistoryItemDto( + return new PaymentHistoryItemResult( productName, paymentMethod, price, @@ -24,4 +24,4 @@ public static PaymentHistoryItemDto of( 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/TossClientResult.java similarity index 98% rename from src/main/java/starlight/application/order/provided/dto/TossClientResponse.java rename to src/main/java/starlight/application/order/provided/dto/TossClientResult.java index 97a4c932..d15e6326 100644 --- a/src/main/java/starlight/application/order/provided/dto/TossClientResponse.java +++ b/src/main/java/starlight/application/order/provided/dto/TossClientResult.java @@ -8,7 +8,7 @@ import java.util.List; @Slf4j -public record TossClientResponse ( +public record TossClientResult ( ) { @JsonIgnoreProperties(ignoreUnknown = true) public record Cancel( diff --git a/src/main/java/starlight/application/order/required/OrderCommandPort.java b/src/main/java/starlight/application/order/required/OrderCommandPort.java new file mode 100644 index 00000000..0c17e806 --- /dev/null +++ b/src/main/java/starlight/application/order/required/OrderCommandPort.java @@ -0,0 +1,8 @@ +package starlight.application.order.required; + +import starlight.domain.order.order.Orders; + +public interface OrderCommandPort { + + Orders save(Orders order); +} diff --git a/src/main/java/starlight/application/order/provided/OrdersQuery.java b/src/main/java/starlight/application/order/required/OrderQueryPort.java similarity index 72% rename from src/main/java/starlight/application/order/provided/OrdersQuery.java rename to src/main/java/starlight/application/order/required/OrderQueryPort.java index 16b5dfc3..c6dd84cb 100644 --- a/src/main/java/starlight/application/order/provided/OrdersQuery.java +++ b/src/main/java/starlight/application/order/required/OrderQueryPort.java @@ -1,17 +1,15 @@ -package starlight.application.order.provided; +package starlight.application.order.required; import starlight.domain.order.order.Orders; import java.util.List; import java.util.Optional; -public interface OrdersQuery { +public interface OrderQueryPort { 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/required/PaymentGatewayPort.java b/src/main/java/starlight/application/order/required/PaymentGatewayPort.java new file mode 100644 index 00000000..d46a704a --- /dev/null +++ b/src/main/java/starlight/application/order/required/PaymentGatewayPort.java @@ -0,0 +1,10 @@ +package starlight.application.order.required; + +import starlight.application.order.provided.dto.TossClientResult; + +public interface PaymentGatewayPort { + + TossClientResult.Confirm confirm(String orderCode, String paymentKey, Long price); + + TossClientResult.Cancel cancel(String paymentKey, String reason); +} diff --git a/src/main/java/starlight/application/order/required/UsageCreditChargePort.java b/src/main/java/starlight/application/order/required/UsageCreditChargePort.java new file mode 100644 index 00000000..f1811f24 --- /dev/null +++ b/src/main/java/starlight/application/order/required/UsageCreditChargePort.java @@ -0,0 +1,6 @@ +package starlight.application.order.required; + +public interface UsageCreditChargePort { + + void chargeForOrder(Long userId, Long orderId, int usageCount); +} diff --git a/src/main/java/starlight/application/usage/UsageCreditService.java b/src/main/java/starlight/application/usage/UsageCreditChargeService.java similarity index 62% rename from src/main/java/starlight/application/usage/UsageCreditService.java rename to src/main/java/starlight/application/usage/UsageCreditChargeService.java index 5b888b72..30818003 100644 --- a/src/main/java/starlight/application/usage/UsageCreditService.java +++ b/src/main/java/starlight/application/usage/UsageCreditChargeService.java @@ -3,9 +3,10 @@ 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.application.order.required.UsageCreditChargePort; +import starlight.application.usage.required.UsageHistoryCommandPort; +import starlight.application.usage.required.UsageWalletCommandPort; +import starlight.application.usage.required.UsageWalletQueryPort; import starlight.domain.order.exception.OrderErrorType; import starlight.domain.order.exception.OrderException; import starlight.domain.order.wallet.UsageHistory; @@ -14,10 +15,11 @@ @Service @RequiredArgsConstructor @Transactional -public class UsageCreditService implements UsageCreditPort { +public class UsageCreditChargeService implements UsageCreditChargePort { - private final UsageWalletQuery usageWalletQuery; - private final UsageHistoryQuery usageHistoryQuery; + private final UsageWalletQueryPort usageWalletQueryPort; + private final UsageWalletCommandPort usageWalletCommandPort; + private final UsageHistoryCommandPort usageHistoryCommandPort; /** * 주문 결제가 완료되었을 때 사용권(지갑)을 충전한다. @@ -33,15 +35,15 @@ public void chargeForOrder(Long userId, Long orderId, int usageCount) { } // 지갑 조회 or 생성 - UsageWallet wallet = usageWalletQuery.findByUserId(userId) - .orElseGet(() -> usageWalletQuery.save(UsageWallet.init(userId))); + UsageWallet wallet = usageWalletQueryPort.findByUserId(userId) + .orElseGet(() -> usageWalletCommandPort.save(UsageWallet.init(userId))); // 사용권 충전 wallet.chargeAiReport(usageCount); - usageWalletQuery.save(wallet); + usageWalletCommandPort.save(wallet); // 이력 기록 - usageHistoryQuery.save( + usageHistoryCommandPort.save( UsageHistory.charged( userId, usageCount, diff --git a/src/main/java/starlight/application/usage/provided/UsageCreditPort.java b/src/main/java/starlight/application/usage/provided/UsageCreditPort.java deleted file mode 100644 index 724eeb4c..00000000 --- a/src/main/java/starlight/application/usage/provided/UsageCreditPort.java +++ /dev/null @@ -1,6 +0,0 @@ -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/required/UsageHistoryCommandPort.java similarity index 54% rename from src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java rename to src/main/java/starlight/application/usage/required/UsageHistoryCommandPort.java index 2f9c10af..a6a2c149 100644 --- a/src/main/java/starlight/application/usage/provided/UsageHistoryQuery.java +++ b/src/main/java/starlight/application/usage/required/UsageHistoryCommandPort.java @@ -1,8 +1,8 @@ -package starlight.application.usage.provided; +package starlight.application.usage.required; import starlight.domain.order.wallet.UsageHistory; -public interface UsageHistoryQuery { +public interface UsageHistoryCommandPort { UsageHistory save(UsageHistory usageHistory); } diff --git a/src/main/java/starlight/application/usage/required/UsageWalletCommandPort.java b/src/main/java/starlight/application/usage/required/UsageWalletCommandPort.java new file mode 100644 index 00000000..26a94fac --- /dev/null +++ b/src/main/java/starlight/application/usage/required/UsageWalletCommandPort.java @@ -0,0 +1,8 @@ +package starlight.application.usage.required; + +import starlight.domain.order.wallet.UsageWallet; + +public interface UsageWalletCommandPort { + + UsageWallet save(UsageWallet usageWallet); +} diff --git a/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java b/src/main/java/starlight/application/usage/required/UsageWalletQueryPort.java similarity index 51% rename from src/main/java/starlight/application/usage/provided/UsageWalletQuery.java rename to src/main/java/starlight/application/usage/required/UsageWalletQueryPort.java index 37f99ee9..a36b1772 100644 --- a/src/main/java/starlight/application/usage/provided/UsageWalletQuery.java +++ b/src/main/java/starlight/application/usage/required/UsageWalletQueryPort.java @@ -1,12 +1,10 @@ -package starlight.application.usage.provided; +package starlight.application.usage.required; import starlight.domain.order.wallet.UsageWallet; import java.util.Optional; -public interface UsageWalletQuery { +public interface UsageWalletQueryPort { Optional findByUserId(Long userId); - - UsageWallet save(UsageWallet usageWallet); } diff --git a/src/main/java/starlight/bootstrap/ObjectStorageConfig.java b/src/main/java/starlight/bootstrap/ObjectStorageConfig.java index 01fcaabd..f987b340 100644 --- a/src/main/java/starlight/bootstrap/ObjectStorageConfig.java +++ b/src/main/java/starlight/bootstrap/ObjectStorageConfig.java @@ -7,7 +7,6 @@ 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; @@ -36,7 +35,7 @@ public S3Client ncpS3Client() { } @Bean - public S3Presigner s3Presigner() { + public S3Presigner ncpS3Presigner() { return S3Presigner.builder() .region(Region.of("kr-standard")) .endpointOverride(URI.create(endpoint)) @@ -45,4 +44,4 @@ public S3Presigner s3Presigner() { )) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/starlight/bootstrap/RestClientConfig.java b/src/main/java/starlight/bootstrap/RestClientConfig.java index 047d4ff5..5c59261e 100644 --- a/src/main/java/starlight/bootstrap/RestClientConfig.java +++ b/src/main/java/starlight/bootstrap/RestClientConfig.java @@ -47,8 +47,8 @@ public RestClient clovaOcrRestClient( * - UA만 지정 (일부 서버 호환) * 필요 없으면 이 빈은 제거해도 됨. */ - @Bean(name = "downloadClient") - public RestClient downloadClient() { + @Bean(name = "pdfDownloadRestClient") + public RestClient pdfDownloadRestClient() { JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(); factory.setReadTimeout(Duration.ofSeconds(60)); @@ -58,8 +58,8 @@ public RestClient downloadClient() { .build(); } - @Bean(name = "clovaClient") - public RestClient clovaStudioClient( + @Bean(name = "clovaStudioRestClient") + public RestClient clovaStudioRestClient( @Value("${cloud.ncp.studio.host}") String clovaHost, @Value("${cloud.ncp.studio.api-key}") String apiKey, @Value("${cloud.ncp.studio.model}") String model @@ -97,4 +97,4 @@ public RestClient tossRestClient( .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 index bd76996f..50b0eaa2 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -22,12 +22,12 @@ 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 starlight.adapter.member.auth.security.filter.ExceptionFilter; +import starlight.adapter.member.auth.security.filter.JwtFilter; +import starlight.adapter.member.auth.security.handler.JwtAccessDeniedHandler; +import starlight.adapter.member.auth.security.handler.JwtAuthenticationHandler; +import starlight.adapter.member.auth.security.oauth2.CustomOAuth2UserService; +import starlight.adapter.member.auth.security.oauth2.OAuth2SuccessHandler; import java.util.List; @@ -71,7 +71,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .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("/v1/auth/**","/v1/user/**", "/v1/experts", "/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() diff --git a/src/main/java/starlight/bootstrap/SwaggerConfig.java b/src/main/java/starlight/bootstrap/SwaggerConfig.java index 366f0f7d..01551e76 100644 --- a/src/main/java/starlight/bootstrap/SwaggerConfig.java +++ b/src/main/java/starlight/bootstrap/SwaggerConfig.java @@ -7,7 +7,6 @@ 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; diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index da0c009d..f1ce0af0 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -30,15 +30,19 @@ public class Expert extends AbstractEntity { @Column(nullable = false, length = 320) private String email; + @Column + private String oneLineIntroduction; + + @Column + private String detailedIntroduction; + @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<>(); + @OneToMany(mappedBy = "expert", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("orderIndex ASC") + private List careers = new ArrayList<>(); @ElementCollection @CollectionTable(name = "expert_tags", joinColumns = @JoinColumn(name = "expert_id")) diff --git a/src/main/java/starlight/domain/expert/entity/ExpertCareer.java b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java new file mode 100644 index 00000000..cd151cae --- /dev/null +++ b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java @@ -0,0 +1,53 @@ +package starlight.domain.expert.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import starlight.shared.AbstractEntity; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "expert_careers") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ExpertCareer extends AbstractEntity { + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "expert_id", nullable = false) + private Expert expert; + + @Column(name="order_index", nullable=false) + private Integer orderIndex; + + @Column(name = "career_title", length = 300, nullable = false) + private String careerTitle; + + @Column(name = "career_explanation", length = 300) + private String careerExplanation; + + @Column(name = "career_started_at", nullable = false) + private LocalDateTime careerStartedAt; + + @Column(name = "career_ended_at", nullable = false) + private LocalDateTime careerEndedAt; + + public static ExpertCareer of(Expert expert, int orderIndex, String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + ExpertCareer expertCareer = new ExpertCareer(); + expertCareer.expert = expert; + expertCareer.orderIndex = orderIndex; + expertCareer.careerTitle = title; + expertCareer.careerExplanation = explanation; + expertCareer.careerStartedAt = startedAt; + expertCareer.careerEndedAt = endedAt; + return expertCareer; + } + + public void update(String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + this.careerTitle = title; + this.careerExplanation = explanation; + this.careerStartedAt = startedAt; + this.careerEndedAt = endedAt; + } +} diff --git a/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationErrorType.java b/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationErrorType.java index 72c97a8a..01eba326 100644 --- a/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationErrorType.java +++ b/src/main/java/starlight/domain/expertApplication/exception/ExpertApplicationErrorType.java @@ -10,8 +10,8 @@ public enum ExpertApplicationErrorType implements ErrorType { // 전문가 신청 관련 오류 타입 정의 - EXPERT_APPLICATION_QUERY_ERROR(HttpStatus.NOT_FOUND, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), - EXPERT_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + EXPERT_APPLICATION_QUERY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다."), + EXPERT_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가 신청을 찾을 수 없습니다."), APPLICATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 신청한 전문가입니다."), EXPERT_FEEDBACK_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 피드백 요청에 실패했습니다."), diff --git a/src/main/java/starlight/domain/expertReport/entity/ExpertReport.java b/src/main/java/starlight/domain/expertReport/entity/ExpertReport.java index 1a98d48a..914eccbb 100644 --- a/src/main/java/starlight/domain/expertReport/entity/ExpertReport.java +++ b/src/main/java/starlight/domain/expertReport/entity/ExpertReport.java @@ -51,7 +51,7 @@ public class ExpertReport extends AbstractEntity { @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(nullable = false) - private List details = new ArrayList<>(); + private List comments = new ArrayList<>(); // 7일의 기한을 가지고 기한 내에만 수정가능하다. // -> expiredAt, submitStatus 필드로 관리 @@ -120,12 +120,12 @@ public void updateOverallComment(String overallComment) { this.overallComment = overallComment; } - public void updateDetails(List newDetails) { - Assert.notNull(newDetails, "details는 null일 수 없습니다"); + public void updateComments(List newComments) { + Assert.notNull(newComments, "comments는 null일 수 없습니다"); validateCanEdit(); - this.details.clear(); - this.details.addAll(newDetails); + this.comments.clear(); + this.comments.addAll(newComments); } public void incrementViewCount() { diff --git a/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java b/src/main/java/starlight/domain/expertReport/entity/ExpertReportComment.java similarity index 62% rename from src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java rename to src/main/java/starlight/domain/expertReport/entity/ExpertReportComment.java index 0a985304..35aef9fa 100644 --- a/src/main/java/starlight/domain/expertReport/entity/ExpertReportDetail.java +++ b/src/main/java/starlight/domain/expertReport/entity/ExpertReportComment.java @@ -11,23 +11,23 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ExpertReportDetail extends AbstractEntity { +public class ExpertReportComment extends AbstractEntity { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 30) - private CommentType commentType; + private CommentType type; @Column(columnDefinition = "TEXT", nullable = false) private String content; - public static ExpertReportDetail create(CommentType commentType, String content) { - Assert.notNull(commentType, "commentType은 필수입니다"); + public static ExpertReportComment create(CommentType type, String content) { + Assert.notNull(type, "type은 필수입니다"); Assert.hasText(content, "content는 필수입니다"); - ExpertReportDetail detail = new ExpertReportDetail(); - detail.commentType = commentType; - detail.content = content; - return detail; + ExpertReportComment comment = new ExpertReportComment(); + comment.type = type; + comment.content = content; + return comment; } public void update(String content) { diff --git a/src/main/java/starlight/domain/expertReport/exception/ExpertReportErrorType.java b/src/main/java/starlight/domain/expertReport/exception/ExpertReportErrorType.java index d05c677e..ee2160a2 100644 --- a/src/main/java/starlight/domain/expertReport/exception/ExpertReportErrorType.java +++ b/src/main/java/starlight/domain/expertReport/exception/ExpertReportErrorType.java @@ -10,7 +10,7 @@ public enum ExpertReportErrorType implements ErrorType { // 전문가 피드백 신청 관련 오류 - EXPERT_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + EXPERT_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가 리포트를 찾을 수 없습니다."), ALREADY_SUBMITTED(HttpStatus.BAD_REQUEST, "이미 전문가 피드백을 제출하였습니다."), REPORT_EXPIRED(HttpStatus.BAD_REQUEST, "전문가 피드백 요청 기간이 만료되었습니다."), ; diff --git a/src/main/java/starlight/domain/auth/exception/AuthErrorType.java b/src/main/java/starlight/domain/member/auth/exception/AuthErrorType.java similarity index 77% rename from src/main/java/starlight/domain/auth/exception/AuthErrorType.java rename to src/main/java/starlight/domain/member/auth/exception/AuthErrorType.java index 42355426..a12423aa 100644 --- a/src/main/java/starlight/domain/auth/exception/AuthErrorType.java +++ b/src/main/java/starlight/domain/member/auth/exception/AuthErrorType.java @@ -1,4 +1,4 @@ -package starlight.domain.auth.exception; +package starlight.domain.member.auth.exception; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -11,7 +11,7 @@ public enum AuthErrorType implements ErrorType { TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "토큰이 존재하지 않습니다."), TOKEN_INVALID(HttpStatus.BAD_REQUEST, "토큰이 유효하지 않습니다."), - PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), ; private final HttpStatus status; diff --git a/src/main/java/starlight/domain/auth/exception/AuthException.java b/src/main/java/starlight/domain/member/auth/exception/AuthException.java similarity index 84% rename from src/main/java/starlight/domain/auth/exception/AuthException.java rename to src/main/java/starlight/domain/member/auth/exception/AuthException.java index fc14f367..24d9e88a 100644 --- a/src/main/java/starlight/domain/auth/exception/AuthException.java +++ b/src/main/java/starlight/domain/member/auth/exception/AuthException.java @@ -1,4 +1,4 @@ -package starlight.domain.auth.exception; +package starlight.domain.member.auth.exception; import starlight.shared.apiPayload.exception.ErrorType; import starlight.shared.apiPayload.exception.GlobalException; diff --git a/src/main/java/starlight/shared/auth/AuthenticatedMember.java b/src/main/java/starlight/shared/auth/AuthenticatedMember.java new file mode 100644 index 00000000..3e1903d4 --- /dev/null +++ b/src/main/java/starlight/shared/auth/AuthenticatedMember.java @@ -0,0 +1,8 @@ +package starlight.shared.auth; + +public interface AuthenticatedMember { + + Long getMemberId(); + + String getMemberName(); +} diff --git a/src/test/java/starlight/adapter/ncp/ocr/ClovaOcrProviderTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java similarity index 94% rename from src/test/java/starlight/adapter/ncp/ocr/ClovaOcrProviderTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java index cd372635..1a155607 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/ClovaOcrProviderTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr; +package starlight.adapter.aireport.infrastructure.ocr; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -8,13 +8,14 @@ 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.adapter.aireport.infrastructure.ocr.ClovaOcrProvider; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.aireport.infrastructure.ocr.infra.ClovaOcrClient; +import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrResponseMerger; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrTextExtractor; +import starlight.adapter.aireport.infrastructure.ocr.util.PdfUtils; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.List; diff --git a/src/test/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClientTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/ClovaOcrClientTest.java similarity index 93% rename from src/test/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClientTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/ClovaOcrClientTest.java index 651ac187..63c87924 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/infra/ClovaOcrClientTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/ClovaOcrClientTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr.infra; +package starlight.adapter.aireport.infrastructure.ocr.infra; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -8,9 +8,10 @@ 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.adapter.aireport.infrastructure.ocr.dto.ClovaOcrRequest; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.aireport.infrastructure.ocr.infra.ClovaOcrClient; import starlight.shared.dto.infrastructure.OcrResponse; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientIntegrationTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java similarity index 94% rename from src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientIntegrationTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java index 442f13cb..660b1980 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientIntegrationTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.ocr.infra; +package starlight.adapter.aireport.infrastructure.ocr.infra; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -9,8 +9,9 @@ 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 starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; import java.io.IOException; import java.time.Duration; diff --git a/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java similarity index 96% rename from src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java index 83842a7f..55f6aad0 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/infra/PdfDownloadClientTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java @@ -1,12 +1,13 @@ -package starlight.adapter.ncp.ocr.infra; +package starlight.adapter.aireport.infrastructure.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 starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; import java.net.URI; diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/OcrResponseMergerUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrResponseMergerUnitTest.java similarity index 97% rename from src/test/java/starlight/adapter/ncp/ocr/util/OcrResponseMergerUnitTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrResponseMergerUnitTest.java index 9c9dce45..3bf8c1dd 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/util/OcrResponseMergerUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrResponseMergerUnitTest.java @@ -1,7 +1,8 @@ -package starlight.adapter.ncp.ocr.util; +package starlight.adapter.aireport.infrastructure.ocr.util; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrResponseMerger; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.ArrayList; diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/OcrTextExtractorUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrTextExtractorUnitTest.java similarity index 98% rename from src/test/java/starlight/adapter/ncp/ocr/util/OcrTextExtractorUnitTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrTextExtractorUnitTest.java index 8af0db54..8f883ee5 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/util/OcrTextExtractorUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/OcrTextExtractorUnitTest.java @@ -1,7 +1,8 @@ -package starlight.adapter.ncp.ocr.util; +package starlight.adapter.aireport.infrastructure.ocr.util; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrTextExtractor; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.List; diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/PdfUtilsUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/PdfUtilsUnitTest.java similarity index 96% rename from src/test/java/starlight/adapter/ncp/ocr/util/PdfUtilsUnitTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/PdfUtilsUnitTest.java index f822afbe..34ffb5a4 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/util/PdfUtilsUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/PdfUtilsUnitTest.java @@ -1,11 +1,12 @@ -package starlight.adapter.ncp.ocr.util; +package starlight.adapter.aireport.infrastructure.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 starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; +import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.aireport.infrastructure.ocr.util.PdfUtils; import java.io.ByteArrayOutputStream; import java.io.IOException; diff --git a/src/test/java/starlight/adapter/ncp/ocr/util/PrivateConstructorTests.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/PrivateConstructorTests.java similarity index 91% rename from src/test/java/starlight/adapter/ncp/ocr/util/PrivateConstructorTests.java rename to src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/PrivateConstructorTests.java index 1513ae9a..16ae4dc1 100644 --- a/src/test/java/starlight/adapter/ncp/ocr/util/PrivateConstructorTests.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/util/PrivateConstructorTests.java @@ -1,7 +1,10 @@ -package starlight.adapter.ncp.ocr.util; +package starlight.adapter.aireport.infrastructure.ocr.util; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrResponseMerger; +import starlight.adapter.aireport.infrastructure.ocr.util.OcrTextExtractor; +import starlight.adapter.aireport.infrastructure.ocr.util.PdfUtils; import java.lang.reflect.Constructor; import java.lang.reflect.Modifier; diff --git a/src/test/java/starlight/adapter/ncp/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java similarity index 98% rename from src/test/java/starlight/adapter/ncp/storage/NcpPresignedUrlProviderUnitTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index 7e7426d7..22344d18 100644 --- a/src/test/java/starlight/adapter/ncp/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.ncp.storage; +package starlight.adapter.aireport.infrastructure.storage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -14,6 +14,7 @@ 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.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URL; diff --git a/src/test/java/starlight/adapter/ncp/webapi/ImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java similarity index 87% rename from src/test/java/starlight/adapter/ncp/webapi/ImageControllerIntegrationTest.java rename to src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java index 7d26dcf6..ef1ce442 100644 --- a/src/test/java/starlight/adapter/ncp/webapi/ImageControllerIntegrationTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java @@ -1,34 +1,25 @@ -package starlight.adapter.ncp.webapi; +package starlight.adapter.aireport.infrastructure.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.adapter.aireport.webapi.ImageController; +import starlight.adapter.member.auth.security.auth.AuthDetails; +import starlight.adapter.member.auth.security.filter.JwtFilter; +import starlight.application.aireport.required.PresignedUrlProvider; +import starlight.bootstrap.SecurityConfig; 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; diff --git a/src/test/java/starlight/adapter/expert/persistence/ExpertRepositoryTest.java b/src/test/java/starlight/adapter/expert/persistence/ExpertRepositoryTest.java deleted file mode 100644 index a74a759d..00000000 --- a/src/test/java/starlight/adapter/expert/persistence/ExpertRepositoryTest.java +++ /dev/null @@ -1,60 +0,0 @@ -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 index ab3a903b..a4c7a490 100644 --- a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java +++ b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java @@ -1,6 +1,6 @@ package starlight.adapter.expert.webapi; -import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -11,16 +11,28 @@ 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 org.springframework.context.annotation.Import; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import starlight.adapter.member.auth.security.filter.JwtFilter; +import starlight.application.expert.provided.ExpertAiReportQueryUseCase; +import starlight.application.expert.provided.ExpertDetailQueryUseCase; +import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; +import starlight.application.expert.provided.dto.ExpertCareerResult; +import starlight.application.expert.provided.dto.ExpertDetailResult; import starlight.domain.expert.enumerate.TagCategory; - -import java.lang.reflect.Constructor; -import java.util.LinkedHashSet; +import starlight.shared.auth.AuthenticatedMember; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import java.util.Collections; import java.util.List; import java.util.Set; @@ -37,74 +49,130 @@ ) ) @AutoConfigureMockMvc(addFilters = false) +@Import(ExpertControllerTest.SecurityTestConfig.class) class ExpertControllerTest { @Autowired MockMvc mockMvc; - @Autowired ObjectMapper om; - - @MockitoBean ExpertFinder expertFinder; - @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; // ← 필드로 추가! + @MockitoBean + ExpertDetailQueryUseCase expertDetailQuery; + @MockitoBean + ExpertAiReportQueryUseCase expertAiReportQuery; + @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; @Test - @DisplayName("카테고리 미전달 시 전체 조회") + @DisplayName("전문가 전체 조회") void listAll() throws Exception { - Expert e1 = expert(1L, "홍길동", + ExpertDetailResult e1 = expertResult(1L, "홍길동", Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY)); - when(expertFinder.loadAll()).thenReturn(List.of(e1)); + when(expertDetailQuery.searchAll()).thenReturn(List.of(e1)); mockMvc.perform(get("/v1/experts")) .andExpect(status().isOk()) .andExpect(jsonPath("$.result").value("SUCCESS")) - .andExpect(jsonPath("$.data[0].name").value("홍길동")); + .andExpect(jsonPath("$.data[0].name").value("홍길동")) + .andExpect(jsonPath("$.data[0].careers.length()").value(3)) + .andExpect(jsonPath("$.data[0].careers[0].orderIndex").exists()) + .andExpect(jsonPath("$.data[0].careers[0].careerTitle").exists()) + .andExpect(jsonPath("$.data[0].applicationCount").doesNotExist()); } @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)); + @DisplayName("전문가 상세 조회") + void detail() throws Exception { + ExpertDetailResult result = expertResult(10L, "김철수", + Set.of(TagCategory.MARKET_BM, TagCategory.GROWTH_STRATEGY)); + when(expertDetailQuery.findById(10L)).thenReturn(result); - mockMvc.perform(get("/v1/experts") - .param("categories", "GROWTH_STRATEGY") - .param("categories", "TEAM_CAPABILITY")) + mockMvc.perform(get("/v1/experts/10")) .andExpect(status().isOk()) .andExpect(jsonPath("$.result").value("SUCCESS")) - .andExpect(jsonPath("$.data[0].name").value("이영희")); + .andExpect(jsonPath("$.data.id").value(10L)) + .andExpect(jsonPath("$.data.applicationCount").value(0)) + .andExpect(jsonPath("$.data.categories").doesNotExist()) + .andExpect(jsonPath("$.data.tags").isArray()); } @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")) + @DisplayName("전문가 상세 AI 리포트 보유 사업계획서 목록 조회") + void aiReportBusinessPlans() throws Exception { + List results = List.of( + new ExpertAiReportBusinessPlanResult(10L, "테스트 사업계획서", 2L, true), + new ExpertAiReportBusinessPlanResult(11L, "신규 사업계획서", 0L, false) + ); + when(expertAiReportQuery.findAiReportBusinessPlans(7L, 100L)).thenReturn(results); + + mockMvc.perform(get("/v1/experts/7/business-plans/ai-reports") + .with(authenticatedMember(100L))) .andExpect(status().isOk()) .andExpect(jsonPath("$.result").value("SUCCESS")) - .andExpect(jsonPath("$.data[0].name").value("박철수")); + .andExpect(jsonPath("$.data[0].businessPlanId").value(10L)) + .andExpect(jsonPath("$.data[0].requestCount").value(2L)) + .andExpect(jsonPath("$.data[0].isOver70").value(true)) + .andExpect(jsonPath("$.data[1].businessPlanId").value(11L)) + .andExpect(jsonPath("$.data[1].isOver70").value(false)); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); } // helper - private 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; + private ExpertDetailResult expertResult(Long id, String name, Set cats) throws Exception { + List careers = List.of( + new ExpertCareerResult(1L, 0, "A사 PO", "설명", null, null), + new ExpertCareerResult(2L, 1, "B사 PM", "설명", null, null), + new ExpertCareerResult(3L, 2, "C사 리드", "설명", null, null), + new ExpertCareerResult(4L, 3, "D사 CTO", "설명", null, null) + ); + + return new ExpertDetailResult( + id, + 0L, + name, + "한줄소개", + "상세소개", + "https://cdn.example.com/" + id + ".png", + 12L, + name + "@example.com", + 50000, + careers, + List.of("tag1", "tag2"), + cats.stream().map(TagCategory::name).toList() + ); + } + + private Authentication testAuthentication(Long memberId) { + AuthenticatedMember member = new TestAuthenticatedMember(memberId, "tester"); + return new UsernamePasswordAuthenticationToken(member, null, Collections.emptyList()); + } + + private RequestPostProcessor authenticatedMember(Long memberId) { + return request -> { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(testAuthentication(memberId)); + SecurityContextHolder.setContext(context); + return request; + }; + } + + private record TestAuthenticatedMember(Long memberId, String memberName) implements AuthenticatedMember { + @Override + public Long getMemberId() { + return memberId; + } + + @Override + public String getMemberName() { + return memberName; + } + } + + @TestConfiguration + static class SecurityTestConfig implements WebMvcConfigurer { + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new AuthenticationPrincipalArgumentResolver()); + } } -} \ No newline at end of file +} diff --git a/src/test/java/starlight/adapter/auth/redis/RedisKeyValueMapTest.java b/src/test/java/starlight/adapter/member/auth/redis/RedisKeyValueMapTest.java similarity index 99% rename from src/test/java/starlight/adapter/auth/redis/RedisKeyValueMapTest.java rename to src/test/java/starlight/adapter/member/auth/redis/RedisKeyValueMapTest.java index ed48b3e3..8d35d8f0 100644 --- a/src/test/java/starlight/adapter/auth/redis/RedisKeyValueMapTest.java +++ b/src/test/java/starlight/adapter/member/auth/redis/RedisKeyValueMapTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.redis; +package starlight.adapter.member.auth.redis; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git a/src/test/java/starlight/adapter/auth/security/filter/ExceptionFilterUnitTest.java b/src/test/java/starlight/adapter/member/auth/security/filter/ExceptionFilterUnitTest.java similarity index 98% rename from src/test/java/starlight/adapter/auth/security/filter/ExceptionFilterUnitTest.java rename to src/test/java/starlight/adapter/member/auth/security/filter/ExceptionFilterUnitTest.java index 54a422db..14318762 100644 --- a/src/test/java/starlight/adapter/auth/security/filter/ExceptionFilterUnitTest.java +++ b/src/test/java/starlight/adapter/member/auth/security/filter/ExceptionFilterUnitTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.filter; +package starlight.adapter.member.auth.security.filter; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/starlight/adapter/auth/security/jwt/JwtTokenProviderTest.java b/src/test/java/starlight/adapter/member/auth/security/jwt/JwtTokenProviderTest.java similarity index 87% rename from src/test/java/starlight/adapter/auth/security/jwt/JwtTokenProviderTest.java rename to src/test/java/starlight/adapter/member/auth/security/jwt/JwtTokenProviderTest.java index 486e9954..774408d6 100644 --- a/src/test/java/starlight/adapter/auth/security/jwt/JwtTokenProviderTest.java +++ b/src/test/java/starlight/adapter/member/auth/security/jwt/JwtTokenProviderTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.jwt; +package starlight.adapter.member.auth.security.jwt; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; @@ -14,8 +14,8 @@ 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.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.provided.dto.AuthTokenResult; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; import starlight.shared.apiPayload.exception.GlobalException; @@ -75,9 +75,9 @@ void createAccessToken_Success() { @Test @DisplayName("AccessToken과 RefreshToken 생성 성공") - void createToken_Success() { + void issueTokens_Success() { // when - TokenResponse tokenResponse = jwtTokenProvider.createToken(member); + AuthTokenResult tokenResponse = jwtTokenProvider.issueTokens(member); // then assertThat(tokenResponse).isNotNull(); @@ -91,10 +91,10 @@ void createToken_Success() { @DisplayName("토큰 재발급 성공 - RefreshToken 유효기간이 충분한 경우") void recreate_Success_WithValidRefreshToken() { // given - TokenResponse originalToken = jwtTokenProvider.createToken(member); + AuthTokenResult originalToken = jwtTokenProvider.issueTokens(member); // when - TokenResponse newToken = jwtTokenProvider.recreate(member, originalToken.refreshToken()); + AuthTokenResult newToken = jwtTokenProvider.reissueTokens(member, originalToken.refreshToken()); // then assertThat(newToken).isNotNull(); @@ -105,20 +105,20 @@ void recreate_Success_WithValidRefreshToken() { } @Test - @DisplayName("토큰 재발급 성공 - RefreshToken 재발급 필요한 경우") - void recreate_Success_WithExpiredRefreshToken() { + @DisplayName("토큰 재발급 성공 - RefreshToken 만료 임박") + void recreate_Success_WithNearExpiryRefreshToken() { // 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초 + .setExpiration(new Date(now.getTime() + 5 * 60 * 1000L)) // 5분 .signWith(key, SignatureAlgorithm.HS256) .compact(); // when - TokenResponse newToken = jwtTokenProvider.recreate(member, expiredRefreshToken); + AuthTokenResult newToken = jwtTokenProvider.reissueTokens(member, expiredRefreshToken); // then assertThat(newToken).isNotNull(); @@ -241,12 +241,12 @@ void resolveAccessToken_Success() { @Test @DisplayName("토큰 무효화 성공") - void invalidateTokens_Success() { + void logoutTokens_Success() { // given - TokenResponse tokenResponse = jwtTokenProvider.createToken(member); + AuthTokenResult tokenResponse = jwtTokenProvider.issueTokens(member); // when - jwtTokenProvider.invalidateTokens(tokenResponse.refreshToken(), tokenResponse.accessToken()); + jwtTokenProvider.logoutTokens(tokenResponse.refreshToken(), tokenResponse.accessToken()); // then verify(redisClient).deleteValue(eq(member.getEmail())); @@ -255,13 +255,13 @@ void invalidateTokens_Success() { @Test @DisplayName("토큰 무효화 실패 - 유효하지 않은 RefreshToken") - void invalidateTokens_Fail_InvalidRefreshToken() { + void logoutTokens_Fail_InvalidRefreshToken() { // given String invalidRefreshToken = "invalid.refresh.token"; String accessToken = jwtTokenProvider.createAccessToken(member); // when & then - assertThatThrownBy(() -> jwtTokenProvider.invalidateTokens(invalidRefreshToken, accessToken)) + assertThatThrownBy(() -> jwtTokenProvider.logoutTokens(invalidRefreshToken, accessToken)) .isInstanceOf(GlobalException.class); } } diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java b/src/test/java/starlight/adapter/member/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java similarity index 83% rename from src/test/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java rename to src/test/java/starlight/adapter/member/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java index 94bd5f21..ccde600d 100644 --- a/src/test/java/starlight/adapter/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java +++ b/src/test/java/starlight/adapter/member/auth/security/oauth2/CustomOAuth2UserServiceUnitTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.auth.security.oauth2; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,8 +13,9 @@ 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.adapter.member.auth.security.auth.AuthDetails; +import starlight.application.member.required.MemberCommandPort; +import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -29,7 +30,8 @@ class CustomOAuth2UserServiceUnitTest { - @Mock MemberRepository memberRepository; + @Mock MemberQueryPort memberQueryPort; + @Mock MemberCommandPort memberCommandPort; @Mock OAuth2UserService delegate; @InjectMocks CustomOAuth2UserService sut; @@ -37,7 +39,7 @@ class CustomOAuth2UserServiceUnitTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - sut = new CustomOAuth2UserService(memberRepository); + sut = new CustomOAuth2UserService(memberQueryPort, memberCommandPort); ReflectionTestUtils.setField(sut, "delegate", delegate); } @@ -86,12 +88,12 @@ void when_providerId_exists_then_load_existing_member() { 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")) + when(memberQueryPort.findByProviderAndProviderId("naver", "nid-1")) .thenReturn(Optional.of(existing)); var result = sut.loadUser(naverReq); - verify(memberRepository, never()).save(any()); + verify(memberCommandPort, never()).save(any()); assertThat(result).isInstanceOf(AuthDetails.class); var details = (AuthDetails) result; assertThat(details.member().getProvider()).isEqualTo("naver"); @@ -103,15 +105,15 @@ 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")) + when(memberQueryPort.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)); + when(memberQueryPort.findByEmail("c@d.com")).thenReturn(Optional.of(byEmail)); var result = sut.loadUser(naverReq); - verify(memberRepository, never()).save(any()); + verify(memberCommandPort, never()).save(any()); var details = (AuthDetails) result; // 정책에 따라: 기존 계정에 naver 연결 or 그냥 로그인만 assertThat(details.member().getEmail()).isEqualTo("c@d.com"); @@ -122,15 +124,15 @@ 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")) + when(memberQueryPort.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); + when(memberCommandPort.save(any(Member.class))).thenReturn(saved); var result = sut.loadUser(naverReq); - verify(memberRepository).save(any(Member.class)); + verify(memberCommandPort).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/member/auth/security/oauth2/OAuth2AttributesUnitTest.java similarity index 97% rename from src/test/java/starlight/adapter/auth/security/oauth2/OAuth2AttributesUnitTest.java rename to src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2AttributesUnitTest.java index d7bb0949..ba058a6e 100644 --- a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2AttributesUnitTest.java +++ b/src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2AttributesUnitTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.auth.security.oauth2; import org.junit.jupiter.api.Test; import org.springframework.security.core.authority.SimpleGrantedAuthority; diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2LoginFlowTest.java b/src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2LoginFlowTest.java similarity index 98% rename from src/test/java/starlight/adapter/auth/security/oauth2/OAuth2LoginFlowTest.java rename to src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2LoginFlowTest.java index c4bc1429..1b6f3362 100644 --- a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2LoginFlowTest.java +++ b/src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2LoginFlowTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.auth.security.oauth2; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java b/src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java similarity index 79% rename from src/test/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java rename to src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java index 6f3e4496..19a72a6d 100644 --- a/src/test/java/starlight/adapter/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java +++ b/src/test/java/starlight/adapter/member/auth/security/oauth2/OAuth2SuccessHandlerUnitTest.java @@ -1,18 +1,16 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.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.adapter.member.auth.security.auth.AuthDetails; +import starlight.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.required.TokenProvider; +import starlight.application.member.auth.provided.dto.AuthTokenResult; import starlight.domain.member.entity.Member; -import java.nio.charset.StandardCharsets; - import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; @@ -44,8 +42,8 @@ void setUp() { Authentication authentication = mock(Authentication.class); when(authentication.getPrincipal()).thenReturn(authDetails); - when(tokenProvider.createToken(any(Member.class))) - .thenReturn(new TokenResponse("access-token", "refresh-token")); + when(tokenProvider.issueTokens(any(Member.class))) + .thenReturn(new AuthTokenResult("access-token", "refresh-token")); MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -56,4 +54,4 @@ void setUp() { 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/member/auth/security/oauth2/TestOAuth2Objects.java similarity index 97% rename from src/test/java/starlight/adapter/auth/security/oauth2/TestOAuth2Objects.java rename to src/test/java/starlight/adapter/member/auth/security/oauth2/TestOAuth2Objects.java index ed080b6a..a1cf4b44 100644 --- a/src/test/java/starlight/adapter/auth/security/oauth2/TestOAuth2Objects.java +++ b/src/test/java/starlight/adapter/member/auth/security/oauth2/TestOAuth2Objects.java @@ -1,4 +1,4 @@ -package starlight.adapter.auth.security.oauth2; +package starlight.adapter.member.auth.security.oauth2; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.registration.ClientRegistration; diff --git a/src/test/java/starlight/adapter/auth/webapi/AuthControllerSliceTest.java b/src/test/java/starlight/adapter/member/auth/webapi/AuthControllerSliceTest.java similarity index 61% rename from src/test/java/starlight/adapter/auth/webapi/AuthControllerSliceTest.java rename to src/test/java/starlight/adapter/member/auth/webapi/AuthControllerSliceTest.java index 58cd7b37..ab0e741c 100644 --- a/src/test/java/starlight/adapter/auth/webapi/AuthControllerSliceTest.java +++ b/src/test/java/starlight/adapter/member/auth/webapi/AuthControllerSliceTest.java @@ -1,13 +1,18 @@ -package starlight.adapter.auth.webapi; +package starlight.adapter.member.auth.webapi; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration; +import org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; 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.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.http.MediaType; import org.springframework.test.context.TestPropertySource; @@ -15,13 +20,14 @@ 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.adapter.member.auth.security.auth.AuthDetails; +import starlight.adapter.member.auth.security.auth.AuthDetailsService; +import starlight.adapter.member.auth.security.filter.JwtFilter; +import starlight.application.member.auth.provided.AuthUseCase; +import starlight.application.member.auth.provided.dto.AuthMemberResult; +import starlight.application.member.auth.provided.dto.AuthTokenResult; +import starlight.application.member.auth.provided.dto.SignUpInput; +import starlight.bootstrap.SecurityConfig; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -35,7 +41,20 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(AuthController.class) +@WebMvcTest( + controllers = AuthController.class, + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + JwtFilter.class, + SecurityConfig.class + }) + }, + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + OAuth2ClientAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class + } +) @AutoConfigureMockMvc(addFilters = false) @TestPropertySource(properties = {"jwt.header=Authorization"}) @Import(AuthControllerSliceTest.AuthTestConfig.class) // 커스텀 argument resolver 등록 @@ -43,8 +62,8 @@ class AuthControllerSliceTest { @Autowired MockMvc mvc; - @MockitoBean AuthService authService; - @MockitoBean TokenProvider tokenProvider; + @MockitoBean AuthUseCase authUseCase; + @MockitoBean AuthTokenResolver tokenResolver; @MockitoBean AuthDetailsService authDetailsService; @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; @@ -64,8 +83,7 @@ public Object resolveArgument(org.springframework.core.MethodParameter parameter 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); + when(authDetails.getMemberId()).thenReturn(1L); return authDetails; } }; @@ -79,50 +97,57 @@ public void addArgumentResolvers(List resolvers) @Test void signOut_OK_토큰파싱_후_서비스호출() throws Exception { - when(tokenProvider.resolveRefreshToken(any())).thenReturn("RT"); - when(tokenProvider.resolveAccessToken(any())).thenReturn("AT"); + when(tokenResolver.resolveRefreshToken(any())).thenReturn("RT"); + when(tokenResolver.resolveAccessToken(any())).thenReturn("AT"); mvc.perform(post("/v1/auth/sign-out")) .andExpect(status().isOk()); - verify(authService).signOut("RT", "AT"); + verify(authUseCase).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")); + when(tokenResolver.resolveRefreshToken(any())).thenReturn("REAL_RT"); + when(authUseCase.reissue(eq("REAL_RT"), eq(1L))) + .thenReturn(new AuthTokenResult("NEW_AT", "RT_OR_NEW")); - mvc.perform(get("/v1/auth/recreate") - .header("Authorization", "Bearer REAL_RT")) + mvc.perform(get("/v1/auth/recreate")) .andExpect(status().isOk()); - verify(authService).recreate(eq("Bearer REAL_RT"), any(Member.class)); + verify(authUseCase).reissue(eq("REAL_RT"), eq(1L)); } @Test void signIn_OK() throws Exception { - when(authService.signIn(argThat(req -> + when(authUseCase.signIn(argThat(req -> "a@b.com".equals(req.email()) && "pw".equals(req.password()) - ))).thenReturn(new TokenResponse("AT", "RT")); + ))).thenReturn(new AuthTokenResult("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 -> + verify(authUseCase).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); + when(authUseCase.signUp(any(SignUpInput.class))).thenAnswer(invocation -> { + SignUpInput input = invocation.getArgument(0); Credential credential = Credential.create("hashedPassword"); - Member member = request.toMember(credential); - return MemberResponse.of(member); + Member member = Member.create( + input.name(), + input.email(), + input.phoneNumber(), + MemberType.FOUNDER, + credential, + "image.png" + ); + return AuthMemberResult.from(member); }); mvc.perform(post("/v1/auth/sign-up") @@ -130,7 +155,7 @@ void signUp_OK() throws Exception { .content("{\"name\":\"정성호\",\"email\":\"user@ex.com\",\"password\":\"pw\",\"phoneNumber\":\"010-1234-5678\"}")) .andExpect(status().isOk()); - verify(authService).signUp(argThat(req -> + verify(authUseCase).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/application/aireport/AiReportServiceImplIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java index f52c326f..98f8b997 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java @@ -17,10 +17,10 @@ import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.required.AiReportGrader; +import starlight.application.aireport.required.OcrProvider; 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; @@ -46,10 +46,6 @@ class AiReportServiceImplIntegrationTest { AiReportRepository aiReportRepository; @Autowired EntityManager em; - @Autowired - ObjectMapper objectMapper; - @Autowired - AiReportResponseParser responseParser; @TestConfiguration static class TestBeans { diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java index ffdcdf3f..64a4cd93 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java @@ -7,10 +7,10 @@ import starlight.application.aireport.provided.dto.AiReportResponse; import starlight.application.aireport.required.AiReportGrader; import starlight.application.aireport.required.AiReportQuery; +import starlight.application.aireport.required.OcrProvider; 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; @@ -50,7 +50,7 @@ void gradeBusinessPlan_createsNewReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); String extractedContent = "사업계획서 내용"; @@ -102,7 +102,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); AiReport existingReport = mock(AiReport.class); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(existingReport)); @@ -154,7 +154,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { Long memberId = 1L; BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(false); - when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); @@ -174,7 +174,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(false); - when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); @@ -195,7 +195,7 @@ void getAiReport_returnsResponse() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); String rawJson = """ { @@ -235,7 +235,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.getOrThrow(planId)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java index 35fefd30..7c076efe 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java @@ -12,7 +12,7 @@ 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.application.member.required.MemberQueryPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; @@ -50,10 +50,27 @@ ObjectMapper objectMapper() { } @Bean - MemberQuery memberQuery() { - return new MemberQuery() { + MemberQueryPort memberQuery() { + return new MemberQueryPort() { @Override - public Member getOrThrow(Long memberId) { + public Member findByIdOrThrow(Long memberId) { + Member m = mock(Member.class); + when(m.getName()).thenReturn("tester"); + return m; + } + + @Override + public java.util.Optional findByEmail(String email) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional findByProviderAndProviderId(String provider, String providerId) { + return java.util.Optional.empty(); + } + + @Override + public Member findByProviderAndProviderIdOrThrow(String provider, String providerId) { Member m = mock(Member.class); when(m.getName()).thenReturn("tester"); return m; diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java index 4c579585..fcc2707a 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java @@ -20,7 +20,7 @@ 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.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; import java.util.List; @@ -46,7 +46,7 @@ class BusinessPlanServiceImplUnitTest { private ObjectMapper objectMapper; @Mock - private MemberQuery memberQuery; + private MemberQueryPort memberQuery; @InjectMocks private BusinessPlanServiceImpl sut; @@ -66,7 +66,7 @@ void setup() { // memberQuery 기본 스텁 Member stubMember = mock(Member.class); when(stubMember.getName()).thenReturn("tester"); - when(memberQuery.getOrThrow(anyLong())).thenReturn(stubMember); + when(memberQuery.findByIdOrThrow(anyLong())).thenReturn(stubMember); } @Test @@ -105,7 +105,7 @@ void createBusinessPlanWithPdf_savesRoot() { void updateTitle_checksOwnership_thenSaves() { BusinessPlan plan = spy(buildPlanWithSections(10L)); doReturn(true).when(plan).isOwnedBy(10L); - when(businessPlanQuery.getOrThrow(100L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(100L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -120,7 +120,7 @@ void updateTitle_checksOwnership_thenSaves() { void updateTitle_unauthorized_throws() { BusinessPlan plan = spy(buildPlanWithSections(20L)); doReturn(false).when(plan).isOwnedBy(10L); - when(businessPlanQuery.getOrThrow(100L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(100L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.updateBusinessPlanTitle(100L, "title", 10L)); @@ -132,7 +132,7 @@ 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); + when(businessPlanQuery.findByIdOrThrow(100L)).thenReturn(plan); BusinessPlanResponse.Result deleted = sut.deleteBusinessPlan(100L, 10L); @@ -150,7 +150,7 @@ void upsertSubSection_creates_whenNotExists() { BusinessPlan plan = buildPlanWithSections(10L); Overview overview = plan.getOverview(); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -189,7 +189,7 @@ void upsertSubSection_updates_whenExists() { List.of(false, false, false, false, false)); overview.putSubSection(existing); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -215,7 +215,7 @@ void upsertSubSection_updates_whenExists() { void upsertSubSection_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() .createObjectNode(); @@ -236,7 +236,7 @@ void getSubSectionDetail_returnsContent() { List.of(true, false, true, false, true)); overview.putSubSection(sub); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); SubSectionResponse.Detail detail = sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L); @@ -249,7 +249,7 @@ void getSubSectionDetail_returnsContent() { @DisplayName("서브섹션 조회: 없으면 예외") void getSubSectionDetail_notFound_throws() { BusinessPlan plan = buildPlanWithSections(10L); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L)); @@ -260,7 +260,7 @@ void getSubSectionDetail_notFound_throws() { void getSubSectionDetail_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L)); @@ -276,7 +276,7 @@ void deleteSubSection_success() { List.of(false, false, false, false, false)); overview.putSubSection(sub); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -295,7 +295,7 @@ void deleteSubSection_success() { void deleteSubSection_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L)); @@ -337,7 +337,7 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { List.of(false, false, false, false, false)); plan.getProblemRecognition().putSubSection(problem); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.getOrThrowWithAllSubSections(1L)).thenReturn(plan); BusinessPlanResponse.Detail detail = sut.getBusinessPlanDetail(1L, 10L); @@ -355,7 +355,7 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { void getBusinessPlanDetail_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.getOrThrowWithAllSubSections(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.getBusinessPlanDetail(1L, 10L)); @@ -371,7 +371,7 @@ void checkAndUpdateSubSection_savesChecks() { SubSection sub = SubSection.create(SubSectionType.OVERVIEW_BASIC, "previous-content", "{}", previousChecks); overview.putSubSection(sub); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -411,7 +411,7 @@ void checkAndUpdateSubSection_savesChecks() { @DisplayName("서브섹션 체크: 없으면 예외") void checkAndUpdateSubSection_notFound_throws() { BusinessPlan plan = buildPlanWithSections(10L); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); JsonNode node = mock(JsonNode.class); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, @@ -423,7 +423,7 @@ void checkAndUpdateSubSection_notFound_throws() { void checkAndUpdateSubSection_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); JsonNode node = mock(JsonNode.class); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, @@ -434,7 +434,7 @@ void checkAndUpdateSubSection_unauthorized_throws() { @DisplayName("섹션 매핑: 각 Section 타입별로 올바르게 SubSection이 저장된다") void createSubSection_forEachSectionType() { BusinessPlan plan = buildPlanWithSections(10L); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -483,7 +483,7 @@ void upsertSubSection_allSubSectionsCreated_updatesStatusToDrafted() { getSectionByPlanAndType(plan, type.getSectionType()).putSubSection(sub); } - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -511,7 +511,7 @@ void upsertSubSection_partialSubSections_noStatusChange() { BusinessPlan plan = spy(buildPlanWithSections(10L)); doReturn(true).when(plan).isOwnedBy(10L); - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -545,7 +545,7 @@ void deleteSubSection_noStatusChange() { getSectionByPlanAndType(plan, type.getSectionType()).putSubSection(sub); } - when(businessPlanQuery.getOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/src/test/java/starlight/application/expert/ExpertQueryServiceTest.java b/src/test/java/starlight/application/expert/ExpertQueryServiceTest.java deleted file mode 100644 index b630f70f..00000000 --- a/src/test/java/starlight/application/expert/ExpertQueryServiceTest.java +++ /dev/null @@ -1,62 +0,0 @@ -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 index 9d441dbf..2cd7ba80 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java @@ -1,16 +1,15 @@ package starlight.application.member; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; 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 org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; @@ -18,9 +17,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({CredentialServiceImpl.class, CredentialServiceImplIntegrationTest.TestBeans.class}) +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {CredentialServiceImpl.class, CredentialServiceImplIntegrationTest.TestBeans.class}) class CredentialServiceImplIntegrationTest { @TestConfiguration @@ -29,21 +27,13 @@ static class TestBeans { } @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); + Credential created = sut.createCredential("raw-pw"); assertNotNull(created.getPassword()); assertTrue(passwordEncoder.matches("raw-pw", created.getPassword())); - - // 실제 DB에도 들어갔는지 확인 (id 존재 등) - assertNotNull(created.getId()); - assertTrue(credentialRepository.findById(created.getId()).isPresent()); } @Test diff --git a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java b/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java index 6ace923a..91a936ac 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java @@ -6,9 +6,7 @@ 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.auth.exception.AuthException; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; @@ -19,22 +17,14 @@ 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); + Credential created = sut.createCredential("raw-pw"); verify(passwordEncoder).encode("raw-pw"); - verify(credentialRepository).save(any(Credential.class)); assertNotNull(created); } diff --git a/src/test/java/starlight/application/member/MemberServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java similarity index 74% rename from src/test/java/starlight/application/member/MemberServiceImplIntegrationTest.java rename to src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java index ac7fc3dc..8a53170f 100644 --- a/src/test/java/starlight/application/member/MemberServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java @@ -5,7 +5,7 @@ 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.MemberJpa; import starlight.adapter.member.persistence.MemberRepository; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; @@ -15,15 +15,13 @@ 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 { +@Import({MemberQueryService.class, MemberJpa.class}) +class MemberQueryServiceIntegrationTest { - @Autowired MemberServiceImpl sut; + @Autowired MemberQueryService sut; @Autowired MemberRepository memberRepository; @Test @@ -45,22 +43,15 @@ class MemberServiceImplIntegrationTest { // 중복 방지 로직은 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)); + () -> sut.createUser(newCredential, "dup", "dup@ex.com", "010-0000-0000")); } @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); + Member saved = sut.createUser(cred, "ok", "ok@ex.com", "010-0000-0000"); Optional found = memberRepository.findByEmail("ok@ex.com"); assertTrue(found.isPresent()); diff --git a/src/test/java/starlight/application/member/MemberServiceImplUnitTest.java b/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java similarity index 54% rename from src/test/java/starlight/application/member/MemberServiceImplUnitTest.java rename to src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java index edabfdcd..dcd8009d 100644 --- a/src/test/java/starlight/application/member/MemberServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java @@ -5,8 +5,8 @@ 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.application.member.required.MemberCommandPort; +import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; import starlight.domain.member.exception.MemberException; @@ -18,52 +18,47 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class MemberServiceImplUnitTest { +class MemberQueryServiceUnitTest { - @Mock MemberRepository memberRepository; - @InjectMocks MemberServiceImpl sut; + @Mock MemberQueryPort memberQueryPort; + @Mock MemberCommandPort memberCommandPort; + @InjectMocks MemberQueryService sut; @Test void createUser_중복이메일이면_예외() { - AuthRequest req = mock(AuthRequest.class); - when(req.email()).thenReturn("dup@ex.com"); - when(memberRepository.findByEmail("dup@ex.com")) + when(memberQueryPort.findByEmail("dup@ex.com")) .thenReturn(Optional.of(mock(Member.class))); assertThrows(MemberException.class, - () -> sut.createUser(mock(Credential.class), req)); - verify(memberRepository, never()).save(any()); + () -> sut.createUser(mock(Credential.class), "name", "dup@ex.com", "010-0000-0000")); + verify(memberCommandPort, 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")) + when(memberQueryPort.findByEmail("ok@ex.com")) .thenReturn(Optional.empty()); - when(req.toMember(cred)).thenReturn(mapped); - when(memberRepository.save(mapped)).thenReturn(saved); + when(memberCommandPort.save(any(Member.class))).thenReturn(saved); - Member result = sut.createUser(cred, req); + Member result = sut.createUser(cred, "name", "ok@ex.com", "010-0000-0000"); - verify(memberRepository).save(mapped); + verify(memberCommandPort).save(any(Member.class)); assertSame(saved, result); } @Test void getUserByEmail_없으면_예외() { - when(memberRepository.findByEmail("none@ex.com")).thenReturn(Optional.empty()); + when(memberQueryPort.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)); + when(memberQueryPort.findByEmail("hit@ex.com")).thenReturn(Optional.of(m)); assertSame(m, sut.getUserByEmail("hit@ex.com")); } } diff --git a/src/test/java/starlight/application/auth/AuthServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java similarity index 55% rename from src/test/java/starlight/application/auth/AuthServiceImplIntegrationTest.java rename to src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java index bf8603b1..d13032a1 100644 --- a/src/test/java/starlight/application/auth/AuthServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java @@ -1,19 +1,19 @@ -package starlight.application.auth; +package starlight.application.member.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.auth.provided.dto.AuthMemberResult; +import starlight.application.member.auth.provided.dto.AuthTokenResult; +import starlight.application.member.auth.provided.dto.SignInInput; +import starlight.application.member.auth.provided.dto.SignUpInput; +import starlight.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.required.TokenProvider; import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberService; -import starlight.domain.auth.exception.AuthException; +import starlight.application.member.provided.MemberQueryUseCase; +import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -28,7 +28,7 @@ }) class AuthServiceImplIntegrationTest { - @MockitoBean MemberService memberService; + @MockitoBean MemberQueryUseCase memberQueryUseCase; @MockitoBean CredentialService credentialService; @MockitoBean TokenProvider tokenProvider; @MockitoBean KeyValueMap redisClient; @@ -37,32 +37,32 @@ class AuthServiceImplIntegrationTest { @Test void signUp_정상_자격증명_생성후_회원생성_리턴() { - AuthRequest req = mock(AuthRequest.class); + SignUpInput req = new SignUpInput("name", "u@ex.com", "010-0000-0000", "pw"); 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); + when(credentialService.createCredential("pw")).thenReturn(cred); + when(memberQueryUseCase.createUser(cred, "name", "u@ex.com", "010-0000-0000")).thenReturn(member); - MemberResponse res = sut.signUp(req); + AuthMemberResult res = sut.signUp(req); - verify(credentialService).createCredential(req); - verify(memberService).createUser(cred, req); + verify(credentialService).createCredential("pw"); + verify(memberQueryUseCase).createUser(cred, "name", "u@ex.com", "010-0000-0000"); assertNotNull(res); } @Test void signIn_정상_토큰생성_리프레시_Redis저장() { - SignInRequest req = new SignInRequest("a@b.com", "pw"); + SignInInput req = new SignInInput("a@b.com", "pw"); Member member = Member.create("test", "a@b.com", null, MemberType.FOUNDER, null, "img.png"); - TokenResponse token = new TokenResponse("AT", "RT"); + AuthTokenResult token = new AuthTokenResult("AT", "RT"); - when(memberService.getUserByEmail("a@b.com")).thenReturn(member); + when(memberQueryUseCase.getUserByEmail("a@b.com")).thenReturn(member); // 비밀번호 검증은 side-effect만 확인 doNothing().when(credentialService).checkPassword(member, "pw"); - when(tokenProvider.createToken(member)).thenReturn(token); + when(tokenProvider.issueTokens(member)).thenReturn(token); - TokenResponse out = sut.signIn(req); + AuthTokenResult out = sut.signIn(req); verify(credentialService).checkPassword(member, "pw"); verify(redisClient).setValue("a@b.com", "RT", 3600L); @@ -72,15 +72,15 @@ class AuthServiceImplIntegrationTest { @Test void signIn_비번오류_전파() { - SignInRequest req = new SignInRequest("a@b.com", "bad"); + SignInInput req = new SignInInput("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(memberQueryUseCase.getUserByEmail("a@b.com")).thenReturn(member); + doThrow(new AuthException(starlight.domain.member.auth.exception.AuthErrorType.TOKEN_INVALID)) .when(credentialService).checkPassword(member, "bad"); assertThrows(AuthException.class, () -> sut.signIn(req)); - verify(tokenProvider, never()).createToken(any()); + verify(tokenProvider, never()).issueTokens(any()); verify(redisClient, never()).setValue(any(), any(), anyLong()); } @@ -88,68 +88,74 @@ class AuthServiceImplIntegrationTest { 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()); + verify(tokenProvider, never()).logoutTokens(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()); + verify(tokenProvider, never()).logoutTokens(any(), any()); } @Test void signOut_정상_무효화호출() { when(tokenProvider.validateToken("GOOD_AT")).thenReturn(true); - doNothing().when(tokenProvider).invalidateTokens("RT", "GOOD_AT"); + when(tokenProvider.validateToken("RT")).thenReturn(true); + doNothing().when(tokenProvider).logoutTokens("RT", "GOOD_AT"); assertDoesNotThrow(() -> sut.signOut("RT", "GOOD_AT")); - verify(tokenProvider).invalidateTokens("RT", "GOOD_AT"); + verify(tokenProvider).logoutTokens("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)); + assertThrows(AuthException.class, () -> sut.reissue(null, 1L)); } @Test void recreate_member_null이면_MEMBER_NOT_FOUND() { - assertThrows(MemberException.class, () -> sut.recreate("Bearer RT", null)); + assertThrows(MemberException.class, () -> sut.reissue("Bearer RT", null)); } @Test void recreate_refresh_유효성_실패면_TOKEN_INVALID() { + Long memberId = 1L; + Member member = Member.create("m","m@ex.com", null, MemberType.FOUNDER, null, "img.png"); + when(memberQueryUseCase.getUserById(memberId)).thenReturn(member); 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"))); + assertThrows(AuthException.class, () -> sut.reissue("Bearer REAL_RT", memberId)); } @Test void recreate_Redis저장값과_불일치면_TOKEN_NOT_FOUND() { + Long memberId = 1L; Member member = Member.create("m","m@ex.com", null, MemberType.FOUNDER, null, "img.png"); + when(memberQueryUseCase.getUserById(memberId)).thenReturn(member); 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()); + assertThrows(AuthException.class, () -> sut.reissue("Bearer REAL_RT", memberId)); + verify(tokenProvider, never()).reissueTokens(any(), anyString()); } @Test void recreate_정상_재발급성공() { + Long memberId = 1L; Member member = Member.create("m","m@ex.com", null, MemberType.FOUNDER, null, "img.png"); - TokenResponse recreated = new TokenResponse("NEW_AT", "SAME_OR_NEW_RT"); + AuthTokenResult recreated = new AuthTokenResult("NEW_AT", "SAME_OR_NEW_RT"); + when(memberQueryUseCase.getUserById(memberId)).thenReturn(member); 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); + when(tokenProvider.reissueTokens(member, "REAL_RT")).thenReturn(recreated); - TokenResponse out = sut.recreate("Bearer REAL_RT", member); + AuthTokenResult out = sut.reissue("Bearer REAL_RT", memberId); assertEquals("NEW_AT", out.accessToken()); - verify(tokenProvider).recreate(member, "REAL_RT"); + verify(tokenProvider).reissueTokens(member, "REAL_RT"); } } diff --git a/src/test/java/starlight/application/auth/AuthServiceImplUnitTest.java b/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java similarity index 66% rename from src/test/java/starlight/application/auth/AuthServiceImplUnitTest.java rename to src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java index f07d6d26..d80e9af6 100644 --- a/src/test/java/starlight/application/auth/AuthServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java @@ -1,4 +1,4 @@ -package starlight.application.auth; +package starlight.application.member.auth; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -7,13 +7,13 @@ 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.auth.provided.dto.AuthTokenResult; +import starlight.application.member.auth.provided.dto.SignInInput; +import starlight.application.member.auth.required.KeyValueMap; +import starlight.application.member.auth.required.TokenProvider; import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberService; -import starlight.domain.auth.exception.AuthException; +import starlight.application.member.provided.MemberQueryUseCase; +import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -24,7 +24,7 @@ @ExtendWith(MockitoExtension.class) class AuthServiceImplUnitTest { - @Mock MemberService memberService; + @Mock MemberQueryUseCase memberQueryUseCase; @Mock CredentialService credentialService; @Mock TokenProvider tokenProvider; @Mock KeyValueMap redisClient; @@ -38,13 +38,13 @@ void init() { @Test void signIn_정상() { - SignInRequest req = new SignInRequest("a@b.com", "pw"); + SignInInput req = new SignInInput("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); + AuthTokenResult token = new AuthTokenResult("AT", "RT"); + when(memberQueryUseCase.getUserByEmail("a@b.com")).thenReturn(member); + when(tokenProvider.issueTokens(member)).thenReturn(token); - TokenResponse res = sut.signIn(req); + AuthTokenResult res = sut.signIn(req); verify(credentialService).checkPassword(member, "pw"); verify(redisClient).setValue("a@b.com", "RT", 3600L); @@ -55,17 +55,18 @@ void init() { void signOut_AccessToken_유효성_실패면_예외() { when(tokenProvider.validateToken("bad")).thenReturn(false); assertThrows(AuthException.class, () -> sut.signOut("r", "bad")); - verify(tokenProvider, never()).invalidateTokens(any(), any()); + verify(tokenProvider, never()).logoutTokens(any(), any()); } @Test void recreate_저장된_리프레시와_불일치면_예외() { Member member = Member.create("testName", "a@b.com", null, MemberType.FOUNDER, null, "image.png"); + when(memberQueryUseCase.getUserById(1L)).thenReturn(member); 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)); + () -> sut.reissue("Bearer REAL_RT", 1L)); } -} \ No newline at end of file +} diff --git a/src/test/java/starlight/bootstrap/SecurityConfigTest.java b/src/test/java/starlight/bootstrap/SecurityConfigTest.java index 52c2e910..bbe1807e 100644 --- a/src/test/java/starlight/bootstrap/SecurityConfigTest.java +++ b/src/test/java/starlight/bootstrap/SecurityConfigTest.java @@ -9,12 +9,12 @@ 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 starlight.adapter.member.auth.security.filter.ExceptionFilter; +import starlight.adapter.member.auth.security.filter.JwtFilter; +import starlight.adapter.member.auth.security.handler.JwtAccessDeniedHandler; +import starlight.adapter.member.auth.security.handler.JwtAuthenticationHandler; +import starlight.adapter.member.auth.security.oauth2.CustomOAuth2UserService; +import starlight.adapter.member.auth.security.oauth2.OAuth2SuccessHandler; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; diff --git a/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java b/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java index 066a71b2..79e7baa4 100644 --- a/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java +++ b/src/test/java/starlight/domain/expertReport/entity/ExpertReportTest.java @@ -104,32 +104,32 @@ void editAfterSubmit_ThrowsException() { } @Test - @DisplayName("Details 업데이트 - null 예외") - void updateDetails_Null_ThrowsException() { + @DisplayName("Comments 업데이트 - null 예외") + void updateComments_Null_ThrowsException() { // given ExpertReport report = ExpertReport.create(1L, 10L, "token"); // when & then - assertThatThrownBy(() -> report.updateDetails(null)) + assertThatThrownBy(() -> report.updateComments(null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("details는 null일 수 없습니다"); + .hasMessageContaining("comments는 null일 수 없습니다"); } @Test - @DisplayName("Details 업데이트 - 성공") - void updateDetails_Success() { + @DisplayName("Comments 업데이트 - 성공") + void updateComments_Success() { // given ExpertReport report = ExpertReport.create(1L, 10L, "token"); - List details = List.of( - ExpertReportDetail.create(CommentType.STRENGTH, "좋습니다"), - ExpertReportDetail.create(CommentType.WEAKNESS, "개선 필요") + List comments = List.of( + ExpertReportComment.create(CommentType.STRENGTH, "좋습니다"), + ExpertReportComment.create(CommentType.WEAKNESS, "개선 필요") ); // when - report.updateDetails(details); + report.updateComments(comments); // then - assertThat(report.getDetails()).hasSize(2); + assertThat(report.getComments()).hasSize(2); } @Test @@ -148,28 +148,28 @@ void incrementViewCount_Success() { } @Test - @DisplayName("ExpertReportDetail 생성 - 성공") - void createDetail_Success() { + @DisplayName("ExpertReportComment 생성 - 성공") + void createComment_Success() { // given CommentType type = CommentType.STRENGTH; String content = "시장 분석이 우수합니다."; // when - ExpertReportDetail detail = ExpertReportDetail.create(type, content); + ExpertReportComment comment = ExpertReportComment.create(type, content); // then - assertThat(detail).isNotNull(); - assertThat(detail.getCommentType()).isEqualTo(type); - assertThat(detail.getContent()).isEqualTo(content); + assertThat(comment).isNotNull(); + assertThat(comment.getType()).isEqualTo(type); + assertThat(comment.getContent()).isEqualTo(content); } @Test - @DisplayName("ExpertReportDetail 생성 - content empty 예외") - void createDetail_EmptyContent_ThrowsException() { + @DisplayName("ExpertReportComment 생성 - content empty 예외") + void createComment_EmptyContent_ThrowsException() { // when & then assertThatThrownBy(() -> - ExpertReportDetail.create(CommentType.STRENGTH, "")) + ExpertReportComment.create(CommentType.STRENGTH, "")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("content는 필수입니다"); } -} \ No newline at end of file +} 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" index e88e1454..c45767a8 100644 --- "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" @@ -25,7 +25,36 @@ - bootstrap ## 유의사항 -- 어댑터는 항상 포트(인터페이스) 에만 의존한다. +- 어댑터는 항상 포트(인터페이스)에만 의존한다. - 어댑터 ↔ 어댑터 직접 의존 금지 (필요하면 새 포트를 애플리케이션에 정의). -- 공통 능력은 공용 포트 1개로 여러 서비스에서 재사용한다. +- Inbound `provided`는 해당 도메인의 유스케이스만 노출한다. +- Outbound 포트는 소비자 도메인에서 정의한다(`application//required`). +- Cross-domain 조회는 `OtherDomainLookupPort` 규칙을 따른다. +- 다른 도메인의 `provided` 서비스를 직접 호출하지 않는다. 소비자 도메인에 `required` 포트를 정의하고, 제공 도메인의 어댑터가 구현한다. +- Response DTO는 애플리케이션 DTO로만 변환하고 엔티티를 직접 받지 않는다. +- 도메인 의미가 있는 포트는 소비자 도메인 `required`에 둔다. +- 순수 인프라 포트는 shared/infrastructure 패키지로 분리할 수 있다. +## 네이밍 규칙 요약 +- Provided (inbound): `*UseCase` +- Required (outbound): `*Port` +- Cross-domain lookup: `OtherDomainLookupPort` +- 컬렉션을 함께 로딩하는 경우 이름에 컬렉션을 명시한다. + - 예: `findAllWithCareersTagsCategories`, `findByIdWithCareersAndTags` + - 예: `fetchExpertsWithCareersByIds` +- 영속성/조회 메서드의 실패-강제 조회는 `findByIdOrThrow`로 통일한다( `getOrThrow` 금지 ). +- 조회 동작은 `find*`로 통일한다(서비스/포트/어댑터). `get*`은 DTO getter 등 의미가 명확한 경우만 사용한다. +- 실패 동작을 이름으로 드러낸다. + - Optional/nullable 반환: `findById`, `findByEmail` + - 예외를 던짐: `findByIdOrThrow`, `findByTokenOrThrow` + - 연관 강제 로딩: `findByIdWithCareersAndTags`, `fetchExpertsWithTagsByIds` +- 유스케이스(입력 포트) 메서드는 동사(행동) 중심으로 네이밍한다. + - 예: `signIn`, `reissue`, `createReport` +- DTO 이름은 계층 역할을 드러낸다. + - Web/API 입력: `*Request` + - Web/API 출력: `*Response` + - Application 입력: `*Input` + - Application 출력: `*Result` + +## 로컬 실행 +- `./gradlew bootRun --args='--spring.profiles.active=dev'` 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" index 8e8e6277..2a579693 100644 --- "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" @@ -275,7 +275,7 @@ _Aggregate Root_ - `viewCount`: `int` - 조회 횟수 - `overallComment`: `String` - 전체 코멘트 (TEXT) - `submitStatus`: `SubmitStatus` - 제출 상태 (기본값: PENDING) -- `details`: `List` - 리포트 상세 목록 (1:N 관계) +- `comments`: `List` - 리포트 코멘트 목록 (1:N 관계) #### 행위 - `static create()`: 전문가 리포트 생성 (전문가ID, 사업계획서ID, 토큰) - 만료일시는 생성 후 7일 @@ -286,7 +286,7 @@ _Aggregate Root_ - `temporarySave()`: 임시 저장 - `submit()`: 리포트 제출 - `updateOverallComment()`: 전체 코멘트 업데이트 -- `updateDetails()`: 리포트 상세 목록 업데이트 +- `updateComments()`: 리포트 코멘트 목록 업데이트 - `incrementViewCount()`: 조회 횟수 증가 #### 규칙 @@ -297,15 +297,15 @@ _Aggregate Root_ - expiredAt가 현재 시간보다 이전이면 EXPIRED 상태로 변경된다. - 동일한 사업계획서와 전문가 조합에 대해 1개의 리포트만 존재할 수 있다 (유니크 제약 조건). -### 리포트 상세(ExpertReportDetail) +### 리포트 코멘트(ExpertReportComment) _Entity_ #### 속성 - `id`: `Long` -- `commentType`: `CommentType` - 코멘트 타입 (STRENGTH, WEAKNESS) +- `type`: `CommentType` - 코멘트 타입 (STRENGTH, WEAKNESS) - `content`: `String` - 내용 (TEXT) #### 행위 -- `static create()`: 리포트 상세 생성 (코멘트타입, 내용) +- `static create()`: 리포트 코멘트 생성 (코멘트타입, 내용) - `update()`: 내용 업데이트 ### 제출 상태(SubmitStatus) @@ -406,6 +406,30 @@ _Enum_ #### 상수 - 사용 크레딧 상품 타입 (1회권, 2회권 등) +### 사용 지갑(UsageWallet) +_Aggregate Root_ +#### 속성 +- `id`: `Long` +- `userId`: `Long` - 사용자 ID +- `aiReportRemainingCount`: `int` - 남은 AI 리포트 사용 가능 횟수 + +#### 행위 +- `static init()`: 지갑 초기화 +- `chargeAiReport()`: 사용권 충전 +- `useAiReport()`: 사용권 사용 + +### 사용 이력(UsageHistory) +_Entity_ +#### 속성 +- `id`: `Long` +- `userId`: `Long` - 사용자 ID +- `changedCount`: `int` - 증감 수량 +- `remainingCount`: `int` - 변경 후 잔여 수량 +- `orderId`: `Long` - 주문 ID (충전 연동) + +#### 행위 +- `static charged()`: 충전 이력 생성 + ### 주문 코드(OrderCode) _Value Object_ #### 속성 @@ -424,4 +448,4 @@ _Value Object_ - `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" index a046a8eb..19997895 100644 --- "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" @@ -14,12 +14,13 @@ | 성장 전략 | GrowthTactic | 사업계획서의 네 번째 섹션. 비즈니스 모델, 자금조달 계획, 시장진입 및 성과창출 전략을 포함한다. | | 팀 역량 | TeamCompetence | 사업계획서의 다섯 번째 섹션. 창업자의 역량, 팀 역량을 포함한다. | | 피드백 신청 | ExpertApplication | 창업자가 특정 전문가에게 자신의 사업계획서에 대한 피드백을 요청하는 신청. 동일한 사업계획서에 대해 동일한 전문가에게는 1회만 신청 가능하다. | -| 전문가 리포트 | ExpertReport | 전문가가 사업계획서에 대한 피드백을 작성한 리포트. 전체 코멘트와 각 섹션별 상세 코멘트(강점/약점)를 포함한다. 7일의 평가 기한을 가진다. | -| 리포트 상세 | ExpertReportDetail | 전문가 리포트의 세부 내용. 강점(STRENGTH) 또는 약점(WEAKNESS) 타입의 코멘트를 포함한다. | +| 전문가 리포트 | ExpertReport | 전문가가 사업계획서에 대한 피드백을 작성한 리포트. 전체 코멘트와 각 코멘트(강점/약점)를 포함한다. 7일의 평가 기한을 가진다. | +| 리포트 코멘트 | ExpertReportComment | 전문가 리포트의 세부 코멘트. 강점(STRENGTH) 또는 약점(WEAKNESS) 타입의 코멘트를 포함한다. | | AI 리포트 | AiReport | AI가 사업계획서를 분석하여 자동으로 생성한 리포트. JSON 형태로 저장된다. | | 주문 | Orders | 전문가 피드백 신청을 위한 결제 주문. 토스페이먼츠를 통해 결제를 처리한다. | | 결제 기록 | PaymentRecords | 주문에 대한 결제 시도 및 완료 기록. 여러 번의 결제 시도를 기록할 수 있다. | -| 사용 크레딧 | UsageCredit | 전문가 피드백 신청에 사용하는 크레딧(이용권). 1회권, 2회권 등의 상품이 있다. | +| 사용 지갑 | UsageWallet | 사용자별 이용권 잔여 수량을 관리하는 지갑. | +| 사용 이력 | UsageHistory | 이용권 충전/사용 이력을 기록한다. | | 사업계획서 상태 | PlanStatus | 사업계획서의 진행 상태. STARTED(시작됨), WRITTEN_COMPLETED(작성 완료), AI_REVIEWED(AI 리뷰 완료), EXPERT_MATCHED(전문가 매칭 완료), FINALIZED(최종 완료)가 있다. | | 제출 상태 | SubmitStatus | 전문가 리포트의 제출 상태. PENDING(평가 전), TEMPORARY_SAVED(임시 저장), SUBMITTED(제출 완료), EXPIRED(만료됨)가 있다. | | 코멘트 타입 | CommentType | 전문가 리포트 상세의 코멘트 타입. STRENGTH(강점), WEAKNESS(약점)이 있다. | @@ -27,4 +28,3 @@ | 주문 상태 | OrderStatus | 주문의 결제 상태. NEW(주문 생성됨, 결제 전), PAID(결제 완료), CANCELED(주문/결제 취소)가 있다. | | 인증 정보 | Credential | 회원의 비밀번호 등 인증 관련 정보. Member와 1:1 관계이다. | | 원시 JSON | RawJson | 원본 데이터를 JSON 형태로 저장하기 위한 값 객체. | -