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초 동안 최대한의 요청을 보낸다.|
-| :-------| :-------|
-|||
-|✅ 총 120개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 82.09 ms
- 최소 요청 처리 시간 : 22.52ms
- 최대 요청 처리 시간 : 164.64ms |✅ 총 4002개의 요청이 문제없이 처리됨
- 평균 요청 처리 시간 : 7.74s
- 최소 요청 처리 시간 : 21.9s
- 최대 요청 처리 시간 : 18.28s
- 95th 퍼센타일 : 14.95s|
-
-
-
-| 시나리오 ③ 사용자 수 변동 시나리오 | 시나리오 ④ 응답 시간이 5초 이내인 최대 요청 수 파악|
-| :-------|:----|
-|||
-|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