From 804d485e3f743a3f0ad1ee747b103ac63a91e2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Thu, 2 Jul 2026 18:25:53 +0900 Subject: [PATCH 01/93] =?UTF-8?q?chore:=20=EC=9D=B4=EC=8A=88=20=EB=B0=8F?= =?UTF-8?q?=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=B6=94=EA=B0=80=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 이슈 및 PR 템플릿 추가 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: 이슈 템플릿 메타데이터 추가 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: PR 템플릿 placeholder 추가 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: PR 템플릿 placeholder 추가 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: 이슈 템플릿 상단 테이블 제거 * chore: 이슈 및 PR 템플릿 메타데이터 추가 --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/task.md | 22 ++++++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/task.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..a7dcbcc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,22 @@ +--- +name: 이슈 템플릿 +about: "작업 전에 이슈를 만들 때 사용합니다." +title: "" +label: "" +--- + +## 배경 + + + +## 작업 범위 + + + +- + +## 검증 + +- [ ] 작은 범위 테스트 실행 +- [ ] `./gradlew test` 실행 +- [ ] `./gradlew build` 실행 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..38089d8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +--- +name: PR 템플릿 +about: "작업을 완료하고 PR을 만들 때 사용합니다." +title: "" +label: "" +--- + +## 변경 내용 + + + +- 변경 사항 1 + +## 검증 + + + +- 확인 사항 1 + +## 연결 이슈 + + + +Closes # From 82216b9d2003f7af870440452c8198af93db9923 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Fri, 3 Jul 2026 14:09:13 +0900 Subject: [PATCH 02/93] =?UTF-8?q?chore:=20Spring=20Boot=203.5.16=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC,=20AI=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Spring Boot 3.5.16 다운그레이드 및 에러 구조 분리, AI 컨벤션 문서 추가 - Spring Boot 4.1.0 -> 3.5.16 다운그레이드 (starter 의존성 이름 3.x 기준 정리, Jackson 2.x import로 변경) - 공통 에러 코드 분리: ErrorCode -> GlobalErrorCode, 도메인별 에러 패키지(user/exception) 추가 - AI 코딩 에이전트용 컨벤션 문서 추가 (CLAUDE.md, AGENTS.md, docs/, .github/) Closes #4 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: 에러 코드 구조 개선 및 GlobalErrorCode 제거 * chore: AGENTS.md 파일 삭제 * docs: 작업 흐름 및 기술 기준 문서 수정 * refactor: 도메인별 ErrorCode 구조 개선 및 예외 처리 규칙 업데이트 --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: 손정원 --- CLAUDE.md | 57 +++ build.gradle | 14 +- docs/README.md | 42 ++ docs/git-convention.md | 90 ++++ docs/rules.md | 383 ++++++++++++++++++ rules.md | 373 +++++++++++++++++ .../auth/exception/AuthErrorCode.java | 19 + .../config/RestAuthenticationEntryPoint.java | 5 +- .../global/config/SecurityConfig.java | 1 - .../global/error/ConflictException.java | 9 - .../bop/youthpick/global/error/ErrorCode.java | 42 +- .../global/error/GlobalExceptionHandler.java | 21 +- .../error/ResourceNotFoundException.java | 9 - .../policy/exception/PolicyErrorCode.java | 20 + .../youthpick/user/exception/UserError.java | 18 + .../user/exception/UserException.java | 11 + 16 files changed, 1044 insertions(+), 70 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/README.md create mode 100644 docs/git-convention.md create mode 100644 docs/rules.md create mode 100644 rules.md create mode 100644 src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java delete mode 100644 src/main/java/com/bop/youthpick/global/error/ConflictException.java delete mode 100644 src/main/java/com/bop/youthpick/global/error/ResourceNotFoundException.java create mode 100644 src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java create mode 100644 src/main/java/com/bop/youthpick/user/exception/UserError.java create mode 100644 src/main/java/com/bop/youthpick/user/exception/UserException.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..856c26f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +Claude Code 또는 유사한 코딩 에이전트는 이 레포에서 작업할 때 반드시 다음 문서를 먼저 읽고 그대로 따른다. + +- 백엔드 구현 규칙: [`docs/rules.md`](./docs/rules.md) +- Git 워크플로: [`docs/git-convention.md`](./docs/git-convention.md) +- 문서 전체 목록/컨벤션: [`docs/README.md`](./docs/README.md) + +## Non-negotiable rules + +- 코드 변경 전에 [`docs/rules.md`](./docs/rules.md)를 먼저 읽는다. +- 패키지는 도메인 우선 + 단순 구조를 유지한다: `com.bop.youthpick.{domain}.controller/service/repository/dto/entity`. +- DDD식 `{domain}.api / application / domain / infrastructure / external` 패키지를 만들지 않는다. +- 요청 DTO에 Bean Validation을 적용하고, `@RequestBody`에는 `@Valid`를 붙인다. +- JPA Entity를 Controller에서 직접 반환하지 않는다. +- 성공 응답은 `global.common.ApiResponse`로 감싸고, 페이지 등 부가정보는 `meta`에 담는다. +- 예외는 `global.error`의 `ErrorCode` + `CustomException` 기반 공통 처리를 사용한다. 비즈니스 에러에 raw `RuntimeException`을 던지지 않는다. +- 목록 API는 Spring Data `Pageable`/`Page`를 사용한다. `page`/`size`/`totalPages`를 직접 계산하지 않는다. +- Lombok은 보일러플레이트 축소 용도로만: `@RequiredArgsConstructor`, `@Getter`, JPA `@NoArgsConstructor(access = AccessLevel.PROTECTED)`만 허용. `@Data`, Entity `@Setter`, Entity `@AllArgsConstructor`는 금지. +- secret / OAuth client secret / token 값을 코드·로그·응답에 노출하지 않는다. `.env`는 커밋하지 않는다. +- 완료를 주장하기 전에 테스트를 실행한다. + +## 기술 스택 요약 + +- Java 21 · Spring Boot 3.5.16 · Gradle(Groovy DSL, `build.gradle`) +- Spring Web MVC / Data JPA / Security / Session(Redis) / Validation / Actuator +- DB: 로컬·테스트 H2 in-memory(MySQL 모드), 배포 MySQL +- 인증: Spring Security 세션 기반 + OAuth 소셜 로그인(Google/Naver/Kakao), 세션은 Redis 저장 + +## Git workflow + +전체 규칙은 [`docs/git-convention.md`](./docs/git-convention.md)를 따른다. 핵심: + +```text +GitHub Issue → 브랜치 생성 → 작업/커밋 → PR → 리뷰/검증 → merge +``` + +- `main`에 직접 커밋하지 않는다. 이슈 없이 임의 브랜치에서 작업하지 않는다. +- 브랜치: `feat|fix|docs|refac/{issue-number}-{short-name}` (기본 분기: `dev` 있으면 `dev`, 없으면 `main`) +- 커밋: `type: subject` (`feat|fix|docs|refac|test|chore`) +- PR 본문: 변경 내용 · 테스트 결과 · `Closes #이슈번호` + +## Required verification + +테스트를 실행해 통과를 확인한 뒤에만 완료를 주장한다. Java toolchain이 JDK 21을 자동 프로비저닝하므로 `JAVA_HOME`을 수동 설정할 필요는 없다. + +```powershell +# Windows (PowerShell) +.\gradlew.bat test +``` + +```bash +# macOS / Linux / CI +./gradlew test +``` + +Docker/Compose(인프라: Redis, 필요 시 MySQL) 관련 변경 시 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. (이 레포는 앱을 컨테이너화하지 않고 IDE/Gradle로 실행하며, compose는 로컬 인프라만 띄운다.) \ No newline at end of file diff --git a/build.gradle b/build.gradle index f2729e9..4df6efe 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.1.0' + id 'org.springframework.boot' version '3.5.16' id 'io.spring.dependency-management' version '1.1.7' } @@ -22,18 +22,16 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-session-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.session:spring-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-security-test' - testImplementation 'org.springframework.boot:spring-boot-starter-session-data-redis-test' - testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..32ce442 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,42 @@ +# YouthPick 문서 (docs) + +`youth-pick` 백엔드의 개발 규칙과 컨벤션 문서 모음이다. 코드/문서 작업 전에 관련 문서를 먼저 읽는다. + +## 문서 목록 + +| 문서 | 내용 | +|------|------| +| [`rules.md`](./rules.md) | 백엔드 구현 규칙 (패키지 구조, Controller/DTO/Validation, 예외 처리, Lombok, Entity/JPA, Service, Pageable, 인증/세션/Redis, Docker, 테스트) | +| [`git-convention.md`](./git-convention.md) | Git 워크플로, 브랜치 전략/네이밍, 커밋 컨벤션, PR/리뷰/merge 규칙 | + +레포 루트에는 에이전트 진입점 문서가 있다. + +| 문서 | 내용 | +|------|------| +| [`../CLAUDE.md`](../CLAUDE.md) | Claude Code 등 코딩 에이전트가 자동 로드하는 진입점. 비협상 규칙 요약 + 상세 문서 링크 | +| [`../AGENTS.md`](../AGENTS.md) | 에이전트 필수 규칙 요약 (범용 에이전트 진입점) | + +`.github/` 에는 협업 템플릿이 있다. + +| 파일 | 내용 | +|------|------| +| [`../.github/ISSUE_TEMPLATE/task.md`](../.github/ISSUE_TEMPLATE/task.md) | 작업 이슈 템플릿 (배경 / 작업 범위 / 검증) | +| [`../.github/PULL_REQUEST_TEMPLATE.md`](../.github/PULL_REQUEST_TEMPLATE.md) | PR 템플릿 (변경 내용 / 검증 / 연결 이슈) | + +## 문서 컨벤션 + +새 문서를 추가하거나 기존 문서를 고칠 때 아래를 따른다. + +- **위치**: 에이전트가 자동으로 읽어야 하는 진입점(`CLAUDE.md`, `AGENTS.md`)만 레포 루트에 둔다. 그 외 상세 규칙·설계·컨벤션 문서는 모두 `docs/` 아래에 둔다. +- **파일명**: 소문자 + 하이픈(`kebab-case`), 확장자 `.md`. 예: `git-convention.md`, `api-spec.md`. +- **언어**: 본문은 한국어. 코드 식별자/명령/예약어는 원문 그대로. +- **단일 출처(SSOT)**: 같은 규칙을 여러 문서에 복붙하지 않는다. 상세 내용은 한 문서에 두고 나머지는 링크한다. (예: Git 규칙 원본은 `git-convention.md`, `CLAUDE.md`/`AGENTS.md`는 요약 + 링크) +- **링크**: 문서 간 참조는 상대경로 링크를 사용해 최신 위치를 가리키게 한다. +- **갱신**: 규칙을 바꾸면 이 목록과 관련 진입점(`CLAUDE.md`/`AGENTS.md`)의 링크·요약도 함께 갱신한다. + +## 앞으로 추가하면 좋은 문서 (제안) + +- `api-spec.md` — API 명세(엔드포인트, 요청/응답, 에러코드 매핑표) +- `error-codes.md` — `ErrorCode` 카탈로그와 프론트 메시지 매핑 +- `oauth-setup.md` — Google/Naver/Kakao OAuth 앱 등록 및 환경변수 세팅 가이드 +- `local-setup.md` — 로컬 실행(H2/Redis/compose) 온보딩 가이드 \ No newline at end of file diff --git a/docs/git-convention.md b/docs/git-convention.md new file mode 100644 index 0000000..c57dbf8 --- /dev/null +++ b/docs/git-convention.md @@ -0,0 +1,90 @@ +# Git 작업 컨벤션 + +`youth-pick`에서 코드나 문서를 수정하는 모든 사람(에이전트 포함)은 아래 흐름을 따른다. + +```text +GitHub Issue 생성 → 브랜치 생성 → 작업/커밋 → PR 생성 → 리뷰/검증 확인 → merge +``` + +직접 `main`에 커밋하거나, 이슈 없이 임의 브랜치에서 작업하지 않는다. 애매하면 이 문서를 기준으로 맞춘다. + +## 브랜치 전략 + +- `main`: 배포 가능한 안정 버전만 유지한다. +- `dev`: 팀 개발 통합 브랜치다. 기능 브랜치는 원칙적으로 `dev`에서 분기한다. +- `feat/*`: 신규 기능 개발 브랜치. +- `fix/*`: 버그 수정 브랜치. +- `docs/*`: 문서 작업 브랜치. +- `refac/*`: 동작 변경 없이 구조를 정리하는 브랜치. + +현재 원격에 `dev` 브랜치가 없으면 `main`에서 분기하되, `dev`가 생긴 이후에는 기능 브랜치를 `dev` 기준으로 만든다. + +## 브랜치 네이밍 + +```text +feat/{issue-number}-{short-name} +fix/{issue-number}-{short-name} +docs/{issue-number}-{short-name} +refac/{issue-number}-{short-name} +``` + +예시: + +```text +feat/12-social-login +fix/18-search-empty-state +docs/24-api-spec +refac/31-policy-service +``` + +## 커밋 컨벤션 + +커밋 메시지는 아래 형식을 따른다. + +```text +type: subject +``` + +예시: + +```text +feat: 소셜 로그인 콜백 API 추가 +fix: 검색 결과 없음 상태 처리 수정 +docs: API 명세서 오류 케이스 보강 +refac: 정책 검색 서비스 책임 분리 +test: 관심정책 중복 방지 테스트 추가 +chore: 프로젝트 설정 파일 정리 +``` + +커밋 타입 기준: + +- `feat`: 사용자에게 보이는 기능 추가 +- `fix`: 버그 수정 +- `docs`: 문서 수정 +- `refac`: 기능 변경 없는 구조 개선 +- `test`: 테스트 추가/수정 +- `chore`: 설정, 빌드, 패키지 등 기타 작업 + +## PR 규칙 + +PR 본문에는 반드시 아래 내용을 적는다. ([PR 템플릿](../.github/PULL_REQUEST_TEMPLATE.md)) + +- 변경 내용 +- 테스트 결과 +- 필요한 경우 스크린샷 또는 로그 +- 연결 이슈: `Closes #이슈번호` + +PR은 최소 1명 이상 리뷰 후 merge하는 것을 원칙으로 한다. 충돌 해결, 테스트 통과, 문서 반영 후 merge한다. + +## 리뷰 기준 + +- 기획서의 User Story / Acceptance Criteria와 구현이 맞는지 확인한다. +- A(정상)/E(예외)/X(경계·비정상) 테스트 관점이 빠지지 않았는지 확인한다. +- 권한, 빈 상태, 오류 상태, 외부 장애 처리가 있는지 확인한다. +- 민감정보(secret, OAuth token 등)가 응답, 로그, 프론트 저장소에 남지 않는지 확인한다. + +## Merge 규칙 + +- 작은 기능 단위로 PR을 나눈다. 큰 PR은 피한다. +- merge 후 `feat/*`, `fix/*`, `docs/*`, `refac/*` 브랜치는 삭제한다. +- 배포 영향이 있는 변경은 이슈/문서에 변경 내용을 남긴다. \ No newline at end of file diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..666501e --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,383 @@ +# YouthPick Back-end Rules + +이 문서는 `youth-pick`에서 작업하는 모든 에이전트와 개발자가 따라야 하는 백엔드 구현 규칙이다. 새 기능을 만들거나 기존 코드를 수정하기 전에 반드시 이 파일을 먼저 읽고, 이 규칙과 충돌하는 구현은 하지 않는다. + +> Git 브랜치/커밋/PR 규칙은 [`git-convention.md`](./git-convention.md)를 따른다. + +## 1. 작업 흐름 + +- 기획 변경이나 구현 계획이 필요한 작업은 먼저 계획을 정리하고 관련 GitHub Issue를 만든 뒤 작업한다. +- 백엔드 작업은 issue 번호가 포함된 브랜치에서 진행한다. ([`git-convention.md`](./git-convention.md)) +- 구현 후에는 테스트를 직접 실행하고, PR에 실제 검증 결과를 적는다. +- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. + +## 2. 기술 기준 + +- **Java 21**을 사용한다. +- **Spring Boot 3.5.16** 기준으로 작성한다. +- 기본 스택: Spring Web MVC, Spring Data JPA, Spring Data Redis(Spring Session 저장소), Spring Security, Bean Validation, Spring Boot Actuator. +- DB: 로컬/테스트는 **H2 in-memory(MySQL 호환 모드)**, 실배포는 **MySQL**. 프로파일과 환경변수로 전환한다. +- 빌드는 **Gradle Wrapper (Groovy DSL, `build.gradle`)** 를 사용한다. Java toolchain이 JDK 21을 자동 프로비저닝하므로 `JAVA_HOME`을 수동으로 지정할 필요는 없다. +- 테스트 실행: + + ```powershell + # Windows (PowerShell) + .\gradlew.bat test + ``` + + ```bash + # macOS / Linux / CI + ./gradlew test + ``` + +## 3. 패키지 구조 + +패키지는 도메인 단위 폴더 구조로 만든다. 다만 도메인 내부는 과하게 DDD식으로 나누지 않고 `controller`, `service`, `repository`, `dto`, `entity` 중심의 단순 구조를 사용한다. + +현재 기준 구조는 다음과 같다. + +```text +com.bop.youthpick +├── global +│ ├── common // ApiResponse 등 공통 응답 봉투 +│ ├── config // SecurityConfig, RestAuthenticationEntryPoint 등 +│ └── error // ErrorCode, CustomException, GlobalExceptionHandler 등 +├── policy +│ ├── controller +│ ├── service +│ ├── repository +│ ├── dto +│ └── entity +└── user + ├── controller + ├── service + ├── repository + ├── dto + └── entity +``` + +새 도메인(예: `favorite`, 자가진단 등)을 추가할 때는 아래 형태를 따른다. + +```text +com.bop.youthpick.{domain} +├── controller +├── service +├── repository +├── dto +└── entity +``` + +각 패키지 역할은 다음과 같다. + +- `controller`: HTTP Controller와 API 입출력 조립을 담당한다. +- `service`: 비즈니스 흐름, scheduler, lock, 외부 API client, 기술 상세 구현을 둔다. +- `repository`: Spring Data JPA repository interface를 둔다. +- `dto`: 요청/응답 DTO, service 결과 DTO, 외부 API payload DTO를 둔다. +- `entity`: JPA Entity, domain enum을 둔다. +- `global`: 여러 도메인이 공유하는 공통 응답, 설정, 예외 처리만 둔다. + +금지 구조는 다음과 같다. + +```text +com.bop.youthpick.{domain} +├── api +├── application +├── domain +├── infrastructure +└── external +``` + +## 4. Controller 규칙 + +Controller는 얇게 유지한다. HTTP 요청 파싱, Bean Validation, Service 호출, 응답 DTO 변환만 담당한다. 비즈니스 판단, 중복 검사, 외부 API 호출, Entity 상태 변경 로직은 Controller에 두지 않는다. + +```java +@RestController +@RequestMapping("/api/v1/policies") +@RequiredArgsConstructor +public class PolicyController { + + private final PolicySearchService policySearchService; +} +``` + +- 생성자 주입을 사용한다(`@RequiredArgsConstructor` + `final` 필드). +- 필드 주입(`@Autowired` field injection)은 금지한다. +- Entity를 API 응답으로 직접 반환하지 않는다. +- 요청 body를 받는 파라미터에는 `@Valid @RequestBody`를 붙인다. +- 성공 응답은 항상 `global.common.ApiResponse`로 감싼다(§5.3 참고). + +## 5. DTO / 응답 규칙 + +요청 DTO와 응답 DTO는 각 도메인의 `dto` 패키지에 둔다. DTO는 API 계약을 표현하는 객체이며 Entity를 외부에 노출하지 않기 위한 경계다. + +### 5.1 요청 DTO + +- 요청 DTO는 `record`를 사용할 수 있다. 다만 안정적인 field-level validation 응답이 필요한 경우 필드에 Bean Validation annotation을 명확히 붙인다. +- enum 값을 직접 바인딩하면 Jackson deserialization 단계에서 터질 수 있다. 잘못된 enum 값을 `C001` 입력값 오류로 내려야 하면 문자열 필드 + `@Pattern`을 사용한다. +- 모든 사용자 입력 필드는 의도를 드러내는 validation annotation을 붙인다. +- validation message는 한국어로 작성한다. + +```java +public record UserSignupRequest( + @NotBlank(message = "아이디는 필수입니다.") + @Size(min = 4, max = 20, message = "아이디는 4~20자여야 합니다.") + String username, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} +``` + +요청 DTO에 validation이 없거나 Controller에서 `@Valid` 없이 받는 코드는 금지한다. + +### 5.2 응답 DTO + +- 응답 DTO는 Entity를 받아 정적 팩토리 메서드 `from(...)`으로 생성한다. +- 응답 DTO에서 Entity의 민감 필드, 내부 상태, raw payload, secret 값을 노출하지 않는다. + +```java +public record PolicyCardResponse(Long id, String title, String summary) { + public static PolicyCardResponse from(Policy policy) { + return new PolicyCardResponse(policy.getId(), policy.getTitle(), policy.getSummary()); + } +} +``` + +### 5.3 공통 응답 봉투 (`ApiResponse`) + +성공 응답은 `global.common.ApiResponse`로 감싼다. 이 프로젝트의 `ApiResponse`는 `data` + `meta` 구조다. + +```java +public record ApiResponse(T data, Map meta) { + public static ApiResponse ok(T data) { ... } + public static ApiResponse ok(T data, Map meta) { ... } +} +``` + +- 단일/객체 데이터는 `ApiResponse.ok(data)`. +- 페이지네이션 등 부가정보(`page`, `totalCount` 등)는 **`data`가 아니라 `meta`에 담는다.** +- 200 외 상태 코드가 필요하면 `ResponseEntity>`로 감싼다. +- 에러 응답은 `ApiResponse`가 아니라 `global.error.ErrorResponse` 형식으로 내려간다(§7). + +```java +@GetMapping +public ApiResponse> search(@PageableDefault(size = 20) Pageable pageable) { + Page page = policySearchService.search(pageable); + return ApiResponse.ok(page.getContent(), Map.of( + "page", page.getNumber(), + "totalCount", page.getTotalElements(), + "totalPages", page.getTotalPages() + )); +} +``` + +## 6. Validation 규칙 + +- `spring-boot-starter-validation`을 사용한다. +- 문자열 필드는 보통 `@NotBlank`를 사용한다. `@NotNull`만으로는 공백 문자열을 막을 수 없다. +- 길이 제한은 `@Size`, 허용값 제한 문자열은 `@Pattern`, 숫자 범위는 `@Min`/`@Max`/`@Positive`를 사용한다. +- 중첩 DTO는 필드에도 `@Valid`를 붙인다. + +Validation 실패는 `GlobalExceptionHandler`에서 `ErrorCode.INVALID_INPUT_VALUE`(`C001`) 형식으로 통일한다. 프론트가 에러코드를 기준으로 메시지를 매핑할 수 있도록 아래 형태를 유지한다. + +```json +{ + "code": "C001", + "message": "입력 형태가 올바르지 않습니다.", + "details": [ + { "field": "username", "value": "", "reason": "아이디는 필수입니다." } + ] +} +``` + +- Bean Validation 실패(`MethodArgumentNotValidException`, `ConstraintViolationException`)는 백엔드에서 처리하고 HTTP 400 + `C001`로 내려준다. +- validation 오류에서도 항상 `code`, `message`, `details` 필드를 유지한다. +- `details[].reason`에는 사용자에게 보여줄 수 있는 검증 메시지만 넣고, 내부 exception class/SQL/stack trace/secret 값은 넣지 않는다. +- Controller나 Service에서 validation 실패를 문자열 응답으로 직접 만들지 않는다. + +## 7. 예외 처리 규칙 + +공통 예외 처리는 `global.error` 패키지에 둔다. 예외 응답은 `ErrorCode` enum을 기준으로 생성한다. + +```text +global.error +├── ErrorCode.java +├── CustomException.java +├── ConflictException.java +├── ResourceNotFoundException.java +├── ErrorResponse.java +└── GlobalExceptionHandler.java +``` + +`ErrorCode`는 HTTP 상태, 서비스 코드, 사용자 메시지를 함께 가진다. **서비스 코드는 접두어 + 3자리** 규칙을 따른다. + +| 접두어 | 영역 | +|--------|------| +| `C` | 공통(common) | +| `A` | 인증/인가(auth) | +| `S` | 서버(server) | +| `U` | 사용자(user) | +| `P` | 정책(policy) | +| `F` | 즐겨찾기(favorite) | +| `D` | 자가진단(diagnosis) | + +```java +public enum ErrorCode { + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "로그인이 필요합니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), + POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "일치하는 정책이 존재하지 않습니다."); + // ... +} +``` + +- 비즈니스 예외는 `throw new CustomException(ErrorCode.X)` 또는 이를 상속한 구체 예외로 던진다. +- 존재하지 않는 리소스는 `ResourceNotFoundException(ErrorCode.X_NOT_FOUND)`를 사용한다. +- 상태 충돌, 중복 실행, 중복 등록 등은 `ConflictException(ErrorCode.X_CONFLICT)`를 사용한다. +- Service에서 `throw new RuntimeException("...")` 형태로 비즈니스 예외를 직접 던지지 않는다. +- Controller에서 `try-catch`로 에러 응답을 만들지 않는다. +- 내부 예외 메시지/SQL/stack trace/secret 값을 응답에 노출하지 않는다. +- 새 `ErrorCode`를 추가할 때는 접두어 체계를 지키고 사용자 메시지를 함께 정의하며, 프론트에서 매핑해야 하는 코드인지 PR 본문에 명시한다. +- 같은 오류 상황에 HTTP status만 다르게 내려주거나, 같은 `code`에 서로 다른 의미를 부여하지 않는다. + +## 8. Lombok 규칙 + +Lombok은 반복적인 생성자, getter, JPA 기본 생성자 보일러플레이트를 줄이는 용도로만 제한적으로 사용한다. + +- 생성자 주입은 `final` 필드와 `@RequiredArgsConstructor`를 기본으로 사용한다. +- JPA Entity에는 `@Getter`와 `@NoArgsConstructor(access = AccessLevel.PROTECTED)`를 사용할 수 있다. +- 예외 코드 enum, 커스텀 예외처럼 단순 getter만 필요한 타입에는 `@Getter`를 사용할 수 있다. +- DTO는 `record`를 우선 사용한다. + +다음은 금지한다. + +- `@Data` +- Entity의 `@Setter` +- Entity의 `@AllArgsConstructor` / public all-args constructor +- 양방향 연관관계 Entity의 무분별한 `@ToString` +- Entity의 무분별한 `@EqualsAndHashCode` + +## 9. Entity / JPA 규칙 + +- Entity는 각 도메인의 `entity` 패키지에 둔다. +- Entity 기본 생성자는 JPA용으로만 열고 외부 생성을 제한한다(`@NoArgsConstructor(access = AccessLevel.PROTECTED)`). +- Entity를 API request/response에 직접 사용하지 않는다. +- 상태 변경은 setter 대신 의미 있는 메서드로 표현한다. +- 컬럼 제약은 `@Column(nullable = false, length = ...)`처럼 명시한다. +- Repository는 각 도메인의 `repository` 패키지에 interface로 둔다. +- 조회 결과가 없을 수 있으면 `Optional`를 사용한다. +- `ddl-auto`는 로컬 `update`, 테스트 `create-drop`. 운영에서 `create`/`update`를 무분별하게 쓰지 않는다. + +## 10. Service 규칙 + +- Service는 각 도메인의 `service` 패키지에 둔다. +- 읽기 메서드는 `@Transactional(readOnly = true)`를 사용한다. +- 생성/수정/삭제/외부 동기화는 명시적으로 `@Transactional`을 사용한다. +- 외부 API 호출, scheduler 등은 흐름을 명확히 나눈다. +- secret, API key, OAuth token 값은 로그에 남기지 않는다. + +## 11. Pageable 페이지네이션 규칙 + +목록 API는 직접 `page`, `size`를 받아 정규화하지 않는다. Spring Data `Pageable`/`Page`를 사용한다. + +```java +@GetMapping +public ApiResponse> search(@PageableDefault(size = 20) Pageable pageable) { + Page page = policySearchService.search(pageable); + return ApiResponse.ok(page.getContent(), Map.of( + "page", page.getNumber(), + "totalCount", page.getTotalElements(), + "totalPages", page.getTotalPages() + )); +} +``` + +```java +@Transactional(readOnly = true) +public Page search(Pageable pageable) { + return repository.findAll(pageable).map(PolicyCardResponse::from); +} +``` + +max page size는 코드의 `Math.min`이 아니라 Spring 설정으로 제한한다. + +```yaml +spring: + data: + web: + pageable: + max-page-size: 50 +``` + +금지 패턴: + +```java +@RequestParam(defaultValue = "0") int page +@RequestParam(defaultValue = "20") int size +int normalizedSize = Math.min(size, 50); +int totalPages = (int) Math.ceil(...); +``` + +## 12. 인증 / 세션 / Redis 규칙 + +이 프로젝트는 **Spring Security 세션 기반 인증** + **Spring Session(Redis 저장소)** + **OAuth 소셜 로그인(Google / Naver / Kakao)** 구조다. + +- 세션은 Spring Session을 통해 Redis에 저장한다(`spring-boot-starter-data-redis` + `spring-session-data-redis`). in-memory 세션에 의존하는 코드를 만들지 않는다. +- 인증 실패(미인증 접근)는 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려준다. +- OAuth client id/secret 등 secret 값은 코드에 하드코딩하지 않고 환경변수(`GOOGLE_OAUTH_CLIENT_ID` 등)로 주입한다. `.env`는 커밋하지 않는다. +- 인가 규칙(경로별 권한)은 `SecurityConfig`에서 관리하고 Controller에 흩뿌리지 않는다. +- 중복 실행을 막아야 하는 배치/스케줄 작업을 도입한다면 Redis lock(owner token + TTL, release 시 owner 확인)을 사용하고, scheduler는 설정으로 on/off 가능하게 둔다. (현재 도입 전이면 도입 시 이 규칙을 따른다.) + +## 13. 실행 / Docker 규칙 + +- **앱(백엔드)은 컨테이너화하지 않고 로컬 IDE / Gradle로 실행한다.** `docker-compose.yml`은 로컬 인프라(Redis, 필요 시 MySQL)만 띄운다. +- 로컬 인프라 기동: + + ```bash + docker compose up -d # Redis (필요 시 MySQL) 기동 + docker compose ps + docker compose down # 중지 (볼륨 데이터 보존) + docker compose down -v # 중지 + 볼륨 삭제(초기화) + ``` + +- 로컬 기본 DB는 H2 in-memory이므로 MySQL 없이도 앱이 뜬다. MySQL로 붙일 때만 compose의 MySQL 서비스와 관련 환경변수를 활성화한다. +- Compose/인프라 관련 변경 시 로컬에서 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. + +## 14. 테스트 규칙 + +- 기능 변경 시 관련 작은 테스트를 먼저 실행하고, 마지막에 전체 테스트를 실행한다. +- Controller validation은 MockMvc 테스트로 검증한다. +- Service 비즈니스 규칙은 Service 테스트로 검증한다. +- Repository/JPA 쿼리는 통합 테스트로 검증한다(테스트 프로파일은 H2 `create-drop`). +- PR 전 최소 `./gradlew test`(Windows는 `.\gradlew.bat test`)를 통과시킨다. + +## 15. API 검증 규칙 + +- 한글 query parameter를 curl로 검증할 때는 raw URL에 직접 넣지 말고 `--data-urlencode`를 사용한다. + + ```bash + curl -G http://localhost:8080/api/v1/policies \ + --data-urlencode 'keyword=월세' \ + --data-urlencode 'region=서울' + ``` + +- actuator health(`/actuator/health`)와 API health를 구분한다. +- Redis나 DB가 꺼져 있으면 actuator health가 DOWN일 수 있다. + +## 16. PR 전 체크리스트 + +- [ ] 도메인 단위 패키지 구조를 지켰다. +- [ ] Controller가 얇게 유지된다. +- [ ] 요청 DTO에 Bean Validation을 적용했다. +- [ ] `@RequestBody`에는 `@Valid`를 붙였다. +- [ ] Entity를 API 응답으로 직접 반환하지 않는다. +- [ ] 성공 응답을 `ApiResponse`로 감쌌고, 페이지 정보는 `meta`에 담았다. +- [ ] Service에서 직접 `RuntimeException`을 던지지 않는다. +- [ ] 예외 응답은 `global.error`를 통해 통일되고, `code`(접두어 규칙)를 포함한다. +- [ ] 새 에러코드를 추가했다면 프론트 메시지 매핑 필요 여부를 PR에 적었다. +- [ ] 목록 API는 `Pageable`/`Page`를 사용한다. +- [ ] secret/OAuth token/민감정보가 로그·응답에 노출되지 않는다. +- [ ] 작은 테스트와 전체 테스트를 실행했다. \ No newline at end of file diff --git a/rules.md b/rules.md new file mode 100644 index 0000000..4934016 --- /dev/null +++ b/rules.md @@ -0,0 +1,373 @@ +# BOP Back-end Agent Rules + +이 문서는 `back-end-proto`에서 작업하는 모든 에이전트와 개발자가 따라야 하는 백엔드 구현 규칙이다. 새 기능을 만들거나 기존 코드를 수정하기 전에 반드시 이 파일을 먼저 읽고, 이 규칙과 충돌하는 구현은 하지 않는다. + +## 1. 작업 흐름 + +- GitHub Issue를 만든 뒤 작업한다. +- 백엔드 작업은 issue 번호가 포함된 브랜치에서 진행한다. +- 구현 후에는 테스트를 직접 실행하고, PR에 실제 검증 결과를 적는다. +- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. + +## 2. 기술 기준 + +- Java 21을 사용한다. +- Spring Boot 3.5.16 기준으로 작성한다. +- Spring Web, Spring Data JPA, Spring Data Redis, Spring Security, Bean Validation을 프로젝트 기본 스택으로 본다. +- Gradle Wrapper(`./gradlew`)를 사용한다. + +```bash +export JAVA_HOME=/home/heeho3/.jdks/temurin-21 +export PATH="$JAVA_HOME/bin:$PATH" +./gradlew test +``` + +## 3. 패키지 구조 + +패키지는 도메인 단위 폴더 구조로 만든다. 다만 도메인 내부는 과하게 DDD식으로 나누지 않고 `controller`, `service`, `repository`, `dto`, `entity` 중심의 단순 구조를 사용한다. + +현재 기준 구조는 다음과 같다. + +```text +com.bop.youthpick +├── global +│ ├── common +│ ├── config +│ └── error +├── admin +│ └── controller +├── policy +│ ├── controller +│ ├── service +│ ├── repository +│ ├── dto +│ └── entity +└── sync + ├── controller + ├── service + ├── repository + ├── dto + └── entity +``` + +새 도메인을 추가할 때는 아래 형태를 따른다. + +```text +com.bop.youthpick.{domain} +├── controller +├── service +├── repository +├── dto +└── entity +``` + +각 패키지 역할은 다음과 같다. + +- `controller`: HTTP Controller와 API 입출력 조립을 담당한다. +- `service`: 비즈니스 흐름, scheduler, lock, 외부 API client, 기술 상세 구현을 둔다. +- `repository`: Spring Data JPA repository interface를 둔다. +- `dto`: 요청/응답 DTO, service 결과 DTO, 외부 API payload DTO를 둔다. +- `entity`: JPA Entity, domain enum을 둔다. +- `global`: 여러 도메인이 공유하는 공통 응답, 설정, 예외 처리만 둔다. + +금지 구조는 다음과 같다. + +```text +com.bop.youthpick.{domain} +├── api +├── application +├── domain +├── infrastructure +└── external +``` + +## 4. Controller 규칙 + +Controller는 얇게 유지한다. HTTP 요청 파싱, Bean Validation, Service 호출, 응답 DTO 변환만 담당한다. 비즈니스 판단, 중복 검사, 외부 API 호출, Entity 상태 변경 로직은 Controller에 두지 않는다. + +```java +@RestController +@RequestMapping("/api/v1/policies") +public class PolicyController { + + private final PolicySearchService policySearchService; + + public PolicyController(PolicySearchService policySearchService) { + this.policySearchService = policySearchService; + } +} +``` + +- 생성자 주입을 사용한다. +- 필드 주입(`@Autowired` field injection)은 금지한다. +- Entity를 API 응답으로 직접 반환하지 않는다. +- 요청 body를 받는 파라미터에는 `@Valid @RequestBody`를 붙인다. + +## 5. DTO 규칙 + +요청 DTO와 응답 DTO는 각 도메인의 `dto` 패키지에 둔다. DTO는 API 계약을 표현하는 객체이며 Entity를 외부에 노출하지 않기 위한 경계다. + +### 5.1 요청 DTO + +- 요청 DTO는 `record`를 사용할 수 있다. 다만 안정적인 field-level validation 응답이 필요한 경우 필드에 Bean Validation annotation을 명확히 붙인다. +- enum 값을 직접 바인딩하면 Jackson deserialization 단계에서 터질 수 있다. 잘못된 enum 값을 `C001` 입력값 오류로 내려야 하면 문자열 필드 + `@Pattern`을 사용한다. +- 모든 사용자 입력 필드는 의도를 드러내는 validation annotation을 붙인다. +- validation message는 한국어로 작성한다. + +```java +public record PolicySyncJobStartRequest( + @Pattern(regexp = "FULL|DELTA", message = "mode는 FULL 또는 DELTA만 허용됩니다.") + String mode +) { + public PolicySyncJobMode normalizedMode() { + if (mode == null || mode.isBlank()) { + return PolicySyncJobMode.FULL; + } + return PolicySyncJobMode.valueOf(mode); + } +} +``` + +요청 DTO에 validation이 없거나 Controller에서 `@Valid` 없이 받는 코드는 금지한다. + +### 5.2 응답 DTO + +- 응답 DTO는 Entity를 받아 정적 팩토리 메서드 `from(...)`으로 생성한다. +- 응답 DTO에서 Entity의 민감 필드, 내부 상태, raw payload, secret 값을 노출하지 않는다. +- 목록 응답은 필요하면 `items`와 `pagination`을 가진 별도 response DTO로 감싼다. + +```java +public record PolicySearchResponse( + List items, + Pagination pagination, + boolean degraded +) { + public static PolicySearchResponse from(PolicySearchResult result) { + return new PolicySearchResponse(result.items(), result.pagination(), result.degraded()); + } +} +``` + +## 6. Validation 규칙 + +- `spring-boot-starter-validation`을 사용한다. +- 문자열 필드는 보통 `@NotBlank`를 사용한다. `@NotNull`만으로는 공백 문자열을 막을 수 없다. +- 길이 제한은 `@Size`를 사용한다. +- 허용값이 제한된 문자열은 `@Pattern`을 사용한다. +- 숫자 범위는 `@Min`, `@Max`, `@Positive`를 사용한다. +- 중첩 DTO는 필드에도 `@Valid`를 붙인다. + +Validation 실패는 `GlobalExceptionHandler`에서 `ErrorCode.INVALID_INPUT_VALUE`(`C001`) 형식으로 통일한다. + +검증 오류 응답 계약은 프론트가 에러코드를 기준으로 메시지를 매핑할 수 있도록 아래 형태를 유지한다. + +```json +{ + "code": "C001", + "message": "입력 형태가 올바르지 않습니다.", + "details": [ + { + "field": "mode", + "value": "INVALID", + "reason": "mode는 FULL 또는 DELTA만 허용됩니다." + } + ] +} +``` + +- Bean Validation 실패(`MethodArgumentNotValidException`, `ConstraintViolationException`)는 백엔드에서 처리하고 HTTP 400 + `C001`로 내려준다. +- 프론트가 `code`를 안정적으로 확인할 수 있도록 validation 오류에서도 항상 `code`, `message`, `details` 필드를 유지한다. +- `details[].reason`에는 사용자에게 보여줄 수 있는 검증 메시지를 넣되, 내부 exception class, SQL, stack trace, secret 값은 넣지 않는다. +- Controller나 Service에서 validation 실패를 문자열 응답으로 직접 만들지 않는다. + +## 7. 예외 처리 규칙 + +공통 예외 처리는 `global.error` 패키지에 둔다. 예외 응답은 참고 레포(`SpringBootStudy`) 방식처럼 도메인별 `ErrorCode` enum을 기준으로 생성한다. + +`ErrorCode`는 각 도메인의 `exception` 패키지에 두고, 도메인별 상태 코드와 사용자 메시지를 함께 관리한다. `global.error`는 공통 응답 생성과 전역 예외 처리만 담당한다. + +```text +global.error +├── CustomException.java +├── ConflictException.java +├── ResourceNotFoundException.java +├── ErrorResponse.java +└── GlobalExceptionHandler.java +``` + +도메인별 `ErrorCode`는 HTTP 상태, 서비스 코드, 사용자 메시지를 함께 가진다. + +```java +public enum PolicyErrorCode { + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), + POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "일치하는 정책이 존재하지 않습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +```text +policy +├── controller +├── service +├── repository +├── dto +└── exception + ├── PolicyErrorCode.java + └── PolicyException.java +``` + +- 비즈니스 예외는 `throw new CustomException(PolicyErrorCode.X)`처럼 해당 도메인의 `ErrorCode`를 사용하거나, 이를 상속한 구체 예외로 던진다. +- 존재하지 않는 리소스는 `ResourceNotFoundException(PolicyErrorCode.X_NOT_FOUND)`를 사용한다. +- 상태 충돌, 중복 실행, 중복 등록 등은 `ConflictException(PolicyErrorCode.X_CONFLICT)`를 사용한다. +- Service에서 `throw new RuntimeException("...")` 형태로 비즈니스 예외를 직접 던지지 않는다. +- Controller에서 `try-catch`로 에러 응답을 만들지 않는다. +- 내부 예외 메시지, SQL, stack trace, secret 값을 응답에 노출하지 않는다. +- validation field error는 `field`, `value`, `reason` detail을 포함한다. +- 프론트가 사용자 메시지를 일관되게 매핑할 수 있도록 모든 예외 응답은 `code`를 필수로 포함한다. +- 새 `ErrorCode`를 추가할 때는 도메인, 코드 체계, 사용자 메시지를 함께 정의하고, 프론트에서 매핑해야 하는 코드인지 PR 본문에 명시한다. +- 같은 오류 상황에 HTTP status만 다르게 내려주거나, 같은 `code`에 서로 다른 의미를 부여하지 않는다. + +## 8. Lombok 규칙 + +백엔드 레포는 Lombok을 사용한다. Lombok은 반복적인 생성자, getter, JPA 기본 생성자 보일러플레이트를 줄이는 용도로만 제한적으로 사용한다. + +- 생성자 주입은 `final` 필드와 `@RequiredArgsConstructor`를 기본으로 사용한다. +- JPA Entity에는 `@Getter`와 `@NoArgsConstructor(access = AccessLevel.PROTECTED)`를 사용할 수 있다. +- 예외 코드 enum, 커스텀 예외처럼 단순 getter만 필요한 타입에는 `@Getter`를 사용할 수 있다. +- DTO는 기존처럼 `record`를 우선 사용한다. DTO에 Lombok class를 도입해야 한다면 validation, 생성자, 불변성을 명확히 유지한다. +- `@Value` 주입 파라미터가 있는 생성자처럼 Lombok 적용이 오히려 의도를 흐리는 경우에는 명시적 생성자를 유지한다. + +다음은 금지한다. + +- `@Data` +- Entity의 `@Setter` +- Entity의 `@AllArgsConstructor` +- 양방향 연관관계 Entity의 무분별한 `@ToString` +- Entity의 무분별한 `@EqualsAndHashCode` +- Entity의 public all-args constructor + +## 9. Entity / JPA 규칙 + +- Entity는 각 도메인의 `entity` 패키지에 둔다. +- Entity 기본 생성자는 JPA용으로만 열고 외부 생성을 제한한다. +- Entity를 API request/response에 직접 사용하지 않는다. +- 상태 변경은 setter 대신 의미 있는 메서드로 표현한다. +- 컬럼 제약은 `@Column(nullable = false, length = ...)`처럼 명시한다. +- Repository는 각 도메인의 `repository` 패키지에 interface로 둔다. +- 조회 결과가 없을 수 있으면 `Optional`를 사용한다. + +## 10. Service 규칙 + +- Service는 각 도메인의 `service` 패키지에 둔다. +- 읽기 메서드는 `@Transactional(readOnly = true)`를 사용한다. +- 생성/수정/삭제/외부 동기화는 명시적으로 `@Transactional`을 사용한다. +- 외부 API 호출, Redis lock, scheduler 등은 application boundary에서 흐름을 명확히 나눈다. +- secret, API key, token 값은 로그에 남기지 않는다. + +## 11. Pageable 페이지네이션 규칙 + +목록 API는 직접 `page`, `size`를 받아 정규화하지 않는다. Spring Data `Pageable`/`Page`를 사용한다. + +Controller 기준: + +```java +@GetMapping +public ApiResponse searchPolicies( + @PageableDefault(size = 20) Pageable pageable +) { + return ApiResponse.ok(...); +} +``` + +Service / Repository 기준: + +```java +@Transactional(readOnly = true) +public Page list(Pageable pageable) { + return repository.findAll(pageable).map(Result::from); +} +``` + +max page size는 코드의 `Math.min`이 아니라 Spring 설정으로 제한한다. + +```yaml +spring: + data: + web: + pageable: + max-page-size: 50 +``` + +금지 패턴: + +```java +@RequestParam(defaultValue = "0") int page +@RequestParam(defaultValue = "20") int size +int normalizedPage = Math.max(page, 0); +int normalizedSize = Math.min(size, 50); +int totalPages = (int) Math.ceil(...); +``` + +## 12. Redis / Lock / Scheduler 규칙 + +- 중복 실행을 막아야 하는 작업은 Redis lock을 사용한다. +- Lock은 owner token과 TTL을 가진다. +- Lock release 시 owner token을 확인한다. +- Scheduler는 기본적으로 비활성화 가능한 설정을 둔다. +- 외부 API sync job은 retry/backoff 설정을 둔다. +- interrupt 발생 시 interrupt status를 복구한다. + +## 13. Docker / 실행 규칙 + +이 레포는 자체 `Dockerfile`과 `docker-compose.yml`을 소유한다. 별도 infra 레포나 루트 통합 Compose를 만들지 않는다. + +```bash +docker compose up -d --build +``` + +Compose에는 백엔드, DB, Redis 실행 기준을 둔다. Docker/Compose 관련 변경 시 로컬에서 가능한 범위로 `docker compose config`, build, runtime smoke를 검증한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. + +## 14. 테스트 규칙 + +- 기능 변경 시 관련 작은 Service 테스트를 먼저 실행하고, 마지막에 전체 테스트를 실행한다. +- Service 비즈니스 규칙은 Service 테스트로 검증한다. +- PR 전 최소 `./gradlew test`를 통과시킨다. + +```bash +export JAVA_HOME=/home/heeho3/.jdks/temurin-21 +export PATH="$JAVA_HOME/bin:$PATH" +./gradlew test +``` + +## 15. API 검증 규칙 + +- 한글 query parameter를 curl로 검증할 때는 raw URL에 직접 넣지 말고 `--data-urlencode`를 사용한다. + +```bash +curl -G http://localhost:8080/api/v1/policies \ + --data-urlencode 'keyword=월세' \ + --data-urlencode 'region=서울' +``` + +- actuator health와 API health를 구분한다. +- Redis나 DB가 꺼져 있으면 actuator health가 DOWN일 수 있다. + +## 16. PR 전 체크리스트 + +- [ ] 도메인 단위 패키지 구조를 지켰다. +- [ ] Controller가 얇게 유지된다. +- [ ] 요청 DTO에 Bean Validation을 적용했다. +- [ ] `@RequestBody`에는 `@Valid`를 붙였다. +- [ ] Entity를 API 응답으로 직접 반환하지 않는다. +- [ ] Service에서 직접 `RuntimeException`을 던지지 않는다. +- [ ] 예외 응답은 `global.error`를 통해 통일된다. +- [ ] 검증 오류는 백엔드에서 `C001` 등 `ErrorCode` 기반 응답으로 처리되며, 프론트가 확인할 `code`가 포함된다. +- [ ] 새 에러코드를 추가했다면 프론트 메시지 매핑 필요 여부를 PR에 적었다. +- [ ] 목록 API는 `Pageable`/`Page`를 사용한다. +- [ ] 민감정보가 로그/응답에 노출되지 않는다. +- [ ] 작은 테스트와 전체 테스트를 실행했다. +- [ ] docs/API/기획 변경이 필요한 경우 docs 레포에 반영했다. diff --git a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..fbb06eb --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java @@ -0,0 +1,19 @@ +package com.bop.youthpick.auth.exception; + +import org.springframework.http.HttpStatus; + +import com.bop.youthpick.global.error.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "로그인이 필요합니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "A002", "아이디 또는 비밀번호가 올바르지 않습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java b/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java index d99bfa9..adbd507 100644 --- a/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java +++ b/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java @@ -1,8 +1,9 @@ package com.bop.youthpick.global.config; +import com.bop.youthpick.auth.exception.AuthErrorCode; import com.bop.youthpick.global.error.ErrorCode; import com.bop.youthpick.global.error.ErrorResponse; -import tools.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -35,7 +36,7 @@ public void commence( HttpServletResponse response, AuthenticationException authException ) throws IOException { - ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + ErrorCode errorCode = AuthErrorCode.UNAUTHORIZED; response.setStatus(errorCode.getStatus().value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 49b397e..bd3577a 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; diff --git a/src/main/java/com/bop/youthpick/global/error/ConflictException.java b/src/main/java/com/bop/youthpick/global/error/ConflictException.java deleted file mode 100644 index 52e622a..0000000 --- a/src/main/java/com/bop/youthpick/global/error/ConflictException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bop.youthpick.global.error; - -/** 상태 충돌, 중복 등록, 중복 실행 등에 사용한다. 예: {@code new ConflictException(ErrorCode.FAVORITE_ALREADY_EXISTS)} */ -public class ConflictException extends CustomException { - - public ConflictException(ErrorCode errorCode) { - super(errorCode); - } -} diff --git a/src/main/java/com/bop/youthpick/global/error/ErrorCode.java b/src/main/java/com/bop/youthpick/global/error/ErrorCode.java index 2c4b4ec..cc2d287 100644 --- a/src/main/java/com/bop/youthpick/global/error/ErrorCode.java +++ b/src/main/java/com/bop/youthpick/global/error/ErrorCode.java @@ -1,43 +1,9 @@ package com.bop.youthpick.global.error; -import lombok.Getter; import org.springframework.http.HttpStatus; -/** - * 비즈니스 에러 코드 카탈로그. - * - *

각 코드는 HTTP 상태 + 서비스 코드(접두어 + 3자리) + 사용자 메시지를 함께 가진다. - * 접두어: 공통 C, 인증 A, 서버 S, 사용자 U, 정책 P, 즐겨찾기 F, 자가진단 D. - * - *

새 에러는 반드시 여기에 추가하고 {@code throw new CustomException(ErrorCode.XXX)} - * (혹은 {@link ResourceNotFoundException}/{@link ConflictException})로 사용한다. - * 컨트롤러/서비스에서 메시지 문자열을 직접 만들지 않는다. - */ -@Getter -public enum ErrorCode { - - - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), - - // --- 인증/인가 --- - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "로그인이 필요합니다."), - INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "A002", "아이디 또는 비밀번호가 올바르지 않습니다."), - - // --- 사용자(user) --- - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), - DUPLICATE_USERNAME(HttpStatus.CONFLICT, "U002", "이미 사용 중인 아이디입니다."), - - // --- 정책(policy) --- - POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "일치하는 정책이 존재하지 않습니다."); - - private final HttpStatus status; - private final String code; // 임의의 코드를 정하세요. - private final String message; - - ErrorCode(HttpStatus status, String code, String message) { - this.status = status; - this.code = code; - this.message = message; - } +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); // 임의의 코드를 정하세요. + String getMessage(); } diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index 8360c42..65eff60 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -2,8 +2,12 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + import java.util.List; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -29,7 +33,7 @@ public ResponseEntity handleMethodArgumentNotValid(MethodArgument )) .toList(); - ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; return ResponseEntity .status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode, details)); @@ -46,7 +50,7 @@ public ResponseEntity handleConstraintViolation(ConstraintViolati )) .toList(); - ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; return ResponseEntity .status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode, details)); @@ -64,7 +68,7 @@ public ResponseEntity handleCustomException(CustomException excep // 개발자가 예상치 못한 에러 @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { - ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + ErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; return ResponseEntity .status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode)); @@ -78,4 +82,15 @@ private String fieldName(ConstraintViolation violation) { } return path.substring(separatorIndex + 1); } + + @RequiredArgsConstructor + @Getter + enum GlobalErrorCode implements ErrorCode { + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."); + + private final HttpStatus status; + private final String code; + private final String message; + } } diff --git a/src/main/java/com/bop/youthpick/global/error/ResourceNotFoundException.java b/src/main/java/com/bop/youthpick/global/error/ResourceNotFoundException.java deleted file mode 100644 index 044a83c..0000000 --- a/src/main/java/com/bop/youthpick/global/error/ResourceNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.bop.youthpick.global.error; - -/** 존재하지 않는 리소스 조회 시 사용한다. 예: {@code new ResourceNotFoundException(ErrorCode.USER_NOT_FOUND)} */ -public class ResourceNotFoundException extends CustomException { - - public ResourceNotFoundException(ErrorCode errorCode) { - super(errorCode); - } -} diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java new file mode 100644 index 0000000..56e0ec3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.policy.exception; + +import org.springframework.http.HttpStatus; + +import com.bop.youthpick.global.error.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PolicyErrorCode implements ErrorCode { + + POLICY_NOT_FOUND("P001", "일치하는 정책이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/bop/youthpick/user/exception/UserError.java b/src/main/java/com/bop/youthpick/user/exception/UserError.java new file mode 100644 index 0000000..2a9805b --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/exception/UserError.java @@ -0,0 +1,18 @@ +package com.bop.youthpick.user.exception; + +import com.bop.youthpick.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserError implements ErrorCode { + // --- 사용자(user) --- + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), + DUPLICATE_USERNAME(HttpStatus.CONFLICT, "U002", "이미 사용 중인 아이디입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/bop/youthpick/user/exception/UserException.java b/src/main/java/com/bop/youthpick/user/exception/UserException.java new file mode 100644 index 0000000..7a28cb8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/exception/UserException.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.user.exception; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.global.error.ErrorCode; + +public class UserException extends CustomException { + + public UserException(ErrorCode errorCode) { + super(errorCode); + } +} From 820acc34aef6dd8f5138c376bfccb2df43088a02 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Wed, 8 Jul 2026 12:11:14 +0900 Subject: [PATCH 03/93] =?UTF-8?q?feat:=20BaseEntity=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0,=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20SQL=20=EC=B6=94=EA=B0=80=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: BaseEntity 및 도메인 엔티티, 스키마 SQL 추가 - global: BaseEntity(createdAt/updatedAt 자동), JpaAuditingConfig - user: User, UserProfile, Role - policy: Policy, Region, PolicyRegion, PolicyApplication(즐겨찾기 통합), ApplicationChecklist - board: Post, Comment(대댓글), Attachment - sync: PolicyBatchHistory - log: AppLog, SearchLog - docs/schema.sql: MySQL 기준 스키마 문서 (연관관계·UNIQUE·인덱스 포함) 검증: ./gradlew test 통과 (H2 create-drop으로 전체 스키마 생성 확인) Co-Authored-By: Claude Fable 5 * docs: 유저 도메인 전 테이블 감사 컬럼(created/updated) 통일 - users·policy_applications·application_checklists·attachments에 updated_at 추가 - user_profiles에 created_at 추가 - BaseEntity 상속 범위와 스키마 문서 1:1 일치 - 불변 데이터(regions·policy_regions·로그·배치이력)는 의도적 제외 유지 Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/schema.sql | 323 ++++++++++++++++++ .../youthpick/board/entity/Attachment.java | 47 +++ .../bop/youthpick/board/entity/Comment.java | 51 +++ .../com/bop/youthpick/board/entity/Post.java | 62 ++++ .../youthpick/board/entity/PostCategory.java | 5 + .../global/config/JpaAuditingConfig.java | 9 + .../youthpick/global/entity/BaseEntity.java | 28 ++ .../com/bop/youthpick/log/entity/AppLog.java | 69 ++++ .../bop/youthpick/log/entity/SearchLog.java | 52 +++ .../policy/entity/ApplicationChecklist.java | 45 +++ .../policy/entity/ApplicationStatus.java | 8 + .../bop/youthpick/policy/entity/Policy.java | 240 +++++++++++++ .../policy/entity/PolicyApplication.java | 61 ++++ .../youthpick/policy/entity/PolicyRegion.java | 42 +++ .../policy/entity/PolicyVisibility.java | 9 + .../bop/youthpick/policy/entity/Region.java | 30 ++ .../bop/youthpick/sync/entity/BatchMode.java | 6 + .../youthpick/sync/entity/BatchStatus.java | 5 + .../sync/entity/PolicyBatchHistory.java | 69 ++++ .../com/bop/youthpick/user/entity/Role.java | 5 + .../com/bop/youthpick/user/entity/User.java | 51 +++ .../youthpick/user/entity/UserProfile.java | 63 ++++ 22 files changed, 1280 insertions(+) create mode 100644 docs/schema.sql create mode 100644 src/main/java/com/bop/youthpick/board/entity/Attachment.java create mode 100644 src/main/java/com/bop/youthpick/board/entity/Comment.java create mode 100644 src/main/java/com/bop/youthpick/board/entity/Post.java create mode 100644 src/main/java/com/bop/youthpick/board/entity/PostCategory.java create mode 100644 src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java create mode 100644 src/main/java/com/bop/youthpick/global/entity/BaseEntity.java create mode 100644 src/main/java/com/bop/youthpick/log/entity/AppLog.java create mode 100644 src/main/java/com/bop/youthpick/log/entity/SearchLog.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/Policy.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/Region.java create mode 100644 src/main/java/com/bop/youthpick/sync/entity/BatchMode.java create mode 100644 src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java create mode 100644 src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/Role.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/User.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/UserProfile.java diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 0000000..a650d99 --- /dev/null +++ b/docs/schema.sql @@ -0,0 +1,323 @@ +-- ============================================================ +-- YouthPick ERD — ERDCloud 정리본 (2026-07-07) +-- 기준: docs/2026-07-03-youthpick-erd.sql + ERDCloud 추가 테이블 +-- +-- ERDCloud 대비 변경: +-- 삭제: CopyOfuser_profiles, CopyOfCopyOfCopyOffavorite_policies, +-- CopyOfpolicy_region(→ regions로 복구), board, Untitled +-- 삭제(설계 결정 2026-07-07): favorite_policies — +-- 정책 신청관리(policy_applications)의 status='INTERESTED'가 +-- 즐겨찾기 역할을 흡수. 별도 테이블 불필요 +-- 복원: policies 영어 물리명, regions, policy_batch_history +-- 정리: 신청관리·게시판·로그 테이블 컬럼명/타입/FK +-- soft delete: 유저 소유 데이터 전부 deleted_at 보유 +-- (로그·배치이력은 의도적으로 없음 — 이력은 삭제하지 않음, +-- policies는 visibility가 그 역할) +-- 감사 컬럼: 유저 도메인 전 테이블 created_at+updated_at 통일 +-- (BaseEntity 상속과 1:1 대응. 예외 = 불변 데이터: +-- regions/policy_regions 없음, 로그는 created_at만, +-- batch_history는 자체 시각) +-- ⚠️팀 결정 필요 표시는 [결정필요]로 검색 +-- ============================================================ + +-- ------------------------------------------------------------ +-- 1. users — 서비스 회원 (소셜 로그인 전용) +-- [결정필요] ERDCloud에서 email·nickname이 user_profiles로 +-- 이동돼 있었음. 온보딩 전 사용자도 표시명이 필요하므로 +-- 원본대로 users에 유지 (중복 배치 금지) +-- ------------------------------------------------------------ +CREATE TABLE users ( + id BIGINT NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL COMMENT 'google | naver | kakao', + provider_id VARCHAR(255) NOT NULL COMMENT 'OAuth subject', + email VARCHAR(255) NULL COMMENT 'provider 미제공 시 NULL', + nickname VARCHAR(100) NULL, + role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT 'USER | ADMIN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT '회원탈퇴 soft delete', + PRIMARY KEY (id), + UNIQUE KEY uk_users_provider (provider, provider_id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '서비스 회원'; + +-- ------------------------------------------------------------ +-- 2. regions — 지역 마스터 +-- ERDCloud의 CopyOfpolicy_region이 이 테이블의 잔해였음. +-- 코드는 VARCHAR(10) — BIGINT면 앞자리 0 소실 + FK 불가 +-- ------------------------------------------------------------ +CREATE TABLE regions ( + code VARCHAR(10) NOT NULL COMMENT '시군구 코드 5자리 (앞 2자리 = 시도)', + sido_name VARCHAR(30) NOT NULL COMMENT '시도명', + name VARCHAR(50) NOT NULL COMMENT '시군구명', + PRIMARY KEY (code) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '지역 마스터 (행안부 법정동코드 시군구 ~250행)'; + +-- ------------------------------------------------------------ +-- 3. user_profiles — 온보딩 프로필 (users와 1:1) +-- ERDCloud의 id2 → user_id로 정정, deleted_at 복원 +-- ------------------------------------------------------------ +CREATE TABLE user_profiles ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + birth_year INT NOT NULL COMMENT '출생연도 (REC 나이 20점)', + region_code VARCHAR(10) NOT NULL COMMENT '거주 시군구코드 (REC 지역 25점)', + employment_status VARCHAR(16) NULL COMMENT 'jobCd 1개 (REC 취업 15점)', + education_level VARCHAR(16) NULL COMMENT 'schoolCd 1개 (REC 학력 10점)', + categories VARCHAR(500) NULL COMMENT '관심분야 콤마목록 (REC 분야 20점)', + keywords VARCHAR(700) NULL COMMENT '관심키워드 콤마목록 (REC 키워드 10점)', + status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT '프로필 삭제 soft delete', + PRIMARY KEY (id), + UNIQUE KEY uk_user_profiles_user (user_id), + CONSTRAINT fk_user_profiles_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_user_profiles_region FOREIGN KEY (region_code) REFERENCES regions (code) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '온보딩 프로필 (점수계산 입력값)'; + +-- ------------------------------------------------------------ +-- 4. policies — 청년정책 (물리명 = 영어, 논리명/설명 = COMMENT) +-- ERDCloud에서 설명문이 물리명 자리에 들어가 있던 것을 복원 +-- ------------------------------------------------------------ +CREATE TABLE policies ( + id BIGINT NOT NULL AUTO_INCREMENT, + policy_no VARCHAR(32) NOT NULL COMMENT 'plcyNo — 온통청년 식별자 (upsert 기준)', + -- 표시/검색 + title VARCHAR(300) NOT NULL COMMENT 'plcyNm — 정책명', + description VARCHAR(2000) NULL COMMENT 'plcyExplnCn — 목록 설명', + support_content TEXT NULL COMMENT 'plcySprtCn — 상세 지원내용', + keywords VARCHAR(500) NULL COMMENT 'plcyKywdNm — 키워드 콤마목록', + category VARCHAR(64) NULL COMMENT 'lclsfNm — 대분류', + middle_category VARCHAR(64) NULL COMMENT 'mclsfNm — 중분류', + organization_name VARCHAR(255) NULL COMMENT 'sprvsnInstCdNm 우선, operInstCdNm fallback', + -- 자격: 자동판정용 + min_age INT NULL COMMENT 'sprtTrgtMinAge — 0/NULL=제한없음', + max_age INT NULL COMMENT 'sprtTrgtMaxAge', + job_codes VARCHAR(255) NULL COMMENT 'jobCd — 콤마 다중', + school_codes VARCHAR(255) NULL COMMENT 'schoolCd — 콤마 다중', + -- 자격: 원문 노출용 + income_condition_code VARCHAR(16) NULL COMMENT 'earnCndSeCd', + income_max_amount INT NULL COMMENT 'earnMaxAmt — 연소득 상한(만원)', + income_etc_content TEXT NULL COMMENT 'earnEtcCn', + marital_status_code VARCHAR(16) NULL COMMENT 'mrgSttsCd', + major_codes VARCHAR(255) NULL COMMENT 'plcyMajorCd — 콤마 다중', + specialization_codes VARCHAR(255) NULL COMMENT 'sbizCd — 콤마 다중', + additional_qualification TEXT NULL COMMENT 'addAplyQlfcCndCn', + participation_restriction TEXT NULL COMMENT 'ptcpPrpTrgtCn', + -- 기간/상태 + application_period_type VARCHAR(16) NULL COMMENT 'aplyPrdSeCd', + application_period_raw VARCHAR(64) NULL COMMENT 'aplyYmd 원문', + application_start_date DATE NULL COMMENT '[파생] 신청 시작일', + application_end_date DATE NULL COMMENT '[파생] 마감일 — D-day 정렬/필터', + business_period_begin DATE NULL COMMENT 'bizPrdBgngYmd', + business_period_end DATE NULL COMMENT 'bizPrdEndYmd', + business_period_etc VARCHAR(64) NULL COMMENT 'bizPrdEtcCn', + -- 규모 + support_scale_count INT NULL COMMENT 'sprtSclCnt — 모집인원', + first_come_first_served BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'sprtArvlSeqYn — 선착순', + -- 링크/신청 + application_url VARCHAR(500) NULL COMMENT 'aplyUrlAddr', + reference_url1 VARCHAR(500) NULL COMMENT 'refUrlAddr1', + reference_url2 VARCHAR(500) NULL COMMENT 'refUrlAddr2', + application_method TEXT NULL COMMENT 'plcyAplyMthdCn', + submission_documents TEXT NULL COMMENT 'sbmsnDcmntCn', + screening_method TEXT NULL COMMENT 'srngMthdCn', + -- 보류 필드 (적재만, 판정 사용 금지) + age_limit_flag VARCHAR(4) NULL COMMENT '[보류] sprtTrgtAgeLmtYn', + income_min_amount INT NULL COMMENT '[보류] earnMinAmt', + support_scale_limit BOOLEAN NULL COMMENT '[보류] sprtSclLmtYn', + operating_institution_name VARCHAR(255) NULL COMMENT '[보류] operInstCdNm', + approval_status_code VARCHAR(16) NULL COMMENT '[보류] plcyAprvSttsCd', + etc_matters TEXT NULL COMMENT '[보류] etcMttrCn', + -- 메타/정렬/운영 + view_count INT NOT NULL DEFAULT 0 COMMENT 'inqCnt — 인기 정렬', + first_registered_at DATETIME NULL COMMENT 'frstRegDt — 최신순 정렬', + last_modified_at DATETIME NULL COMMENT 'lastMdfcnDt — 변경 감지', + visibility VARCHAR(20) NOT NULL DEFAULT 'VISIBLE' COMMENT 'VISIBLE | HIDDEN', + raw_payload LONGTEXT NULL COMMENT 'API 응답 원문 JSON', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_policies_policy_no (policy_no), + KEY idx_policies_application_end (application_end_date), + KEY idx_policies_view_count (view_count), + KEY idx_policies_category (category) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '청년정책 (온통청년 수집·전처리 결과)'; + +-- ------------------------------------------------------------ +-- 5. policy_regions — 정책 적용지역 (N:M) +-- ERDCloud의 `지역pk` BIGINT → region_code VARCHAR(10) 정정 +-- ------------------------------------------------------------ +CREATE TABLE policy_regions ( + id BIGINT NOT NULL AUTO_INCREMENT, + policy_id BIGINT NOT NULL, + region_code VARCHAR(10) NOT NULL COMMENT 'zipCd 분해', + PRIMARY KEY (id), + UNIQUE KEY uk_policy_regions (policy_id, region_code), + KEY idx_policy_regions_region (region_code), + CONSTRAINT fk_policy_regions_policy FOREIGN KEY (policy_id) REFERENCES policies (id), + CONSTRAINT fk_policy_regions_region FOREIGN KEY (region_code) REFERENCES regions (code) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '정책 적용지역'; + +-- ------------------------------------------------------------ +-- 6. policy_applications — 정책 신청관리 (ERDCloud `정책 신청관리`) +-- ID/id/id2 3개 → id + user_id + policy_id로 정리. +-- 기존 favorite_policies 흡수: status='INTERESTED'가 즐겨찾기. +-- UNIQUE(user_id, policy_id) — 같은 정책 중복 등록 방지 +-- (즐겨찾기 시절 UNIQUE의 계승) +-- ------------------------------------------------------------ +CREATE TABLE policy_applications ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + policy_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'INTERESTED' COMMENT 'INTERESTED(관심=구 즐겨찾기) | APPLIED | COMPLETED', + memo TEXT NULL, + end_at DATETIME NULL COMMENT '마감일 (개인 설정)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '상태 전환(관심→신청→완료) 추적', + deleted_at DATETIME NULL COMMENT '관리 해제 = soft delete. 재등록 시 행 재활성화(UNIQUE 충돌 방지)', + PRIMARY KEY (id), + UNIQUE KEY uk_policy_applications_user_policy (user_id, policy_id), + CONSTRAINT fk_policy_applications_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_policy_applications_policy FOREIGN KEY (policy_id) REFERENCES policies (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '사용자별 정책 신청 진행관리 (관심~완료 전 단계, 즐겨찾기 통합)'; + +-- ------------------------------------------------------------ +-- 7. application_checklists — 신청관리 체크리스트 +-- p_id → application_id, status TINYINT → is_checked +-- ------------------------------------------------------------ +CREATE TABLE application_checklists ( + id BIGINT NOT NULL AUTO_INCREMENT, + application_id BIGINT NOT NULL COMMENT 'policy_applications FK', + content TEXT NOT NULL COMMENT '체크 항목 내용', + is_checked BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_application_checklists_app (application_id), + CONSTRAINT fk_application_checklists_app FOREIGN KEY (application_id) REFERENCES policy_applications (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '신청관리별 준비 체크리스트'; + +-- ------------------------------------------------------------ +-- 8. posts — 게시판 (ERDCloud `게시글`) +-- `Key` VARCHAR → policy_id BIGINT NULL(자유글 허용)로 정정 +-- ------------------------------------------------------------ +CREATE TABLE posts ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL COMMENT '글쓴이', + policy_id BIGINT NULL COMMENT '연관 정책 (NULL = 자유글)', + category VARCHAR(20) NOT NULL COMMENT 'QUESTION(질문) | REVIEW(후기) | FREE(자유) — ENUM 대신 VARCHAR(role과 동일 이유)', + title VARCHAR(100) NOT NULL, + content TEXT NOT NULL, + view_count INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_posts_policy (policy_id), + CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_posts_policy FOREIGN KEY (policy_id) REFERENCES policies (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '정책 기반 게시판 글'; + +-- ------------------------------------------------------------ +-- 9. comments — 댓글 (ERDCloud `댓글`) +-- Key → parent_id(대댓글), Field3/4/Field → created/updated/deleted_at +-- ------------------------------------------------------------ +CREATE TABLE comments ( + id BIGINT NOT NULL AUTO_INCREMENT, + post_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + parent_id BIGINT NULL COMMENT '대댓글 부모 (NULL = 최상위)', + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_comments_post (post_id), + CONSTRAINT fk_comments_post FOREIGN KEY (post_id) REFERENCES posts (id), + CONSTRAINT fk_comments_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_comments_parent FOREIGN KEY (parent_id) REFERENCES comments (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '게시글 댓글 (1단 대댓글)'; + +-- ------------------------------------------------------------ +-- 10. attachments — 첨부파일 (ERDCloud `첨부파일`) +-- Key/Key2/Field/Field2/Field3 → 의미 있는 이름으로 +-- [미정] 첨부 대상이 게시글인지 아직 미확정 — 일단 post_id로 +-- 두고, 대상 바뀌면 FK만 교체 +-- ------------------------------------------------------------ +CREATE TABLE attachments ( + id BIGINT NOT NULL AUTO_INCREMENT, + post_id BIGINT NOT NULL, + file_url VARCHAR(500) NOT NULL COMMENT '저장 경로/URL', + file_size BIGINT NULL COMMENT 'byte', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_attachments_post (post_id), + CONSTRAINT fk_attachments_post FOREIGN KEY (post_id) REFERENCES posts (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '게시글 첨부파일'; + +-- ------------------------------------------------------------ +-- 11. policy_batch_history — 배치 작업 이력 (ERDCloud 누락 → 복원) +-- ------------------------------------------------------------ +CREATE TABLE policy_batch_history ( + id BIGINT NOT NULL AUTO_INCREMENT, + mode VARCHAR(20) NOT NULL COMMENT 'FULL | DELTA', + status VARCHAR(20) NOT NULL COMMENT 'REQUESTED | RUNNING | SUCCEEDED | FAILED', + requested_at DATETIME NOT NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + new_count INT NOT NULL DEFAULT 0, + updated_count INT NOT NULL DEFAULT 0, + unchanged_count INT NOT NULL DEFAULT 0, + missing_count INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + failure_message VARCHAR(1000) NULL, + PRIMARY KEY (id), + KEY idx_policy_batch_history_requested (requested_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '정책 수집 배치 이력 (FK 없음 — 독립 이력)'; + +-- ------------------------------------------------------------ +-- 12. app_logs — 앱 에러/요청 로그 (ERDCloud `로그`) +-- VARCHAR 길이 누락 정정, user_id NULL 허용(비로그인/배치), +-- FK 없음 — 로그는 유저 삭제와 무관하게 보존 +-- ------------------------------------------------------------ +CREATE TABLE app_logs ( + id BIGINT NOT NULL AUTO_INCREMENT, + level VARCHAR(10) NOT NULL COMMENT 'INFO | WARN | ERROR', + message TEXT NOT NULL, + trace_id VARCHAR(64) NULL, + method VARCHAR(10) NULL COMMENT 'GET | POST ...', + uri TEXT NULL, + ip VARCHAR(45) NULL COMMENT 'IPv6 최대 45자', + exception_class VARCHAR(255) NULL, + exception_message TEXT NULL, + stack_trace TEXT NULL, + user_id BIGINT NULL COMMENT '비로그인/배치 = NULL. FK 안 걺', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_app_logs_created (created_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '앱 로그 (기한 지나면 삭제)'; + +-- ------------------------------------------------------------ +-- 13. search_logs — 검색 로그 (ERDCloud `검색용 로그`) +-- 개수 VARCHAR → INT, user_id NULL 허용, 물리명 영어로 +-- ------------------------------------------------------------ +CREATE TABLE search_logs ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NULL COMMENT '비로그인 NULL. FK 안 걺', + query VARCHAR(200) NOT NULL COMMENT '입력 원문', + normalized VARCHAR(200) NOT NULL COMMENT '소문자·공백정리 — 집계용', + result_count INT NOT NULL COMMENT '검색 결과 건수 (0 = 0건 검색)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_search_logs_created (created_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '검색 로그 (집계 후 원본 정리)'; + +-- ============================================================ +-- [결정필요] 원본에 있었으나 이 정리본에 미포함: +-- policy_read_states — 읽음 상태 (원본에서도 "팀 논의 중"). +-- 포함하려면 docs/2026-07-03-youthpick-erd.sql §6 참조. +-- ============================================================ diff --git a/src/main/java/com/bop/youthpick/board/entity/Attachment.java b/src/main/java/com/bop/youthpick/board/entity/Attachment.java new file mode 100644 index 0000000..7322876 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/entity/Attachment.java @@ -0,0 +1,47 @@ +package com.bop.youthpick.board.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 게시글 첨부파일. + * [미정] 첨부 대상이 게시글로 확정되지 않음 — 대상 바뀌면 FK만 교체. + */ +@Entity +@Table(name = "attachments", + indexes = @Index(name = "idx_attachments_post", columnList = "post_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Attachment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(name = "file_url", length = 500, nullable = false) + private String fileUrl; + + /** byte */ + @Column(name = "file_size") + private Long fileSize; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/bop/youthpick/board/entity/Comment.java b/src/main/java/com/bop/youthpick/board/entity/Comment.java new file mode 100644 index 0000000..4c92c87 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/entity/Comment.java @@ -0,0 +1,51 @@ +package com.bop.youthpick.board.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 게시글 댓글. parent가 NULL이면 최상위, 있으면 대댓글(1단). + */ +@Entity +@Table(name = "comments", + indexes = @Index(name = "idx_comments_post", columnList = "post_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/bop/youthpick/board/entity/Post.java b/src/main/java/com/bop/youthpick/board/entity/Post.java new file mode 100644 index 0000000..dbdf46b --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/entity/Post.java @@ -0,0 +1,62 @@ +package com.bop.youthpick.board.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 정책 기반 게시판 글. policy가 NULL이면 자유글. + * 정책 행을 재활용하지 않고 FK로 참조만 — 정책(배치가 주인)과 + * 게시글(유저가 주인)은 수명이 다르므로. + */ +@Entity +@Table(name = "posts", + indexes = @Index(name = "idx_posts_policy", columnList = "policy_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "policy_id") + private Policy policy; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private PostCategory category; + + @Column(length = 100, nullable = false) + private String title; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(name = "view_count", nullable = false) + private int viewCount; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/bop/youthpick/board/entity/PostCategory.java b/src/main/java/com/bop/youthpick/board/entity/PostCategory.java new file mode 100644 index 0000000..1895c21 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/entity/PostCategory.java @@ -0,0 +1,5 @@ +package com.bop.youthpick.board.entity; + +public enum PostCategory { + QUESTION, REVIEW, FREE +} diff --git a/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java b/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..d794b62 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java b/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java new file mode 100644 index 0000000..132dccd --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 생성/수정 시각 공통 필드. soft delete(deletedAt)는 테이블마다 유무가 달라 + * 각 엔티티가 직접 선언한다. + */ +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/bop/youthpick/log/entity/AppLog.java b/src/main/java/com/bop/youthpick/log/entity/AppLog.java new file mode 100644 index 0000000..dc7a814 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/entity/AppLog.java @@ -0,0 +1,69 @@ +package com.bop.youthpick.log.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 앱 에러/요청 로그. userId는 연관관계 없이 값만 저장 — + * 로그는 유저 삭제와 무관하게 보존하고, 비로그인/배치는 NULL. + */ +@Entity +@Table(name = "app_logs", + indexes = @Index(name = "idx_app_logs_created", columnList = "created_at")) +@EntityListeners(AuditingEntityListener.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AppLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** INFO | WARN | ERROR */ + @Column(length = 10, nullable = false) + private String level; + + @Column(columnDefinition = "TEXT", nullable = false) + private String message; + + @Column(name = "trace_id", length = 64) + private String traceId; + + @Column(length = 10) + private String method; + + @Column(columnDefinition = "TEXT") + private String uri; + + /** IPv6 최대 45자 */ + @Column(length = 45) + private String ip; + + @Column(name = "exception_class", length = 255) + private String exceptionClass; + + @Column(name = "exception_message", columnDefinition = "TEXT") + private String exceptionMessage; + + @Column(name = "stack_trace", columnDefinition = "TEXT") + private String stackTrace; + + @Column(name = "user_id") + private Long userId; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/bop/youthpick/log/entity/SearchLog.java b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java new file mode 100644 index 0000000..d088720 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java @@ -0,0 +1,52 @@ +package com.bop.youthpick.log.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 검색 로그 (0건율·인기검색어 집계 원본. 집계 후 기한 지나면 삭제). + * userId는 연관관계 없이 값만 — 비로그인 검색은 NULL. + */ +@Entity +@Table(name = "search_logs", + indexes = @Index(name = "idx_search_logs_created", columnList = "created_at")) +@EntityListeners(AuditingEntityListener.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SearchLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + /** 입력 원문 */ + @Column(length = 200, nullable = false) + private String query; + + /** 소문자·공백정리 표준형 — 집계는 이 컬럼으로 */ + @Column(length = 200, nullable = false) + private String normalized; + + /** 검색 결과 건수. 0 = 0건 검색(오타보정 발동 지표) */ + @Column(name = "result_count", nullable = false) + private int resultCount; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java new file mode 100644 index 0000000..0c37772 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java @@ -0,0 +1,45 @@ +package com.bop.youthpick.policy.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신청관리별 준비 체크리스트 (제출서류 등). + */ +@Entity +@Table(name = "application_checklists", + indexes = @Index(name = "idx_application_checklists_app", columnList = "application_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicationChecklist extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id", nullable = false) + private PolicyApplication application; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(name = "is_checked", nullable = false) + private boolean checked; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java new file mode 100644 index 0000000..a393232 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java @@ -0,0 +1,8 @@ +package com.bop.youthpick.policy.entity; + +/** + * 정책 신청관리 상태. INTERESTED(관심)가 구 즐겨찾기 역할. + */ +public enum ApplicationStatus { + INTERESTED, APPLIED, COMPLETED +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java new file mode 100644 index 0000000..2b07970 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -0,0 +1,240 @@ +package com.bop.youthpick.policy.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 청년정책 (온통청년 API 수집·전처리 결과). + * 각 필드 주석의 코드는 API 원본 키(plcyNo 등) — 매핑표 역할. + * [보류] 필드는 원천 데이터 품질 문제로 판정 로직 사용 금지, 적재만 한다. + */ +@Entity +@Table(name = "policies", + uniqueConstraints = @UniqueConstraint(name = "uk_policies_policy_no", columnNames = "policy_no"), + indexes = { + @Index(name = "idx_policies_application_end", columnList = "application_end_date"), + @Index(name = "idx_policies_view_count", columnList = "view_count"), + @Index(name = "idx_policies_category", columnList = "category") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Policy extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** plcyNo — 온통청년 식별자 (upsert 기준) */ + @Column(name = "policy_no", length = 32, nullable = false) + private String policyNo; + + // ---- 표시/검색 ---- + + /** plcyNm */ + @Column(length = 300, nullable = false) + private String title; + + /** plcyExplnCn */ + @Column(length = 2000) + private String description; + + /** plcySprtCn */ + @Column(name = "support_content", columnDefinition = "TEXT") + private String supportContent; + + /** plcyKywdNm — 콤마목록 */ + @Column(length = 500) + private String keywords; + + /** lclsfNm — 대분류 */ + @Column(length = 64) + private String category; + + /** mclsfNm — 중분류 */ + @Column(name = "middle_category", length = 64) + private String middleCategory; + + /** sprvsnInstCdNm 우선, operInstCdNm fallback */ + @Column(name = "organization_name", length = 255) + private String organizationName; + + // ---- 자격: 자동판정용 ---- + + /** sprtTrgtMinAge — 0/NULL=제한없음 */ + @Column(name = "min_age") + private Integer minAge; + + /** sprtTrgtMaxAge */ + @Column(name = "max_age") + private Integer maxAge; + + /** jobCd — 콤마 다중 */ + @Column(name = "job_codes", length = 255) + private String jobCodes; + + /** schoolCd — 콤마 다중 */ + @Column(name = "school_codes", length = 255) + private String schoolCodes; + + // ---- 자격: 원문 노출용 ---- + + /** earnCndSeCd */ + @Column(name = "income_condition_code", length = 16) + private String incomeConditionCode; + + /** earnMaxAmt — 연소득 상한(만원) */ + @Column(name = "income_max_amount") + private Integer incomeMaxAmount; + + /** earnEtcCn */ + @Column(name = "income_etc_content", columnDefinition = "TEXT") + private String incomeEtcContent; + + /** mrgSttsCd */ + @Column(name = "marital_status_code", length = 16) + private String maritalStatusCode; + + /** plcyMajorCd — 콤마 다중 */ + @Column(name = "major_codes", length = 255) + private String majorCodes; + + /** sbizCd — 콤마 다중 */ + @Column(name = "specialization_codes", length = 255) + private String specializationCodes; + + /** addAplyQlfcCndCn */ + @Column(name = "additional_qualification", columnDefinition = "TEXT") + private String additionalQualification; + + /** ptcpPrpTrgtCn */ + @Column(name = "participation_restriction", columnDefinition = "TEXT") + private String participationRestriction; + + // ---- 기간/상태 ---- + + /** aplyPrdSeCd */ + @Column(name = "application_period_type", length = 16) + private String applicationPeriodType; + + /** aplyYmd 원문 */ + @Column(name = "application_period_raw", length = 64) + private String applicationPeriodRaw; + + /** [파생] 신청 시작일 */ + @Column(name = "application_start_date") + private LocalDate applicationStartDate; + + /** [파생] 마감일 — D-day 정렬/필터 */ + @Column(name = "application_end_date") + private LocalDate applicationEndDate; + + /** bizPrdBgngYmd */ + @Column(name = "business_period_begin") + private LocalDate businessPeriodBegin; + + /** bizPrdEndYmd */ + @Column(name = "business_period_end") + private LocalDate businessPeriodEnd; + + /** bizPrdEtcCn */ + @Column(name = "business_period_etc", length = 64) + private String businessPeriodEtc; + + // ---- 규모 ---- + + /** sprtSclCnt — 모집인원 */ + @Column(name = "support_scale_count") + private Integer supportScaleCount; + + /** sprtArvlSeqYn — 선착순 */ + @Column(name = "first_come_first_served", nullable = false) + private boolean firstComeFirstServed; + + // ---- 링크/신청 ---- + + /** aplyUrlAddr */ + @Column(name = "application_url", length = 500) + private String applicationUrl; + + /** refUrlAddr1 */ + @Column(name = "reference_url1", length = 500) + private String referenceUrl1; + + /** refUrlAddr2 */ + @Column(name = "reference_url2", length = 500) + private String referenceUrl2; + + /** plcyAplyMthdCn */ + @Column(name = "application_method", columnDefinition = "TEXT") + private String applicationMethod; + + /** sbmsnDcmntCn */ + @Column(name = "submission_documents", columnDefinition = "TEXT") + private String submissionDocuments; + + /** srngMthdCn */ + @Column(name = "screening_method", columnDefinition = "TEXT") + private String screeningMethod; + + // ---- 보류 필드 (적재만, 판정 사용 금지) ---- + + /** [보류] sprtTrgtAgeLmtYn — 본문과 모순 사례. 판정은 min/maxAge만 */ + @Column(name = "age_limit_flag", length = 4) + private String ageLimitFlag; + + /** [보류] earnMinAmt — 실데이터 거의 0 */ + @Column(name = "income_min_amount") + private Integer incomeMinAmount; + + /** [보류] sprtSclLmtYn */ + @Column(name = "support_scale_limit") + private Boolean supportScaleLimit; + + /** [보류] operInstCdNm */ + @Column(name = "operating_institution_name", length = 255) + private String operatingInstitutionName; + + /** [보류] plcyAprvSttsCd */ + @Column(name = "approval_status_code", length = 16) + private String approvalStatusCode; + + /** [보류] etcMttrCn */ + @Column(name = "etc_matters", columnDefinition = "TEXT") + private String etcMatters; + + // ---- 메타/정렬/운영 ---- + + /** inqCnt — 인기 정렬 */ + @Column(name = "view_count", nullable = false) + private int viewCount; + + /** frstRegDt — 최신순 정렬 */ + @Column(name = "first_registered_at") + private LocalDateTime firstRegisteredAt; + + /** lastMdfcnDt — 변경 감지(upsert 비교) */ + @Column(name = "last_modified_at") + private LocalDateTime lastModifiedAt; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private PolicyVisibility visibility = PolicyVisibility.VISIBLE; + + /** API 응답 원문 JSON — 스키마 진화 시 백필용 보험 */ + @Column(name = "raw_payload", columnDefinition = "LONGTEXT") + private String rawPayload; +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java new file mode 100644 index 0000000..365ee01 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java @@ -0,0 +1,61 @@ +package com.bop.youthpick.policy.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 사용자별 정책 신청 진행관리 (관심 → 신청 → 완료). + * 기존 favorite_policies를 흡수 — status=INTERESTED가 즐겨찾기. + * UNIQUE(user, policy)로 같은 정책 중복 등록 방지. + */ +@Entity +@Table(name = "policy_applications", uniqueConstraints = + @UniqueConstraint(name = "uk_policy_applications_user_policy", + columnNames = {"user_id", "policy_id"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PolicyApplication extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "policy_id", nullable = false) + private Policy policy; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private ApplicationStatus status = ApplicationStatus.INTERESTED; + + @Column(columnDefinition = "TEXT") + private String memo; + + /** 개인이 설정하는 마감일 */ + @Column(name = "end_at") + private LocalDateTime endAt; + + /** 관리 해제 = soft delete. 재등록 시 행 재활성화(UNIQUE 충돌 방지) */ + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java new file mode 100644 index 0000000..4e00d31 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java @@ -0,0 +1,42 @@ +package com.bop.youthpick.policy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 정책 적용지역 (zipCd 콤마목록 → 행 단위 정규화). + * 배치가 통째로 갈아끼우는 데이터라 타임스탬프/soft delete 없음. + */ +@Entity +@Table(name = "policy_regions", + uniqueConstraints = @UniqueConstraint(name = "uk_policy_regions", + columnNames = {"policy_id", "region_code"}), + indexes = @Index(name = "idx_policy_regions_region", columnList = "region_code")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PolicyRegion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "policy_id", nullable = false) + private Policy policy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_code", nullable = false) + private Region region; +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java new file mode 100644 index 0000000..cbfdb20 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.policy.entity; + +/** + * 정책 노출 상태. deleted_at 대신 쓰는 이유: + * 배치가 원본 소실 정책을 숨겼다가 재등장 시 되살리는 "상태 전환"이라서. + */ +public enum PolicyVisibility { + VISIBLE, HIDDEN +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/Region.java b/src/main/java/com/bop/youthpick/policy/entity/Region.java new file mode 100644 index 0000000..c468a6a --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/Region.java @@ -0,0 +1,30 @@ +package com.bop.youthpick.policy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 지역(시군구) 마스터. 행안부 법정동코드 ~250행 1회 적재. + * 코드는 국가 표준 불변값이라 대리키 없이 code가 PK (통화코드 KRW와 같은 급). + */ +@Entity +@Table(name = "regions") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Region { + + @Id + @Column(length = 10) + private String code; + + @Column(name = "sido_name", length = 30, nullable = false) + private String sidoName; + + @Column(length = 50, nullable = false) + private String name; +} diff --git a/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java b/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java new file mode 100644 index 0000000..0139d1c --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java @@ -0,0 +1,6 @@ +package com.bop.youthpick.sync.entity; + +/** FULL = 전량 수집(기본, missing 감지 가능) / DELTA = 변경분만 */ +public enum BatchMode { + FULL, DELTA +} diff --git a/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java b/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java new file mode 100644 index 0000000..651cefa --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java @@ -0,0 +1,5 @@ +package com.bop.youthpick.sync.entity; + +public enum BatchStatus { + REQUESTED, RUNNING, SUCCEEDED, FAILED +} diff --git a/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java new file mode 100644 index 0000000..a5f3e6d --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java @@ -0,0 +1,69 @@ +package com.bop.youthpick.sync.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 정책 수집 배치 작업 이력 + 실행 요청서(REQUESTED→RUNNING→...). + * 자체 시각(requested/started/finished)을 쓰므로 BaseEntity 미상속. + * 이력은 삭제하지 않는다 — soft delete 없음. + */ +@Entity +@Table(name = "policy_batch_history", + indexes = @Index(name = "idx_policy_batch_history_requested", columnList = "requested_at")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PolicyBatchHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private BatchMode mode; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private BatchStatus status; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "started_at") + private LocalDateTime startedAt; + + @Column(name = "finished_at") + private LocalDateTime finishedAt; + + @Column(name = "new_count", nullable = false) + private int newCount; + + @Column(name = "updated_count", nullable = false) + private int updatedCount; + + @Column(name = "unchanged_count", nullable = false) + private int unchangedCount; + + /** API에서 사라진 정책 수 → visibility=HIDDEN 처리 (정상 결과) */ + @Column(name = "missing_count", nullable = false) + private int missingCount; + + /** 처리 실패로 건너뛴 정책 수 (실패 격리 — 상세는 경고 로그의 plcyNo) */ + @Column(name = "error_count", nullable = false) + private int errorCount; + + @Column(name = "failure_message", length = 1000) + private String failureMessage; +} diff --git a/src/main/java/com/bop/youthpick/user/entity/Role.java b/src/main/java/com/bop/youthpick/user/entity/Role.java new file mode 100644 index 0000000..e4a5f84 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/Role.java @@ -0,0 +1,5 @@ +package com.bop.youthpick.user.entity; + +public enum Role { + USER, ADMIN +} diff --git a/src/main/java/com/bop/youthpick/user/entity/User.java b/src/main/java/com/bop/youthpick/user/entity/User.java new file mode 100644 index 0000000..29ce1bc --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/User.java @@ -0,0 +1,51 @@ +package com.bop.youthpick.user.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 서비스 회원 (소셜 로그인 전용). + * 식별은 (provider, providerId) — email은 카카오 미제공/미보유 가능이라 NULL 허용. + */ +@Entity +@Table(name = "users", uniqueConstraints = + @UniqueConstraint(name = "uk_users_provider", columnNames = {"provider", "provider_id"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 20, nullable = false) + private String provider; + + @Column(name = "provider_id", length = 255, nullable = false) + private String providerId; + + @Column(length = 255) + private String email; + + @Column(length = 100) + private String nickname; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private Role role = Role.USER; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java new file mode 100644 index 0000000..d014da4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -0,0 +1,63 @@ +package com.bop.youthpick.user.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.policy.entity.Region; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 온보딩 프로필 (users와 1:1). 맞춤정책 점수계산(REC 6축)의 입력값. + */ +@Entity +@Table(name = "user_profiles", uniqueConstraints = + @UniqueConstraint(name = "uk_user_profiles_user", columnNames = "user_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserProfile extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "birth_year", nullable = false) + private Integer birthYear; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "region_code", nullable = false) + private Region region; + + @Column(name = "employment_status", length = 16) + private String employmentStatus; + + @Column(name = "education_level", length = 16) + private String educationLevel; + + @Column(length = 500) + private String categories; + + @Column(length = 700) + private String keywords; + + @Column(length = 20, nullable = false) + private String status = "COMPLETED"; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; +} From 684b38945aaf1fbb72774e3dc90ba4fcb73efd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Wed, 8 Jul 2026 12:44:30 +0900 Subject: [PATCH 04/93] =?UTF-8?q?chore:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- .coderabbit.yaml | 100 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..c92bad4 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,100 @@ +# CodeRabbit 설정 파일 +# 스키마: https://storage.googleapis.com/coderabbit_public_assets/schema.v2.json +# 문서: https://docs.coderabbit.ai/getting-started/configure-coderabbit +# +# 이 레포의 리뷰 기준은 docs/rules.md · docs/git-convention.md 를 따른다. + +language: "ko-KR" + +tone_instructions: >- + 한국어로 정중하지만 간결하게 리뷰한다. docs/rules.md의 규칙 위반은 명확히 지적하고, + 단순 취향 차이는 nitpick으로 구분한다. + +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + + auto_review: + enabled: true + drafts: false + base_branches: + - "main" + - "dev" + + path_filters: + - "!build/**" + - "!gradle/wrapper/**" + - "!**/*.jar" + + path_instructions: + - path: "src/main/java/**" + instructions: >- + 이 레포의 백엔드 구현 규칙(docs/rules.md)을 기준으로 리뷰한다. + 패키지는 com.bop.youthpick.{domain}.controller/service/repository/dto/entity 의 + 도메인 우선 단순 구조를 유지해야 하며, DDD식 api/application/domain/infrastructure/external + 패키지 생성은 규칙 위반이다. + Controller: 얇게 유지(파싱·검증·Service 호출·DTO 변환만), 생성자 주입(@RequiredArgsConstructor + final)만 허용, + 필드 주입(@Autowired) 금지, JPA Entity를 API 응답으로 직접 반환 금지, + @RequestBody 파라미터에는 @Valid 필수, 성공 응답은 global.common.ApiResponse로 감싼다. + 페이지네이션 등 부가정보는 data가 아닌 meta에 담고, 목록 API는 Spring Data Pageable/Page를 사용한다. + page/size를 @RequestParam으로 직접 받거나 totalPages를 직접 계산하는 코드는 규칙 위반이다. + 예외: 비즈니스 예외는 global.error의 ErrorCode + CustomException(또는 ResourceNotFoundException, + ConflictException)으로 던진다. raw RuntimeException을 던지거나 Controller에서 try-catch로 + 에러 응답을 만드는 것은 금지. 에러 응답에 내부 예외 메시지/SQL/stack trace/secret 노출 금지. + ErrorCode 서비스 코드는 접두어(C/A/S/U/P/F/D) + 3자리 규칙을 따른다. + + - path: "src/main/java/**/entity/**" + instructions: >- + JPA Entity 규칙: @NoArgsConstructor(access = AccessLevel.PROTECTED) 사용, + @Setter/@Data/@AllArgsConstructor(및 public all-args 생성자) 금지, + 상태 변경은 setter 대신 의미 있는 메서드로 표현, + 컬럼 제약은 @Column(nullable = false, length = ...)처럼 명시한다. + 양방향 연관관계에서 무분별한 @ToString/@EqualsAndHashCode는 지적한다. + + - path: "src/main/java/**/dto/**" + instructions: >- + DTO는 record를 우선 사용한다. 요청 DTO의 모든 사용자 입력 필드에는 Bean Validation + annotation(@NotBlank/@Size/@Pattern/@Min/@Max 등)이 있어야 하며 validation message는 + 한국어로 작성한다. 문자열 필수 필드는 @NotNull이 아닌 @NotBlank를 사용한다. + 중첩 DTO 필드에는 @Valid를 붙인다. 응답 DTO는 Entity를 받는 정적 팩토리 메서드 + from(...)으로 생성하고, Entity의 민감 필드/내부 상태/secret 값을 노출하지 않는다. + + - path: "src/main/java/**/service/**" + instructions: >- + 읽기 메서드는 @Transactional(readOnly = true), 생성/수정/삭제/외부 동기화는 명시적 + @Transactional을 사용한다. secret, API key, OAuth token 값을 로그에 남기지 않는다. + 비즈니스 예외를 raw RuntimeException으로 던지지 않는다. + + - path: "src/test/java/**" + instructions: >- + Controller validation은 MockMvc 테스트, Service 비즈니스 규칙은 Service 테스트, + Repository/JPA 쿼리는 통합 테스트(H2 create-drop 프로파일)로 검증하는지 확인한다. + 기능 변경 PR에 대응하는 테스트가 없으면 지적한다. + + - path: "src/main/resources/**" + instructions: >- + 설정 파일에 secret/OAuth client secret/token 값이 하드코딩되어 있으면 반드시 지적한다. + secret은 환경변수로 주입해야 한다. 운영 프로파일에서 ddl-auto가 create/update로 + 설정되어 있으면 지적한다. + + - path: "docker-compose.yml" + instructions: >- + 이 레포는 앱을 컨테이너화하지 않는다. compose는 로컬 인프라(Redis, 필요 시 MySQL)만 + 띄운다. 앱 서비스를 compose에 추가하는 변경은 규칙 위반이다. + + labeling_instructions: + - label: "feat" + instructions: "신규 기능 추가 PR" + - label: "fix" + instructions: "버그 수정 PR" + - label: "docs" + instructions: "문서 변경 PR" + - label: "refac" + instructions: "동작 변경 없는 구조 정리 PR" + +chat: + auto_reply: true From 48d4648773b3068669662907be79ebc117a99ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Wed, 8 Jul 2026 12:47:00 +0900 Subject: [PATCH 05/93] =?UTF-8?q?chore:=20Claude=20Code=20=ED=95=98?= =?UTF-8?q?=EB=84=A4=EC=8A=A4=20=EC=9E=AC=EA=B5=AC=EC=84=B1=20(.claude/rul?= =?UTF-8?q?es=20+=20=EC=8A=A4=ED=82=AC/=EC=BB=A4=EB=A7=A8=EB=93=9C/?= =?UTF-8?q?=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8/=ED=9B=85)=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Claude Code 하네스 재구성 (.claude/rules + 스킬/커맨드/에이전트/훅/워크플로우) 규칙을 자동 로드되는 .claude/rules/로 이전하고 에이전트 하네스를 구성한다. - 규칙: 낡은 사본(루트 rules.md, docs/rules.md, docs/git-convention.md) 삭제, 실제 코드 기준으로 갱신해 주제별 .claude/rules/ 10개로 분리 (paths frontmatter로 경로 스코프 적용) - 진입점: AGENTS.md 신규(공통 진입점), CLAUDE.md는 AGENTS.md import로 축소 - 스킬: security-review(보안 점검 + 체크리스트) - 커맨드: fix-issue 슬래시 커맨드 - 에이전트: code-reviewer(리뷰 전용, 수정 안 함) - 훅: format-on-save(java Spotless), block-secrets(위험 삭제/시크릿 파일 차단) + settings.json 연결 - 출력 스타일: teaching - 워크플로우: pr-review.js(관점별 병렬 리뷰 후 검증) - .mcp.json, .worktreeinclude, agent-memory 시드 추가 - gradlew 실행 권한 복구, gitignore에 로컬 설정 파일 추가 Co-Authored-By: Claude Fable 5 * fix: CodeRabbit 리뷰 반영 (하네스 최소권한·훅 견고화·규칙 보강) PR #12 CodeRabbit 지적 4건 반영, 1건은 오탐으로 스킵. - fix-issue 커맨드: allowed-tools를 필요한 하위 명령으로 축소(최소권한). Bash(git:*)/Bash(gh:*) 광범위 허용 제거. - block-secrets 훅: 정규식 우회 차단. python3+shlex 토큰 파싱으로 재작성해 분리 플래그(-r -f)·롱옵션(--recursive --force)·sudo 접두·와일드카드 대상을 탐지. 시크릿 파일/스테이징 검사도 서브명령 단위로 판별. - error-handling 규칙: errors[].value에 민감 입력(비밀번호·토큰) 반사 금지 가이드 추가. - pr-review.js top-level return 지적은 스킵: Workflow 하네스가 본문을 async로 래핑하는 스크립트라 top-level return/await가 정상. 일반 ESM으로 오판한 오탐. Co-Authored-By: Claude Fable 5 * fix: block-secrets 훅에서 git 전역 옵션 뒤 서브명령 판별 PR #12 CodeRabbit 추가 지적 반영. args[0]=="add"만 보면 `git -C add .` / `git -c k=v add .env` 처럼 전역 옵션이 서브명령 앞에 오는 호출에서 .env 차단 로직이 우회됐다. 값 받는 전역 옵션(-C/-c/--git-dir 등)을 먼저 건너뛰고 서브명령을 판별하도록 수정. 우회/정상 케이스 재검증 완료. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .claude/agent-memory/code-reviewer/MEMORY.md | 13 + .claude/agents/code-reviewer.md | 30 ++ .claude/commands/fix-issue.md | 15 + .claude/hooks/block-secrets.sh | 130 +++++++ .claude/hooks/format-on-save.sh | 39 ++ .claude/output-styles/teaching.md | 12 + .claude/rules/api-design.md | 68 ++++ .claude/rules/architecture.md | 34 ++ .claude/rules/auth-security.md | 17 + .claude/rules/entity-jpa.md | 14 + .claude/rules/error-handling.md | 64 ++++ .claude/rules/infra.md | 33 ++ .claude/rules/lombok.md | 22 ++ .claude/rules/service.md | 18 + .claude/rules/testing.md | 12 + .claude/rules/workflow.md | 36 ++ .claude/settings.json | 39 ++ .claude/skills/security-review/SKILL.md | 24 ++ .claude/skills/security-review/checklist.md | 32 ++ .claude/workflows/pr-review.js | 92 +++++ .gitignore | 4 + .mcp.json | 3 + .worktreeinclude | 5 + AGENTS.md | 55 +++ CLAUDE.md | 60 +-- docs/README.md | 42 +- docs/git-convention.md | 90 ----- docs/rules.md | 383 ------------------- gradlew | 0 rules.md | 373 ------------------ 30 files changed, 841 insertions(+), 918 deletions(-) create mode 100644 .claude/agent-memory/code-reviewer/MEMORY.md create mode 100644 .claude/agents/code-reviewer.md create mode 100644 .claude/commands/fix-issue.md create mode 100755 .claude/hooks/block-secrets.sh create mode 100755 .claude/hooks/format-on-save.sh create mode 100644 .claude/output-styles/teaching.md create mode 100644 .claude/rules/api-design.md create mode 100644 .claude/rules/architecture.md create mode 100644 .claude/rules/auth-security.md create mode 100644 .claude/rules/entity-jpa.md create mode 100644 .claude/rules/error-handling.md create mode 100644 .claude/rules/infra.md create mode 100644 .claude/rules/lombok.md create mode 100644 .claude/rules/service.md create mode 100644 .claude/rules/testing.md create mode 100644 .claude/rules/workflow.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/security-review/SKILL.md create mode 100644 .claude/skills/security-review/checklist.md create mode 100644 .claude/workflows/pr-review.js create mode 100644 .mcp.json create mode 100644 .worktreeinclude create mode 100644 AGENTS.md delete mode 100644 docs/git-convention.md delete mode 100644 docs/rules.md mode change 100644 => 100755 gradlew delete mode 100644 rules.md diff --git a/.claude/agent-memory/code-reviewer/MEMORY.md b/.claude/agent-memory/code-reviewer/MEMORY.md new file mode 100644 index 0000000..ed713d3 --- /dev/null +++ b/.claude/agent-memory/code-reviewer/MEMORY.md @@ -0,0 +1,13 @@ +# code-reviewer 메모리 (인덱스) + +이 파일은 code-reviewer 에이전트가 리뷰를 반복하며 축적하는 학습의 인덱스다. 상세 노트는 같은 디렉토리의 주제별 파일로 분리한다. + +## 프로젝트 리뷰 기준 (요약) + +- 규칙 원본은 `.claude/rules/`. 리뷰 지적은 항상 규칙 문서 근거와 함께 남긴다. +- 에러코드는 `ErrorCode` 인터페이스 + `{domain}.exception.{Domain}ErrorCode` enum 구조. 접두어 체계(C/A/S/U/P/F/D)를 지키는지 본다. +- 성공 응답은 `ApiResponse`(data+meta), 에러는 `ErrorResponse`(code 기반). 페이지 정보는 `meta`에. + +## 반복 발견 패턴 + +(아직 없음 — 리뷰를 진행하며 자주 나오는 위반/오탐 패턴을 여기에 축적한다.) diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..5da5a7e --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,30 @@ +--- +name: code-reviewer +description: YouthPick 백엔드 코드 리뷰 전용 에이전트. 규칙 위반·버그·보안 관점으로 검토하고 보고만 한다. 코드를 수정하지 않는다. PR 전이나 변경분 리뷰가 필요할 때 사용. +tools: Read, Grep, Glob, Bash +model: sonnet +--- + +너는 YouthPick 백엔드 코드 리뷰어다. **코드를 수정하지 않는다.** 발견 사항을 심각도순으로 보고만 한다. + +## 기준 문서 + +리뷰 전에 관련 규칙을 읽는다: [`.claude/rules/`](../rules/) 의 `architecture.md`, `api-design.md`, `error-handling.md`, `entity-jpa.md`, `service.md`, `lombok.md`, `auth-security.md`, `testing.md`. + +## 리뷰 관점 (순서대로) + +1. **규칙 위반** — 패키지 구조, 얇은 Controller, `@Valid` 누락, Entity 직접 반환, `ApiResponse` 미사용, raw `RuntimeException`, Pageable 미사용, 금지 Lombok. +2. **버그/정확성** — 트랜잭션 경계, null/Optional 처리, 경계 조건, 동시성. +3. **보안** — secret/토큰 노출, 인가 누락(IDOR), 입력 검증 우회. (깊은 점검은 `security-review` 스킬) +4. **테스트** — A(정상)/E(예외)/X(경계) 관점 누락 여부. + +## 출력 형식 + +각 발견 사항을 다음으로 보고한다: + +- **심각도**: critical / high / medium / low +- **위치**: `파일:라인` +- **문제**: 무엇이 왜 잘못됐는지 한 문장 +- **제안**: 어떻게 고칠지 + +확신이 없으면 "가능성"으로 표시하고 근거를 남긴다. 근거 없는 확정 지적을 하지 않는다. 발견이 없으면 없다고 명확히 말한다. diff --git a/.claude/commands/fix-issue.md b/.claude/commands/fix-issue.md new file mode 100644 index 0000000..940827a --- /dev/null +++ b/.claude/commands/fix-issue.md @@ -0,0 +1,15 @@ +--- +description: GitHub 이슈를 Git 컨벤션대로 브랜치 생성부터 검증까지 처리한다 +argument-hint: <이슈번호> +allowed-tools: Bash(git checkout:*), Bash(git switch:*), Bash(git branch:*), Bash(git add:*), Bash(git commit:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(gh issue view:*), Bash(gh pr create:*), Bash(./gradlew test:*), Read, Edit, Write, Grep, Glob +--- + +이슈 #$1 을 이 레포의 Git 컨벤션([.claude/rules/workflow.md](../rules/workflow.md))에 따라 처리한다. + +1. `gh issue view $1` 로 이슈 내용·작업 범위·검증 기준을 파악한다. +2. 기본 분기(`dev` 있으면 `dev`, 없으면 `main`)에서 `type/$1-{short-name}` 브랜치를 만든다. type은 이슈 성격에 맞춰 `feat|fix|docs|refac` 중 고른다. +3. 관련 규칙 파일(`.claude/rules/`)을 먼저 읽고, 규칙을 지키며 구현한다. +4. 관련 작은 테스트 → 전체 `./gradlew test` 순으로 검증한다. (JAVA_HOME 필요 시 `CLAUDE.local.md` 참고) +5. `type: subject` 커밋을 만들고, 변경 내용·검증 결과·`Closes #$1` 을 담은 PR 본문 초안을 제시한다. + +파괴적이거나 이슈 범위를 벗어나는 결정이 필요하면 진행 전에 사용자에게 확인한다. diff --git a/.claude/hooks/block-secrets.sh b/.claude/hooks/block-secrets.sh new file mode 100755 index 0000000..c800316 --- /dev/null +++ b/.claude/hooks/block-secrets.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) 훅: 위험한 파괴 명령과 시크릿 파일 접근을 차단한다. +# 차단 시 exit code 2 + stderr 사유. Claude Code가 사유를 읽고 명령을 막는다. +# +# 정규식만으로는 `rm -r -f`, `rm --recursive --force`, `git add .` 같은 우회가 +# 쉬우므로, python3 + shlex로 토큰을 파싱해 플래그/대상/서브명령을 판별한다. +# 프로그램은 -c 인자로 넘겨 stdin(훅 JSON)을 그대로 python에 전달한다. + +set -uo pipefail + +exec python3 -c ' +import json, sys, shlex, os, re, subprocess + +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) + +cmd = (data.get("tool_input", {}) or {}).get("command", "") or "" +if not cmd.strip(): + sys.exit(0) + +def block(msg): + sys.stderr.write("block-secrets: 차단됨 — " + msg + "\n") + sys.exit(2) + +segments = re.split(r"[;\n]|&&|\|\||\||&", cmd) + +BROAD_TARGETS = {"/", "~", "$HOME", ".", "./", "*", "./*", "~/", "/*", ".."} +ENV_RE = re.compile(r"^\.env(\..+)?$") +ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + +def is_env_file(arg): + base = os.path.basename(arg) + return bool(ENV_RE.fullmatch(base)) and base != ".env.example" + +def dangerous_target(t): + tt = t.rstrip("/") + return ( + t in BROAD_TARGETS + or tt in ("", "/", "~", "$HOME", ".", "..") + or t.startswith(("/", "~", "$HOME")) + or "*" in t + ) + +for seg in segments: + try: + tokens = shlex.split(seg) + except ValueError: + tokens = seg.split() + if not tokens: + continue + + idx = 0 + while idx < len(tokens): + t = tokens[idx] + if t in ("sudo", "env") or ASSIGN_RE.match(t): + idx += 1 + continue + break + if idx >= len(tokens): + continue + + name = os.path.basename(tokens[idx]) + args = tokens[idx + 1:] + + # 1) 파괴적 rm (플래그 결합/분리/롱옵션 모두 탐지) + if name == "rm": + recursive = force = False + targets = [] + for a in args: + if a == "--": + continue + if a == "--recursive": + recursive = True + elif a == "--force": + force = True + elif a.startswith("--"): + pass + elif a.startswith("-") and len(a) > 1: + flags = a[1:] + if "r" in flags or "R" in flags: + recursive = True + if "f" in flags: + force = True + else: + targets.append(a) + if recursive and any(dangerous_target(x) for x in targets): + block("위험한 rm -r 대상(루트/홈/현재 디렉토리/와일드카드). 삭제 대상을 구체적 경로로 좁혀라.") + + # 2) 시크릿 파일(.env) 내용 노출 + if name in ("cat", "less", "more", "head", "tail", "cp", "scp", + "curl", "nc", "xxd", "base64", "strings", "bat", "grep"): + if any(is_env_file(a) for a in args if not a.startswith("-")): + block("시크릿 파일(.env) 내용 노출 시도. .env는 커밋·출력·전송하지 않는다.") + + # 3) git add 로 .env 스테이징 (명시적/광범위/force 모두 고려) + if name == "git": + # git 전역 옵션(-C , -c , --git-dir 등)을 먼저 건너뛰고 + # 실제 서브명령을 찾는다. 값을 따로 받는 옵션은 다음 토큰까지 스킵. + # (git -C x add ., git -c k=v add .env 우회 방지) + VALUE_OPTS = ("-C", "-c", "--git-dir", "--work-tree", "--namespace", "--super-prefix") + gi = 0 + while gi < len(args) and args[gi].startswith("-"): + gi += 2 if args[gi] in VALUE_OPTS else 1 + subcmd = args[gi] if gi < len(args) else None + add_args = args[gi + 1:] + + if subcmd == "add": + positionals = [a for a in add_args if not a.startswith("-")] + force_add = any(a in ("-f", "--force") for a in add_args) + explicit_env = any(is_env_file(a) for a in positionals) + broad = any(a in (".", "./", "-A", "--all", "-u", "--update", "*") for a in add_args) + + if explicit_env: + block(".env를 git에 추가하려는 시도. .env는 커밋 금지.") + if broad or force_add: + status_cmd = ["git", "status", "--porcelain"] + if force_add: + status_cmd.append("--ignored") + status_cmd += ["--", ".env"] + try: + out = subprocess.run(status_cmd, capture_output=True, text=True, timeout=5) + if out.stdout.strip(): + block(".env가 스테이징 대상에 포함될 수 있음. .env는 커밋 금지.") + except Exception: + pass + +sys.exit(0) +' diff --git a/.claude/hooks/format-on-save.sh b/.claude/hooks/format-on-save.sh new file mode 100755 index 0000000..1ea71bb --- /dev/null +++ b/.claude/hooks/format-on-save.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# PostToolUse(Edit|Write) 훅: .java 파일을 저장하면 Spotless로 포맷한다. +# Spotless가 build.gradle에 없으면 조용히 통과한다(no-op). +# 훅 stdin은 Claude Code가 주는 JSON. tool_input.file_path 를 뽑는다. + +set -euo pipefail + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +file_path="$(python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) +except Exception: + print(""); sys.exit(0) +ti = data.get("tool_input", {}) or {} +print(ti.get("file_path", "") or "") +' 2>/dev/null || true)" + +# .java 파일이 아니면 아무것도 하지 않는다. +case "$file_path" in + *.java) ;; + *) exit 0 ;; +esac + +# Spotless 미설정이면 no-op. +if ! grep -q "spotless" "$PROJECT_DIR/build.gradle" 2>/dev/null; then + exit 0 +fi + +cd "$PROJECT_DIR" +# JAVA_HOME이 없으면 gradle이 실패하므로, 있을 때만 시도한다. +if [ -z "${JAVA_HOME:-}" ] && ! command -v java >/dev/null 2>&1; then + echo "format-on-save: JAVA_HOME 미설정으로 스킵" >&2 + exit 0 +fi + +./gradlew spotlessApply "-PspotlessFiles=$(printf '%s' "$file_path" | sed 's/[.[\*^$/]/\\&/g')" >/dev/null 2>&1 || true +exit 0 diff --git a/.claude/output-styles/teaching.md b/.claude/output-styles/teaching.md new file mode 100644 index 0000000..cfd8069 --- /dev/null +++ b/.claude/output-styles/teaching.md @@ -0,0 +1,12 @@ +--- +name: Teaching +description: 변경한 코드에 왜 그렇게 했는지 설명을 덧붙이는 말투. 학습·온보딩용. +--- + +너는 평소처럼 YouthPick 백엔드 작업을 수행하되, 결과에 **왜 그렇게 했는지**를 함께 설명한다. 신입 개발자가 옆에서 배우는 상황을 가정한다. + +- 코드를 바꾼 뒤에는 "무엇을 바꿨는지"뿐 아니라 "왜 이 방식인지"를 한두 문장으로 덧붙인다. 특히 이 레포의 규칙(`.claude/rules/`)과 연결해 설명한다. 예: "요청 DTO에 `@Valid`를 붙인 이유는 검증 실패를 `GlobalExceptionHandler`가 `C001`로 통일해 내려주기 때문이다." +- 대안이 있었다면 왜 그 대안을 택하지 않았는지 짧게 언급한다. +- Spring/JPA 관용구가 처음 등장하면 한 줄로 개념을 풀어준다. 단, 이미 다룬 개념을 반복 설명하지 않는다. +- 설명은 **간결하게**. 코드보다 설명이 길어지지 않게 한다. 장황한 이론 강의가 아니라 "이 결정의 이유"에 집중한다. +- 실제 동작·검증(테스트 결과)은 여전히 사실대로 보고한다. 설명 말투가 결과 왜곡의 핑계가 되지 않는다. diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md new file mode 100644 index 0000000..91c0bd0 --- /dev/null +++ b/.claude/rules/api-design.md @@ -0,0 +1,68 @@ +--- +paths: + - "src/main/java/**" +--- + +# Controller / DTO / 응답 규칙 + +## Controller + +- 생성자 주입(`@RequiredArgsConstructor` + `final` 필드). 필드 주입(`@Autowired`) 금지. +- Entity를 API 응답으로 직접 반환하지 않는다. +- 요청 body 파라미터에는 `@Valid @RequestBody`를 붙인다. validation 없는 요청 DTO, `@Valid` 없는 바인딩은 금지. +- 성공 응답은 항상 `global.common.ApiResponse`로 감싼다. + +## 요청 DTO (`{domain}.dto`) + +- `record`를 우선 사용한다. 모든 사용자 입력 필드에 의도를 드러내는 Bean Validation annotation을 붙인다. +- validation message는 한국어로 작성한다. +- 문자열은 보통 `@NotBlank`(`@NotNull`은 공백을 못 막는다), 길이는 `@Size`, 허용값 문자열은 `@Pattern`, 숫자 범위는 `@Min`/`@Max`/`@Positive`. 중첩 DTO 필드에는 `@Valid`. +- enum을 직접 바인딩하면 Jackson deserialization 단계에서 터진다. 잘못된 enum 값을 `C001` 입력값 오류로 내려야 하면 문자열 필드 + `@Pattern`을 사용한다. + +```java +public record UserSignupRequest( + @NotBlank(message = "아이디는 필수입니다.") + @Size(min = 4, max = 20, message = "아이디는 4~20자여야 합니다.") + String username, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} +``` + +## 응답 DTO + +- Entity를 받아 정적 팩토리 메서드 `from(...)`으로 생성한다. +- Entity의 민감 필드, 내부 상태, raw payload, secret 값을 노출하지 않는다. + +```java +public record PolicyCardResponse(Long id, String title, String summary) { + public static PolicyCardResponse from(Policy policy) { + return new PolicyCardResponse(policy.getId(), policy.getTitle(), policy.getSummary()); + } +} +``` + +## 공통 응답 봉투 `ApiResponse` (data + meta) + +- 단일/객체 데이터는 `ApiResponse.ok(data)`. 페이지 등 부가정보(`page`, `totalCount` 등)는 **`data`가 아니라 `meta`에** 담는다. +- 200 외 상태 코드가 필요하면 `ResponseEntity>`로 감싼다. +- 에러 응답은 `ApiResponse`가 아니라 `global.error.ErrorResponse`로 내려간다 (→ `error-handling.md`). + +## 페이지네이션 — Spring Data `Pageable`/`Page` + +```java +@GetMapping +public ApiResponse> search(@PageableDefault(size = 20) Pageable pageable) { + Page page = policySearchService.search(pageable); + return ApiResponse.ok(page.getContent(), Map.of( + "page", page.getNumber(), + "totalCount", page.getTotalElements(), + "totalPages", page.getTotalPages() + )); +} +``` + +- max page size는 코드의 `Math.min`이 아니라 설정(`spring.data.web.pageable.max-page-size`)으로 제한한다. +- 금지: `@RequestParam int page/size` 직접 수신, `Math.min(size, 50)` 정규화, `totalPages` 직접 계산. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 0000000..ba20b1a --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,34 @@ +--- +paths: + - "src/main/java/**" +--- + +# 패키지 구조 + +도메인 우선 + 단순 구조를 유지한다. 패키지 루트는 `com.bop.youthpick`. + +```text +com.bop.youthpick +├── global +│ ├── common // ApiResponse 등 공통 응답 봉투 +│ ├── config // SecurityConfig, RestAuthenticationEntryPoint 등 +│ └── error // ErrorCode(인터페이스), CustomException, ErrorResponse, GlobalExceptionHandler +└── {domain} // user, policy, auth, favorite(즐겨찾기), diagnosis(자가진단) ... + ├── controller // HTTP Controller와 API 입출력 조립 + ├── service // 비즈니스 흐름, scheduler, lock, 외부 API client + ├── repository // Spring Data JPA repository interface + ├── dto // 요청/응답 DTO, service 결과 DTO, 외부 API payload DTO + ├── entity // JPA Entity, domain enum + └── exception // {Domain}ErrorCode(ErrorCode 구현 enum), {Domain}Exception(CustomException 상속) +``` + +- `global`에는 여러 도메인이 공유하는 공통 응답·설정·예외 처리만 둔다. +- 도메인에 아직 필요 없는 하위 패키지는 만들지 않는다. +- Controller는 얇게: HTTP 파싱, Bean Validation, Service 호출, 응답 DTO 변환만. 비즈니스 판단·중복 검사·외부 API 호출·Entity 상태 변경은 Service에 둔다. + +**금지 구조** — DDD식 패키지를 만들지 않는다: + +```text +com.bop.youthpick.{domain} +├── api / application / domain / infrastructure / external // ❌ +``` diff --git a/.claude/rules/auth-security.md b/.claude/rules/auth-security.md new file mode 100644 index 0000000..9fa508f --- /dev/null +++ b/.claude/rules/auth-security.md @@ -0,0 +1,17 @@ +--- +paths: + - "src/main/java/**" + - "src/main/resources/**" + - ".env.example" +--- + +# 인증 / 세션 / Redis / Secret + +Spring Security **세션 기반 인증** + Spring Session(**Redis 저장소**) + OAuth 소셜 로그인(Google / Naver / Kakao) 구조다. + +- 세션은 Spring Session을 통해 Redis에 저장한다(`spring-session-data-redis`). in-memory 세션에 의존하는 코드를 만들지 않는다. +- 인증 실패(미인증 접근)는 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려준다. +- 인가 규칙(경로별 권한)은 `SecurityConfig`에서 관리하고 Controller에 흩뿌리지 않는다. +- OAuth client id/secret 등 secret 값은 하드코딩하지 않고 환경변수(`GOOGLE_OAUTH_CLIENT_ID` 등)로 주입한다. 새 환경변수는 `.env.example`에 placeholder로 추가한다. +- secret / token 값을 코드·로그·응답 어디에도 노출하지 않는다. `.env`는 커밋하지 않는다. +- 중복 실행을 막아야 하는 배치/스케줄 작업을 도입하면 Redis lock(owner token + TTL, release 시 owner 확인)을 사용하고, scheduler는 설정으로 on/off 가능하게 둔다. diff --git a/.claude/rules/entity-jpa.md b/.claude/rules/entity-jpa.md new file mode 100644 index 0000000..702dba3 --- /dev/null +++ b/.claude/rules/entity-jpa.md @@ -0,0 +1,14 @@ +--- +paths: + - "src/main/java/**" +--- + +# Entity / JPA / Repository + +- Entity는 각 도메인의 `entity` 패키지에 둔다. +- 기본 생성자는 JPA용으로만 연다: `@NoArgsConstructor(access = AccessLevel.PROTECTED)`. +- Entity를 API request/response에 직접 사용하지 않는다. +- 상태 변경은 setter 대신 의미 있는 메서드로 표현한다. +- 컬럼 제약은 `@Column(nullable = false, length = ...)`처럼 명시한다. +- Repository는 각 도메인의 `repository` 패키지에 interface로 둔다. 조회 결과가 없을 수 있으면 `Optional`. +- `ddl-auto`: 로컬 `update`, 테스트 `create-drop`. 운영에서 `create`/`update`를 무분별하게 쓰지 않는다. diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md new file mode 100644 index 0000000..280a02b --- /dev/null +++ b/.claude/rules/error-handling.md @@ -0,0 +1,64 @@ +--- +paths: + - "src/main/java/**" +--- + +# 예외 처리 + +공통 기반은 `global.error`, 도메인별 에러코드·예외는 `{domain}.exception`에 둔다. + +## 구조 + +- `global.error.ErrorCode` — **인터페이스** (`getStatus()`, `getCode()`, `getMessage()`) +- `global.error.CustomException` — `ErrorCode`를 담는 비즈니스 예외 베이스 +- `global.error.ErrorResponse` — 공통 에러 응답 (`status`, `code`, `message`, `errors[]`, `timestamp`) +- `global.error.GlobalExceptionHandler` — 모든 컨트롤러 예외를 `ErrorResponse`로 변환. validation 실패는 `C001`, 예상 못 한 예외는 `S001`로 처리한다. +- `{domain}.exception.{Domain}ErrorCode` — `ErrorCode`를 구현하는 enum (예: `AuthErrorCode`, `PolicyErrorCode`) +- `{domain}.exception.{Domain}Exception` — 필요 시 `CustomException`을 상속한 도메인 예외 (예: `UserException`) + +```java +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), + DUPLICATE_USERNAME(HttpStatus.CONFLICT, "U002", "이미 사용 중인 아이디입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +## 서비스 코드 규칙 — 접두어 + 3자리 + +| 접두어 | 영역 | | 접두어 | 영역 | +|--------|------|-|--------|------| +| `C` | 공통(common) | | `U` | 사용자(user) | +| `A` | 인증/인가(auth) | | `P` | 정책(policy) | +| `S` | 서버(server) | | `F` | 즐겨찾기(favorite) | +| | | | `D` | 자가진단(diagnosis) | + +## 규칙 + +- 비즈니스 예외는 `throw new CustomException(에러코드)` 또는 도메인 예외(`UserException` 등)로 던진다. raw `RuntimeException` 금지. +- Controller에서 `try-catch`로 에러 응답을 직접 만들지 않는다. `GlobalExceptionHandler`에 위임한다. +- 내부 예외 메시지/SQL/stack trace/secret 값을 응답에 노출하지 않는다. `errors[].reason`에는 사용자에게 보여줄 수 있는 검증 메시지만 넣는다. +- `errors[].value`에는 거절된 입력값(rejectedValue)이 담긴다. 비밀번호·토큰 등 **민감 필드는 `value`를 비우거나 마스킹한다.** 현재 `GlobalExceptionHandler`는 rejectedValue를 그대로 담으므로, 민감 입력을 검증하는 DTO는 응답에 값이 반사되지 않도록 처리한다. +- 새 에러코드는 접두어 체계를 지키고 사용자 메시지를 함께 정의하며, 프론트 매핑 필요 여부를 PR 본문에 명시한다. +- 같은 오류에 HTTP status만 다르게 내리거나, 같은 `code`에 다른 의미를 부여하지 않는다. + +## 에러 응답 형식 + +프론트가 `code` 기준으로 메시지를 매핑한다. validation 실패(HTTP 400 + `C001`) 예시: + +```json +{ + "status": 400, + "code": "C001", + "message": "입력 형태가 올바르지 않습니다.", + "errors": [ + { "field": "username", "value": "", "reason": "아이디는 필수입니다." } + ], + "timestamp": "2026-06-30T14:13:40.005" +} +``` diff --git a/.claude/rules/infra.md b/.claude/rules/infra.md new file mode 100644 index 0000000..6f7de38 --- /dev/null +++ b/.claude/rules/infra.md @@ -0,0 +1,33 @@ +--- +paths: + - "docker-compose.yml" + - "src/main/resources/**" + - ".env.example" + - "build.gradle" +--- + +# 실행 / Docker / API 수동 검증 + +- **앱(백엔드)은 컨테이너화하지 않는다.** 로컬 IDE / Gradle로 실행하고, `docker-compose.yml`은 로컬 인프라(Redis, 필요 시 MySQL)만 띄운다. + +```bash +docker compose up -d # Redis (필요 시 MySQL) 기동 +docker compose ps +docker compose down # 중지 (볼륨 보존) +docker compose down -v # 중지 + 볼륨 삭제(초기화) +``` + +- 로컬 기본 DB는 H2 in-memory이므로 MySQL 없이도 앱이 뜬다. MySQL로 붙일 때만 compose의 MySQL 서비스와 관련 환경변수를 활성화한다. +- Compose/인프라 변경 시 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. Docker가 없으면 PR 검증 결과에 명시한다. + +## API 수동 검증 + +- 한글 query parameter는 raw URL에 넣지 말고 `--data-urlencode`를 사용한다: + +```bash +curl -G http://localhost:8080/api/v1/policies \ + --data-urlencode 'keyword=월세' \ + --data-urlencode 'region=서울' +``` + +- actuator health(`/actuator/health`)와 API health를 구분한다. Redis나 DB가 꺼져 있으면 actuator health가 DOWN일 수 있다. diff --git a/.claude/rules/lombok.md b/.claude/rules/lombok.md new file mode 100644 index 0000000..b3bbcda --- /dev/null +++ b/.claude/rules/lombok.md @@ -0,0 +1,22 @@ +--- +paths: + - "src/main/java/**" +--- + +# Lombok + +보일러플레이트 축소 용도로만 제한적으로 사용한다. + +**허용** + +- `@RequiredArgsConstructor` + `final` 필드 (생성자 주입 기본형) +- JPA Entity의 `@Getter`, `@NoArgsConstructor(access = AccessLevel.PROTECTED)` +- 에러코드 enum, 커스텀 예외처럼 단순 getter만 필요한 타입의 `@Getter` +- DTO는 Lombok 대신 `record`를 우선 사용한다. + +**금지** + +- `@Data` +- Entity의 `@Setter` +- Entity의 `@AllArgsConstructor` / public all-args constructor +- 양방향 연관관계 Entity의 무분별한 `@ToString` / `@EqualsAndHashCode` diff --git a/.claude/rules/service.md b/.claude/rules/service.md new file mode 100644 index 0000000..3da651f --- /dev/null +++ b/.claude/rules/service.md @@ -0,0 +1,18 @@ +--- +paths: + - "src/main/java/**" +--- + +# Service 계층 + +- Service는 각 도메인의 `service` 패키지에 둔다. 비즈니스 판단, 중복 검사, 외부 API 호출, Entity 상태 변경은 Controller가 아니라 여기에 둔다. +- 읽기 메서드는 `@Transactional(readOnly = true)`, 생성/수정/삭제/외부 동기화는 명시적 `@Transactional`. +- 외부 API 호출, scheduler 등은 흐름을 명확히 나눈다. +- secret, API key, OAuth token 값은 로그에 남기지 않는다. + +```java +@Transactional(readOnly = true) +public Page search(Pageable pageable) { + return repository.findAll(pageable).map(PolicyCardResponse::from); +} +``` diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..2317d41 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,12 @@ +--- +paths: + - "src/test/**" +--- + +# 테스트 + +- 기능 변경 시 관련 작은 테스트를 먼저 실행하고, 마지막에 전체 테스트(`./gradlew test`)를 실행한다. +- Controller validation은 MockMvc 테스트로 검증한다. +- Service 비즈니스 규칙은 Service 테스트로 검증한다. +- Repository/JPA 쿼리는 통합 테스트로 검증한다(테스트 프로파일은 H2 `create-drop`). +- A(정상) / E(예외) / X(경계·비정상) 관점을 모두 다룬다. diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md new file mode 100644 index 0000000..9c45454 --- /dev/null +++ b/.claude/rules/workflow.md @@ -0,0 +1,36 @@ +# 작업 흐름 / Git 컨벤션 + +```text +GitHub Issue 생성 → 브랜치 생성 → 작업/커밋 → PR 생성 → 리뷰/검증 확인 → merge +``` + +- `main`에 직접 커밋하지 않는다. 이슈 없이 임의 브랜치에서 작업하지 않는다. +- 기획 변경이나 구현 계획이 필요한 작업은 먼저 계획을 정리하고 관련 GitHub Issue를 만든 뒤 작업한다. +- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. + +## 브랜치 + +- `main`: 배포 가능한 안정 버전. `dev`: 팀 통합 브랜치(있으면 `dev`에서 분기, 없으면 `main`). +- 네이밍: `feat|fix|docs|refac/{issue-number}-{short-name}` — 예: `feat/12-social-login`, `fix/18-search-empty-state` + +## 커밋 + +`type: subject` 형식. 타입: `feat`(기능) · `fix`(버그) · `docs`(문서) · `refac`(구조 개선) · `test`(테스트) · `chore`(설정/빌드). + +```text +feat: 소셜 로그인 콜백 API 추가 +refac: 정책 검색 서비스 책임 분리 +``` + +## PR / 리뷰 / Merge + +- PR 본문([템플릿](../../.github/PULL_REQUEST_TEMPLATE.md)): 변경 내용 · 실제 검증 결과(실행한 명령·확인 내용) · `Closes #이슈번호` +- 새 `ErrorCode`를 추가했다면 프론트 메시지 매핑 필요 여부를 PR에 명시한다. +- 리뷰 관점: User Story/AC 일치, A(정상)/E(예외)/X(경계) 케이스, 권한·빈 상태·오류·외부 장애 처리, 민감정보 노출 여부. +- 최소 1인 리뷰 후 merge. 작은 기능 단위로 PR을 나누고, merge 후 작업 브랜치는 삭제한다. + +## 완료 전 검증 + +- 기능 변경 시 관련 작은 테스트를 먼저 실행하고, 마지막에 전체 테스트를 실행한다. +- `./gradlew test`(Windows: `.\gradlew.bat test`)를 통과시킨 뒤에만 완료를 주장한다. +- Docker/Compose 관련 변경 시 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c575ce1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,39 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew test:*)", + "Bash(./gradlew build:*)", + "Bash(./gradlew compileJava:*)", + "Bash(./gradlew compileTestJava:*)", + "Bash(./gradlew spotlessApply:*)", + "Bash(docker compose config:*)", + "Bash(docker compose ps:*)", + "Bash(git diff:*)", + "Bash(gh issue view:*)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-on-save.sh" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-secrets.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/security-review/SKILL.md b/.claude/skills/security-review/SKILL.md new file mode 100644 index 0000000..62ec5b2 --- /dev/null +++ b/.claude/skills/security-review/SKILL.md @@ -0,0 +1,24 @@ +--- +name: security-review +description: YouthPick 백엔드 변경분의 보안 점검. 인증/세션/OAuth, secret 노출, 입력 검증, 인가, 민감정보 응답 노출을 검토한다. PR 전 또는 auth/user/secret 관련 코드를 만졌을 때 사용. +--- + +# Security Review + +YouthPick 백엔드(세션 기반 인증 + OAuth 소셜 로그인 + Redis 세션) 변경분을 보안 관점에서 점검한다. + +## 절차 + +1. 리뷰 범위를 정한다. 기본은 현재 브랜치의 diff: + ```bash + git diff --merge-base main + ``` +2. [`checklist.md`](./checklist.md)의 각 항목을 diff에 대입해 점검한다. +3. 발견 항목을 **심각도(critical / high / medium / low)** 순으로 정리한다. 각 항목에 `파일:라인`, 무엇이 문제인지, 어떻게 고칠지를 적는다. +4. 확실하지 않은 지적은 "가능성"으로 표시하고 근거를 남긴다. 추측을 확정처럼 말하지 않는다. + +## 원칙 + +- secret / OAuth client secret / 세션 토큰 값이 코드·로그·응답·예외 메시지에 노출되는지 최우선으로 본다. +- 규칙 근거는 [`.claude/rules/auth-security.md`](../../rules/auth-security.md), [`.claude/rules/error-handling.md`](../../rules/error-handling.md)와 일치시킨다. +- 이 스킬은 **점검·보고만** 한다. 수정이 필요하면 항목을 제시하고, 실제 수정은 사용자 승인 후 진행한다. diff --git a/.claude/skills/security-review/checklist.md b/.claude/skills/security-review/checklist.md new file mode 100644 index 0000000..c5e734d --- /dev/null +++ b/.claude/skills/security-review/checklist.md @@ -0,0 +1,32 @@ +# 보안 점검 체크리스트 (YouthPick 백엔드) + +## 인증 / 세션 / OAuth + +- [ ] 미인증 접근이 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려가는가. +- [ ] 세션이 Spring Session(Redis)에 저장되는가. in-memory 세션에 의존하는 코드가 새로 생기지 않았는가. +- [ ] 인가 규칙(경로별 권한)이 `SecurityConfig`에 모여 있는가. Controller에 `if (권한)` 식으로 흩뿌려지지 않았는가. +- [ ] 로그인/회원가입에서 비밀번호가 평문 저장·로그되지 않는가. 인증 실패 메시지가 아이디 존재 여부를 흘리지 않는가(`A002`로 통일). +- [ ] OAuth 콜백에서 state/redirect_uri 검증이 있는가. 외부에서 받은 값을 검증 없이 신뢰하지 않는가. + +## Secret / 민감정보 + +- [ ] OAuth client id/secret 등이 하드코딩되지 않고 환경변수로 주입되는가. 새 secret은 `.env.example`에 placeholder만 추가됐는가. +- [ ] secret / token / 세션 값이 로그(`log.info` 등)·응답 DTO·예외 메시지에 노출되지 않는가. +- [ ] `.env`가 커밋 대상에 포함되지 않았는가. +- [ ] 응답 DTO가 Entity의 민감 필드(비밀번호 해시, 내부 상태, raw OAuth payload)를 노출하지 않는가. + +## 입력 검증 / 주입 + +- [ ] 모든 요청 DTO 필드에 Bean Validation이 붙어 있고 Controller가 `@Valid @RequestBody`로 받는가. +- [ ] JPA 쿼리에 사용자 입력을 문자열 연결로 넣지 않는가(파라미터 바인딩 사용). +- [ ] 한글/특수문자 파라미터 처리에서 인코딩 문제로 검증이 우회되지 않는가. + +## 예외 / 정보 노출 + +- [ ] 예외 응답이 `GlobalExceptionHandler`를 거쳐 `ErrorResponse`로 통일되는가. stack trace/SQL/내부 클래스명이 응답에 노출되지 않는가. +- [ ] 예상 못 한 예외가 `S001`로 뭉뚱그려지고 상세가 클라이언트로 새지 않는가. + +## 인가 / 소유권 + +- [ ] 즐겨찾기·자가진단 등 사용자 소유 리소스 조회/수정 시 "본인 것인지" 확인하는가(IDOR 방지). +- [ ] 관리자/일반 사용자 권한 경계가 명확한가. diff --git a/.claude/workflows/pr-review.js b/.claude/workflows/pr-review.js new file mode 100644 index 0000000..c242135 --- /dev/null +++ b/.claude/workflows/pr-review.js @@ -0,0 +1,92 @@ +export const meta = { + name: 'pr-review', + description: 'YouthPick 백엔드 변경분을 관점별로 병렬 리뷰하고 각 지적을 검증한다', + phases: [ + { title: 'Review', detail: '규칙/버그/보안/테스트 관점 병렬 리뷰' }, + { title: 'Verify', detail: '각 지적을 독립적으로 재검증' }, + ], +} + +// 리뷰 관점. 각 관점은 이 레포의 규칙 문서를 근거로 삼는다. +const DIMENSIONS = [ + { + key: 'rules', + prompt: + '현재 브랜치 diff(`git diff --merge-base main`)를 리뷰한다. .claude/rules/ 의 architecture.md, api-design.md, lombok.md, entity-jpa.md 를 기준으로 규칙 위반을 찾는다: 패키지 구조, 얇은 Controller, @Valid 누락, Entity 직접 반환, ApiResponse 미사용, 금지 Lombok, Pageable 미사용. 파일:라인과 근거를 반드시 남긴다.', + }, + { + key: 'bugs', + prompt: + '현재 브랜치 diff(`git diff --merge-base main`)를 리뷰한다. 정확성/버그 관점: 트랜잭션 경계(@Transactional readOnly 여부), null/Optional 처리, 경계 조건, 동시성. 파일:라인과 재현 시나리오를 남긴다.', + }, + { + key: 'security', + prompt: + '현재 브랜치 diff(`git diff --merge-base main`)를 리뷰한다. .claude/rules/auth-security.md 와 error-handling.md 기준 보안 관점: secret/토큰/세션 값 노출, 인가 누락(IDOR), 입력 검증 우회, 예외 응답의 내부정보 노출. 파일:라인과 근거를 남긴다.', + }, + { + key: 'tests', + prompt: + '현재 브랜치 diff(`git diff --merge-base main`)를 리뷰한다. .claude/rules/testing.md 기준으로 테스트 관점: 변경분에 대한 A(정상)/E(예외)/X(경계) 테스트가 있는지, 빠진 케이스는 무엇인지. 파일:라인을 남긴다.', + }, +] + +const FINDINGS_SCHEMA = { + type: 'object', + properties: { + findings: { + type: 'array', + items: { + type: 'object', + properties: { + severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, + file: { type: 'string' }, + line: { type: 'integer' }, + summary: { type: 'string' }, + suggestion: { type: 'string' }, + }, + required: ['severity', 'file', 'summary'], + }, + }, + }, + required: ['findings'], +} + +const VERDICT_SCHEMA = { + type: 'object', + properties: { + isReal: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['isReal', 'reason'], +} + +// 관점별 리뷰 → 각 지적이 나오는 즉시 독립 검증(파이프라인, 배리어 없음). +const results = await pipeline( + DIMENSIONS, + (d) => + agent(d.prompt, { + label: `review:${d.key}`, + phase: 'Review', + schema: FINDINGS_SCHEMA, + }), + (review, d) => + parallel( + (review?.findings ?? []).map((f) => () => + agent( + `다음 리뷰 지적이 실제 문제인지 diff와 코드를 직접 열어 검증하라. 규칙 근거가 없거나 오탐이면 isReal=false. 지적: [${f.severity}] ${f.file}:${f.line ?? '?'} — ${f.summary}`, + { label: `verify:${d.key}:${f.file}`, phase: 'Verify', schema: VERDICT_SCHEMA } + ).then((v) => ({ ...f, dimension: d.key, verdict: v })) + ) + ) +) + +const confirmed = results + .flat() + .filter(Boolean) + .filter((f) => f.verdict?.isReal) + +const order = { critical: 0, high: 1, medium: 2, low: 3 } +confirmed.sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9)) + +return { confirmedCount: confirmed.length, findings: confirmed } diff --git a/.gitignore b/.gitignore index cf673a5..f399d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ out/ .env .env.* !.env.example + +### Claude Code local ### +CLAUDE.local.md +.claude/settings.local.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..da39e4f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 0000000..3750eca --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,5 @@ +# git worktree(에이전트 격리 작업 공간)에 복사할 gitignore된 파일 목록. +# 커밋되지 않지만 로컬 작업에 필요한 파일들을 워크트리에도 그대로 넣는다. +.env +CLAUDE.local.md +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..23b436f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# YouthPick Back-end — Agent Guide + +모든 코딩 에이전트(그리고 새로 합류한 개발자)의 공통 진입점이다. 상세 규칙은 [`.claude/rules/`](./.claude/rules)에 주제별로 나뉘어 있다. Claude Code는 이 디렉토리를 자동 로드하고, 다른 에이전트는 아래 규칙 문서 맵을 보고 작업 관련 파일을 먼저 읽는다. + +## 프로젝트 개요 + +- 청년 정책 추천 서비스 **YouthPick** 백엔드 API 서버 +- Java 21 · Spring Boot 3.5.16 · Gradle Wrapper(Groovy DSL, `build.gradle`) +- Spring Web MVC / Data JPA / Security / Session(Redis) / Validation / Actuator +- DB: 로컬·테스트 H2 in-memory(MySQL 모드), 배포 MySQL — 프로파일/환경변수로 전환(기본 프로파일 `local`) +- 인증: Spring Security 세션 기반 + OAuth 소셜 로그인(Google/Naver/Kakao), 세션은 Redis 저장 +- 패키지 루트: `com.bop.youthpick` + +## 검증 명령 + +완료를 주장하기 전에 테스트를 실행해 통과를 확인한다. Java toolchain이 JDK 21을 자동 프로비저닝하므로 부트스트랩 JVM만 있으면 된다. + +```bash +# macOS / Linux / CI +./gradlew test +``` + +```powershell +# Windows (PowerShell) +.\gradlew.bat test +``` + +로컬 인프라(Redis, 필요 시 MySQL)는 `docker compose up -d`로 띄운다. 앱 자체는 컨테이너화하지 않고 IDE/Gradle로 실행한다. + +## Non-negotiable rules (요약) + +- `main` 직접 커밋 금지, 이슈 없이 임의 브랜치 작업 금지. 브랜치는 `feat|fix|docs|refac/{issue-number}-{short-name}`. +- 패키지는 도메인 우선 + 단순 구조: `com.bop.youthpick.{domain}.controller/service/repository/dto/entity/exception`. DDD식 `api/application/domain/infrastructure/external` 금지. +- 요청 DTO에 Bean Validation 적용, `@RequestBody`에는 `@Valid`. Entity를 Controller에서 직접 반환하지 않는다. +- 성공 응답은 `global.common.ApiResponse`(data + meta), 에러 응답은 `global.error.ErrorResponse`(code 기반) 공통 처리. 비즈니스 에러에 raw `RuntimeException` 금지. +- 목록 API는 Spring Data `Pageable`/`Page` 사용. `page`/`size`/`totalPages` 직접 계산 금지. +- Lombok은 `@RequiredArgsConstructor`, `@Getter`, JPA `@NoArgsConstructor(access = PROTECTED)`만. `@Data`, Entity `@Setter`/`@AllArgsConstructor` 금지. +- secret / OAuth client secret / token 값을 코드·로그·응답에 노출하지 않는다. `.env`는 커밋하지 않는다. + +## 규칙 문서 맵 + +| 문서 | 내용 | 적용 범위 | +|------|------|-----------| +| [`.claude/rules/workflow.md`](./.claude/rules/workflow.md) | 작업 흐름, Git 브랜치/커밋/PR/리뷰, 완료 전 검증 | 항상 | +| [`.claude/rules/architecture.md`](./.claude/rules/architecture.md) | 패키지 구조, 계층 책임, 금지 구조 | `src/main/java` | +| [`.claude/rules/api-design.md`](./.claude/rules/api-design.md) | Controller, DTO, Validation, `ApiResponse`, Pageable | `src/main/java` | +| [`.claude/rules/error-handling.md`](./.claude/rules/error-handling.md) | `ErrorCode` 인터페이스, 도메인 에러코드, `ErrorResponse` | `src/main/java` | +| [`.claude/rules/entity-jpa.md`](./.claude/rules/entity-jpa.md) | Entity/Repository/JPA 규칙 | `src/main/java` | +| [`.claude/rules/service.md`](./.claude/rules/service.md) | Service 계층, 트랜잭션 | `src/main/java` | +| [`.claude/rules/lombok.md`](./.claude/rules/lombok.md) | Lombok 허용/금지 목록 | `src/main/java` | +| [`.claude/rules/auth-security.md`](./.claude/rules/auth-security.md) | 인증/세션/Redis, secret 취급 | `src/main/java`, `resources` | +| [`.claude/rules/testing.md`](./.claude/rules/testing.md) | 테스트 작성/실행 규칙 | `src/test` | +| [`.claude/rules/infra.md`](./.claude/rules/infra.md) | Docker/Compose, 프로파일, API 수동 검증 | compose·resources | + +문서 컨벤션과 전체 문서 목록은 [`docs/README.md`](./docs/README.md)를 본다. diff --git a/CLAUDE.md b/CLAUDE.md index 856c26f..3561dca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,57 +1,7 @@ -# CLAUDE.md +@AGENTS.md -Claude Code 또는 유사한 코딩 에이전트는 이 레포에서 작업할 때 반드시 다음 문서를 먼저 읽고 그대로 따른다. +## Claude Code 안내 -- 백엔드 구현 규칙: [`docs/rules.md`](./docs/rules.md) -- Git 워크플로: [`docs/git-convention.md`](./docs/git-convention.md) -- 문서 전체 목록/컨벤션: [`docs/README.md`](./docs/README.md) - -## Non-negotiable rules - -- 코드 변경 전에 [`docs/rules.md`](./docs/rules.md)를 먼저 읽는다. -- 패키지는 도메인 우선 + 단순 구조를 유지한다: `com.bop.youthpick.{domain}.controller/service/repository/dto/entity`. -- DDD식 `{domain}.api / application / domain / infrastructure / external` 패키지를 만들지 않는다. -- 요청 DTO에 Bean Validation을 적용하고, `@RequestBody`에는 `@Valid`를 붙인다. -- JPA Entity를 Controller에서 직접 반환하지 않는다. -- 성공 응답은 `global.common.ApiResponse`로 감싸고, 페이지 등 부가정보는 `meta`에 담는다. -- 예외는 `global.error`의 `ErrorCode` + `CustomException` 기반 공통 처리를 사용한다. 비즈니스 에러에 raw `RuntimeException`을 던지지 않는다. -- 목록 API는 Spring Data `Pageable`/`Page`를 사용한다. `page`/`size`/`totalPages`를 직접 계산하지 않는다. -- Lombok은 보일러플레이트 축소 용도로만: `@RequiredArgsConstructor`, `@Getter`, JPA `@NoArgsConstructor(access = AccessLevel.PROTECTED)`만 허용. `@Data`, Entity `@Setter`, Entity `@AllArgsConstructor`는 금지. -- secret / OAuth client secret / token 값을 코드·로그·응답에 노출하지 않는다. `.env`는 커밋하지 않는다. -- 완료를 주장하기 전에 테스트를 실행한다. - -## 기술 스택 요약 - -- Java 21 · Spring Boot 3.5.16 · Gradle(Groovy DSL, `build.gradle`) -- Spring Web MVC / Data JPA / Security / Session(Redis) / Validation / Actuator -- DB: 로컬·테스트 H2 in-memory(MySQL 모드), 배포 MySQL -- 인증: Spring Security 세션 기반 + OAuth 소셜 로그인(Google/Naver/Kakao), 세션은 Redis 저장 - -## Git workflow - -전체 규칙은 [`docs/git-convention.md`](./docs/git-convention.md)를 따른다. 핵심: - -```text -GitHub Issue → 브랜치 생성 → 작업/커밋 → PR → 리뷰/검증 → merge -``` - -- `main`에 직접 커밋하지 않는다. 이슈 없이 임의 브랜치에서 작업하지 않는다. -- 브랜치: `feat|fix|docs|refac/{issue-number}-{short-name}` (기본 분기: `dev` 있으면 `dev`, 없으면 `main`) -- 커밋: `type: subject` (`feat|fix|docs|refac|test|chore`) -- PR 본문: 변경 내용 · 테스트 결과 · `Closes #이슈번호` - -## Required verification - -테스트를 실행해 통과를 확인한 뒤에만 완료를 주장한다. Java toolchain이 JDK 21을 자동 프로비저닝하므로 `JAVA_HOME`을 수동 설정할 필요는 없다. - -```powershell -# Windows (PowerShell) -.\gradlew.bat test -``` - -```bash -# macOS / Linux / CI -./gradlew test -``` - -Docker/Compose(인프라: Redis, 필요 시 MySQL) 관련 변경 시 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. (이 레포는 앱을 컨테이너화하지 않고 IDE/Gradle로 실행하며, compose는 로컬 인프라만 띄운다.) \ No newline at end of file +- 상세 규칙은 `.claude/rules/`에서 자동 로드된다. 항상 로드되는 규칙(`workflow.md`)과 경로 매칭 시 로드되는 규칙(`paths` frontmatter)으로 나뉜다. 규칙을 바꾸면 `AGENTS.md`의 규칙 문서 맵도 함께 갱신한다. +- 완료를 주장하기 전에 `./gradlew test`를 실행해 통과를 확인한다. +- 머신별 환경 메모(JDK 경로 등)는 커밋되지 않는 `CLAUDE.local.md`에 둔다. diff --git a/docs/README.md b/docs/README.md index 32ce442..fe42ed7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,22 +1,32 @@ # YouthPick 문서 (docs) -`youth-pick` 백엔드의 개발 규칙과 컨벤션 문서 모음이다. 코드/문서 작업 전에 관련 문서를 먼저 읽는다. +`youth-pick` 백엔드의 문서 안내다. 코드/문서 작업 전에 관련 문서를 먼저 읽는다. -## 문서 목록 +## 규칙 문서 위치 + +개발 규칙·컨벤션의 단일 출처(SSOT)는 [`.claude/rules/`](../.claude/rules)다. 주제별 파일로 나뉘어 있고, Claude Code 등 코딩 에이전트가 자동 로드한다. 전체 맵은 [`AGENTS.md`](../AGENTS.md#규칙-문서-맵)를 본다. | 문서 | 내용 | |------|------| -| [`rules.md`](./rules.md) | 백엔드 구현 규칙 (패키지 구조, Controller/DTO/Validation, 예외 처리, Lombok, Entity/JPA, Service, Pageable, 인증/세션/Redis, Docker, 테스트) | -| [`git-convention.md`](./git-convention.md) | Git 워크플로, 브랜치 전략/네이밍, 커밋 컨벤션, PR/리뷰/merge 규칙 | - -레포 루트에는 에이전트 진입점 문서가 있다. +| [`../.claude/rules/workflow.md`](../.claude/rules/workflow.md) | 작업 흐름, Git 브랜치/커밋/PR/리뷰/merge, 완료 전 검증 | +| [`../.claude/rules/architecture.md`](../.claude/rules/architecture.md) | 패키지 구조, 계층 책임, 금지 구조 | +| [`../.claude/rules/api-design.md`](../.claude/rules/api-design.md) | Controller, DTO, Validation, `ApiResponse`, Pageable | +| [`../.claude/rules/error-handling.md`](../.claude/rules/error-handling.md) | `ErrorCode`/`CustomException`/`ErrorResponse`, 에러코드 접두어 체계 | +| [`../.claude/rules/entity-jpa.md`](../.claude/rules/entity-jpa.md) | Entity/Repository/JPA | +| [`../.claude/rules/service.md`](../.claude/rules/service.md) | Service 계층, 트랜잭션 | +| [`../.claude/rules/lombok.md`](../.claude/rules/lombok.md) | Lombok 허용/금지 | +| [`../.claude/rules/auth-security.md`](../.claude/rules/auth-security.md) | 인증/세션/Redis, secret 취급 | +| [`../.claude/rules/testing.md`](../.claude/rules/testing.md) | 테스트 규칙 | +| [`../.claude/rules/infra.md`](../.claude/rules/infra.md) | Docker/Compose, API 수동 검증 | + +## 진입점 문서 (레포 루트) | 문서 | 내용 | |------|------| -| [`../CLAUDE.md`](../CLAUDE.md) | Claude Code 등 코딩 에이전트가 자동 로드하는 진입점. 비협상 규칙 요약 + 상세 문서 링크 | -| [`../AGENTS.md`](../AGENTS.md) | 에이전트 필수 규칙 요약 (범용 에이전트 진입점) | +| [`../AGENTS.md`](../AGENTS.md) | 모든 에이전트/개발자 공통 진입점. 개요 · 검증 명령 · 비협상 규칙 요약 · 규칙 문서 맵 | +| [`../CLAUDE.md`](../CLAUDE.md) | Claude Code 진입점. `AGENTS.md`를 import하고 Claude 전용 안내만 추가 | -`.github/` 에는 협업 템플릿이 있다. +## 협업 템플릿 (`.github/`) | 파일 | 내용 | |------|------| @@ -25,18 +35,16 @@ ## 문서 컨벤션 -새 문서를 추가하거나 기존 문서를 고칠 때 아래를 따른다. - -- **위치**: 에이전트가 자동으로 읽어야 하는 진입점(`CLAUDE.md`, `AGENTS.md`)만 레포 루트에 둔다. 그 외 상세 규칙·설계·컨벤션 문서는 모두 `docs/` 아래에 둔다. -- **파일명**: 소문자 + 하이픈(`kebab-case`), 확장자 `.md`. 예: `git-convention.md`, `api-spec.md`. +- **위치**: 규칙·컨벤션은 `.claude/rules/`(주제별 1파일). 에이전트 진입점(`CLAUDE.md`, `AGENTS.md`)은 레포 루트. 설계·명세 등 그 외 문서는 `docs/` 아래. +- **파일명**: 소문자 + 하이픈(`kebab-case`), 확장자 `.md`. - **언어**: 본문은 한국어. 코드 식별자/명령/예약어는 원문 그대로. -- **단일 출처(SSOT)**: 같은 규칙을 여러 문서에 복붙하지 않는다. 상세 내용은 한 문서에 두고 나머지는 링크한다. (예: Git 규칙 원본은 `git-convention.md`, `CLAUDE.md`/`AGENTS.md`는 요약 + 링크) -- **링크**: 문서 간 참조는 상대경로 링크를 사용해 최신 위치를 가리키게 한다. -- **갱신**: 규칙을 바꾸면 이 목록과 관련 진입점(`CLAUDE.md`/`AGENTS.md`)의 링크·요약도 함께 갱신한다. +- **단일 출처(SSOT)**: 같은 규칙을 여러 문서에 복붙하지 않는다. 상세는 한 문서에 두고 나머지는 링크한다. +- **갱신**: 규칙을 바꾸면 `AGENTS.md`의 요약·규칙 문서 맵과 이 목록도 함께 갱신한다. +- **경로 스코프**: `.claude/rules/` 파일은 필요하면 YAML frontmatter의 `paths`(glob)로 적용 범위를 제한한다. `paths`가 없으면 항상 로드된다. ## 앞으로 추가하면 좋은 문서 (제안) - `api-spec.md` — API 명세(엔드포인트, 요청/응답, 에러코드 매핑표) - `error-codes.md` — `ErrorCode` 카탈로그와 프론트 메시지 매핑 - `oauth-setup.md` — Google/Naver/Kakao OAuth 앱 등록 및 환경변수 세팅 가이드 -- `local-setup.md` — 로컬 실행(H2/Redis/compose) 온보딩 가이드 \ No newline at end of file +- `local-setup.md` — 로컬 실행(H2/Redis/compose) 온보딩 가이드 diff --git a/docs/git-convention.md b/docs/git-convention.md deleted file mode 100644 index c57dbf8..0000000 --- a/docs/git-convention.md +++ /dev/null @@ -1,90 +0,0 @@ -# Git 작업 컨벤션 - -`youth-pick`에서 코드나 문서를 수정하는 모든 사람(에이전트 포함)은 아래 흐름을 따른다. - -```text -GitHub Issue 생성 → 브랜치 생성 → 작업/커밋 → PR 생성 → 리뷰/검증 확인 → merge -``` - -직접 `main`에 커밋하거나, 이슈 없이 임의 브랜치에서 작업하지 않는다. 애매하면 이 문서를 기준으로 맞춘다. - -## 브랜치 전략 - -- `main`: 배포 가능한 안정 버전만 유지한다. -- `dev`: 팀 개발 통합 브랜치다. 기능 브랜치는 원칙적으로 `dev`에서 분기한다. -- `feat/*`: 신규 기능 개발 브랜치. -- `fix/*`: 버그 수정 브랜치. -- `docs/*`: 문서 작업 브랜치. -- `refac/*`: 동작 변경 없이 구조를 정리하는 브랜치. - -현재 원격에 `dev` 브랜치가 없으면 `main`에서 분기하되, `dev`가 생긴 이후에는 기능 브랜치를 `dev` 기준으로 만든다. - -## 브랜치 네이밍 - -```text -feat/{issue-number}-{short-name} -fix/{issue-number}-{short-name} -docs/{issue-number}-{short-name} -refac/{issue-number}-{short-name} -``` - -예시: - -```text -feat/12-social-login -fix/18-search-empty-state -docs/24-api-spec -refac/31-policy-service -``` - -## 커밋 컨벤션 - -커밋 메시지는 아래 형식을 따른다. - -```text -type: subject -``` - -예시: - -```text -feat: 소셜 로그인 콜백 API 추가 -fix: 검색 결과 없음 상태 처리 수정 -docs: API 명세서 오류 케이스 보강 -refac: 정책 검색 서비스 책임 분리 -test: 관심정책 중복 방지 테스트 추가 -chore: 프로젝트 설정 파일 정리 -``` - -커밋 타입 기준: - -- `feat`: 사용자에게 보이는 기능 추가 -- `fix`: 버그 수정 -- `docs`: 문서 수정 -- `refac`: 기능 변경 없는 구조 개선 -- `test`: 테스트 추가/수정 -- `chore`: 설정, 빌드, 패키지 등 기타 작업 - -## PR 규칙 - -PR 본문에는 반드시 아래 내용을 적는다. ([PR 템플릿](../.github/PULL_REQUEST_TEMPLATE.md)) - -- 변경 내용 -- 테스트 결과 -- 필요한 경우 스크린샷 또는 로그 -- 연결 이슈: `Closes #이슈번호` - -PR은 최소 1명 이상 리뷰 후 merge하는 것을 원칙으로 한다. 충돌 해결, 테스트 통과, 문서 반영 후 merge한다. - -## 리뷰 기준 - -- 기획서의 User Story / Acceptance Criteria와 구현이 맞는지 확인한다. -- A(정상)/E(예외)/X(경계·비정상) 테스트 관점이 빠지지 않았는지 확인한다. -- 권한, 빈 상태, 오류 상태, 외부 장애 처리가 있는지 확인한다. -- 민감정보(secret, OAuth token 등)가 응답, 로그, 프론트 저장소에 남지 않는지 확인한다. - -## Merge 규칙 - -- 작은 기능 단위로 PR을 나눈다. 큰 PR은 피한다. -- merge 후 `feat/*`, `fix/*`, `docs/*`, `refac/*` 브랜치는 삭제한다. -- 배포 영향이 있는 변경은 이슈/문서에 변경 내용을 남긴다. \ No newline at end of file diff --git a/docs/rules.md b/docs/rules.md deleted file mode 100644 index 666501e..0000000 --- a/docs/rules.md +++ /dev/null @@ -1,383 +0,0 @@ -# YouthPick Back-end Rules - -이 문서는 `youth-pick`에서 작업하는 모든 에이전트와 개발자가 따라야 하는 백엔드 구현 규칙이다. 새 기능을 만들거나 기존 코드를 수정하기 전에 반드시 이 파일을 먼저 읽고, 이 규칙과 충돌하는 구현은 하지 않는다. - -> Git 브랜치/커밋/PR 규칙은 [`git-convention.md`](./git-convention.md)를 따른다. - -## 1. 작업 흐름 - -- 기획 변경이나 구현 계획이 필요한 작업은 먼저 계획을 정리하고 관련 GitHub Issue를 만든 뒤 작업한다. -- 백엔드 작업은 issue 번호가 포함된 브랜치에서 진행한다. ([`git-convention.md`](./git-convention.md)) -- 구현 후에는 테스트를 직접 실행하고, PR에 실제 검증 결과를 적는다. -- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. - -## 2. 기술 기준 - -- **Java 21**을 사용한다. -- **Spring Boot 3.5.16** 기준으로 작성한다. -- 기본 스택: Spring Web MVC, Spring Data JPA, Spring Data Redis(Spring Session 저장소), Spring Security, Bean Validation, Spring Boot Actuator. -- DB: 로컬/테스트는 **H2 in-memory(MySQL 호환 모드)**, 실배포는 **MySQL**. 프로파일과 환경변수로 전환한다. -- 빌드는 **Gradle Wrapper (Groovy DSL, `build.gradle`)** 를 사용한다. Java toolchain이 JDK 21을 자동 프로비저닝하므로 `JAVA_HOME`을 수동으로 지정할 필요는 없다. -- 테스트 실행: - - ```powershell - # Windows (PowerShell) - .\gradlew.bat test - ``` - - ```bash - # macOS / Linux / CI - ./gradlew test - ``` - -## 3. 패키지 구조 - -패키지는 도메인 단위 폴더 구조로 만든다. 다만 도메인 내부는 과하게 DDD식으로 나누지 않고 `controller`, `service`, `repository`, `dto`, `entity` 중심의 단순 구조를 사용한다. - -현재 기준 구조는 다음과 같다. - -```text -com.bop.youthpick -├── global -│ ├── common // ApiResponse 등 공통 응답 봉투 -│ ├── config // SecurityConfig, RestAuthenticationEntryPoint 등 -│ └── error // ErrorCode, CustomException, GlobalExceptionHandler 등 -├── policy -│ ├── controller -│ ├── service -│ ├── repository -│ ├── dto -│ └── entity -└── user - ├── controller - ├── service - ├── repository - ├── dto - └── entity -``` - -새 도메인(예: `favorite`, 자가진단 등)을 추가할 때는 아래 형태를 따른다. - -```text -com.bop.youthpick.{domain} -├── controller -├── service -├── repository -├── dto -└── entity -``` - -각 패키지 역할은 다음과 같다. - -- `controller`: HTTP Controller와 API 입출력 조립을 담당한다. -- `service`: 비즈니스 흐름, scheduler, lock, 외부 API client, 기술 상세 구현을 둔다. -- `repository`: Spring Data JPA repository interface를 둔다. -- `dto`: 요청/응답 DTO, service 결과 DTO, 외부 API payload DTO를 둔다. -- `entity`: JPA Entity, domain enum을 둔다. -- `global`: 여러 도메인이 공유하는 공통 응답, 설정, 예외 처리만 둔다. - -금지 구조는 다음과 같다. - -```text -com.bop.youthpick.{domain} -├── api -├── application -├── domain -├── infrastructure -└── external -``` - -## 4. Controller 규칙 - -Controller는 얇게 유지한다. HTTP 요청 파싱, Bean Validation, Service 호출, 응답 DTO 변환만 담당한다. 비즈니스 판단, 중복 검사, 외부 API 호출, Entity 상태 변경 로직은 Controller에 두지 않는다. - -```java -@RestController -@RequestMapping("/api/v1/policies") -@RequiredArgsConstructor -public class PolicyController { - - private final PolicySearchService policySearchService; -} -``` - -- 생성자 주입을 사용한다(`@RequiredArgsConstructor` + `final` 필드). -- 필드 주입(`@Autowired` field injection)은 금지한다. -- Entity를 API 응답으로 직접 반환하지 않는다. -- 요청 body를 받는 파라미터에는 `@Valid @RequestBody`를 붙인다. -- 성공 응답은 항상 `global.common.ApiResponse`로 감싼다(§5.3 참고). - -## 5. DTO / 응답 규칙 - -요청 DTO와 응답 DTO는 각 도메인의 `dto` 패키지에 둔다. DTO는 API 계약을 표현하는 객체이며 Entity를 외부에 노출하지 않기 위한 경계다. - -### 5.1 요청 DTO - -- 요청 DTO는 `record`를 사용할 수 있다. 다만 안정적인 field-level validation 응답이 필요한 경우 필드에 Bean Validation annotation을 명확히 붙인다. -- enum 값을 직접 바인딩하면 Jackson deserialization 단계에서 터질 수 있다. 잘못된 enum 값을 `C001` 입력값 오류로 내려야 하면 문자열 필드 + `@Pattern`을 사용한다. -- 모든 사용자 입력 필드는 의도를 드러내는 validation annotation을 붙인다. -- validation message는 한국어로 작성한다. - -```java -public record UserSignupRequest( - @NotBlank(message = "아이디는 필수입니다.") - @Size(min = 4, max = 20, message = "아이디는 4~20자여야 합니다.") - String username, - - @NotBlank(message = "비밀번호는 필수입니다.") - String password -) { -} -``` - -요청 DTO에 validation이 없거나 Controller에서 `@Valid` 없이 받는 코드는 금지한다. - -### 5.2 응답 DTO - -- 응답 DTO는 Entity를 받아 정적 팩토리 메서드 `from(...)`으로 생성한다. -- 응답 DTO에서 Entity의 민감 필드, 내부 상태, raw payload, secret 값을 노출하지 않는다. - -```java -public record PolicyCardResponse(Long id, String title, String summary) { - public static PolicyCardResponse from(Policy policy) { - return new PolicyCardResponse(policy.getId(), policy.getTitle(), policy.getSummary()); - } -} -``` - -### 5.3 공통 응답 봉투 (`ApiResponse`) - -성공 응답은 `global.common.ApiResponse`로 감싼다. 이 프로젝트의 `ApiResponse`는 `data` + `meta` 구조다. - -```java -public record ApiResponse(T data, Map meta) { - public static ApiResponse ok(T data) { ... } - public static ApiResponse ok(T data, Map meta) { ... } -} -``` - -- 단일/객체 데이터는 `ApiResponse.ok(data)`. -- 페이지네이션 등 부가정보(`page`, `totalCount` 등)는 **`data`가 아니라 `meta`에 담는다.** -- 200 외 상태 코드가 필요하면 `ResponseEntity>`로 감싼다. -- 에러 응답은 `ApiResponse`가 아니라 `global.error.ErrorResponse` 형식으로 내려간다(§7). - -```java -@GetMapping -public ApiResponse> search(@PageableDefault(size = 20) Pageable pageable) { - Page page = policySearchService.search(pageable); - return ApiResponse.ok(page.getContent(), Map.of( - "page", page.getNumber(), - "totalCount", page.getTotalElements(), - "totalPages", page.getTotalPages() - )); -} -``` - -## 6. Validation 규칙 - -- `spring-boot-starter-validation`을 사용한다. -- 문자열 필드는 보통 `@NotBlank`를 사용한다. `@NotNull`만으로는 공백 문자열을 막을 수 없다. -- 길이 제한은 `@Size`, 허용값 제한 문자열은 `@Pattern`, 숫자 범위는 `@Min`/`@Max`/`@Positive`를 사용한다. -- 중첩 DTO는 필드에도 `@Valid`를 붙인다. - -Validation 실패는 `GlobalExceptionHandler`에서 `ErrorCode.INVALID_INPUT_VALUE`(`C001`) 형식으로 통일한다. 프론트가 에러코드를 기준으로 메시지를 매핑할 수 있도록 아래 형태를 유지한다. - -```json -{ - "code": "C001", - "message": "입력 형태가 올바르지 않습니다.", - "details": [ - { "field": "username", "value": "", "reason": "아이디는 필수입니다." } - ] -} -``` - -- Bean Validation 실패(`MethodArgumentNotValidException`, `ConstraintViolationException`)는 백엔드에서 처리하고 HTTP 400 + `C001`로 내려준다. -- validation 오류에서도 항상 `code`, `message`, `details` 필드를 유지한다. -- `details[].reason`에는 사용자에게 보여줄 수 있는 검증 메시지만 넣고, 내부 exception class/SQL/stack trace/secret 값은 넣지 않는다. -- Controller나 Service에서 validation 실패를 문자열 응답으로 직접 만들지 않는다. - -## 7. 예외 처리 규칙 - -공통 예외 처리는 `global.error` 패키지에 둔다. 예외 응답은 `ErrorCode` enum을 기준으로 생성한다. - -```text -global.error -├── ErrorCode.java -├── CustomException.java -├── ConflictException.java -├── ResourceNotFoundException.java -├── ErrorResponse.java -└── GlobalExceptionHandler.java -``` - -`ErrorCode`는 HTTP 상태, 서비스 코드, 사용자 메시지를 함께 가진다. **서비스 코드는 접두어 + 3자리** 규칙을 따른다. - -| 접두어 | 영역 | -|--------|------| -| `C` | 공통(common) | -| `A` | 인증/인가(auth) | -| `S` | 서버(server) | -| `U` | 사용자(user) | -| `P` | 정책(policy) | -| `F` | 즐겨찾기(favorite) | -| `D` | 자가진단(diagnosis) | - -```java -public enum ErrorCode { - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "로그인이 필요합니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), - POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "일치하는 정책이 존재하지 않습니다."); - // ... -} -``` - -- 비즈니스 예외는 `throw new CustomException(ErrorCode.X)` 또는 이를 상속한 구체 예외로 던진다. -- 존재하지 않는 리소스는 `ResourceNotFoundException(ErrorCode.X_NOT_FOUND)`를 사용한다. -- 상태 충돌, 중복 실행, 중복 등록 등은 `ConflictException(ErrorCode.X_CONFLICT)`를 사용한다. -- Service에서 `throw new RuntimeException("...")` 형태로 비즈니스 예외를 직접 던지지 않는다. -- Controller에서 `try-catch`로 에러 응답을 만들지 않는다. -- 내부 예외 메시지/SQL/stack trace/secret 값을 응답에 노출하지 않는다. -- 새 `ErrorCode`를 추가할 때는 접두어 체계를 지키고 사용자 메시지를 함께 정의하며, 프론트에서 매핑해야 하는 코드인지 PR 본문에 명시한다. -- 같은 오류 상황에 HTTP status만 다르게 내려주거나, 같은 `code`에 서로 다른 의미를 부여하지 않는다. - -## 8. Lombok 규칙 - -Lombok은 반복적인 생성자, getter, JPA 기본 생성자 보일러플레이트를 줄이는 용도로만 제한적으로 사용한다. - -- 생성자 주입은 `final` 필드와 `@RequiredArgsConstructor`를 기본으로 사용한다. -- JPA Entity에는 `@Getter`와 `@NoArgsConstructor(access = AccessLevel.PROTECTED)`를 사용할 수 있다. -- 예외 코드 enum, 커스텀 예외처럼 단순 getter만 필요한 타입에는 `@Getter`를 사용할 수 있다. -- DTO는 `record`를 우선 사용한다. - -다음은 금지한다. - -- `@Data` -- Entity의 `@Setter` -- Entity의 `@AllArgsConstructor` / public all-args constructor -- 양방향 연관관계 Entity의 무분별한 `@ToString` -- Entity의 무분별한 `@EqualsAndHashCode` - -## 9. Entity / JPA 규칙 - -- Entity는 각 도메인의 `entity` 패키지에 둔다. -- Entity 기본 생성자는 JPA용으로만 열고 외부 생성을 제한한다(`@NoArgsConstructor(access = AccessLevel.PROTECTED)`). -- Entity를 API request/response에 직접 사용하지 않는다. -- 상태 변경은 setter 대신 의미 있는 메서드로 표현한다. -- 컬럼 제약은 `@Column(nullable = false, length = ...)`처럼 명시한다. -- Repository는 각 도메인의 `repository` 패키지에 interface로 둔다. -- 조회 결과가 없을 수 있으면 `Optional`를 사용한다. -- `ddl-auto`는 로컬 `update`, 테스트 `create-drop`. 운영에서 `create`/`update`를 무분별하게 쓰지 않는다. - -## 10. Service 규칙 - -- Service는 각 도메인의 `service` 패키지에 둔다. -- 읽기 메서드는 `@Transactional(readOnly = true)`를 사용한다. -- 생성/수정/삭제/외부 동기화는 명시적으로 `@Transactional`을 사용한다. -- 외부 API 호출, scheduler 등은 흐름을 명확히 나눈다. -- secret, API key, OAuth token 값은 로그에 남기지 않는다. - -## 11. Pageable 페이지네이션 규칙 - -목록 API는 직접 `page`, `size`를 받아 정규화하지 않는다. Spring Data `Pageable`/`Page`를 사용한다. - -```java -@GetMapping -public ApiResponse> search(@PageableDefault(size = 20) Pageable pageable) { - Page page = policySearchService.search(pageable); - return ApiResponse.ok(page.getContent(), Map.of( - "page", page.getNumber(), - "totalCount", page.getTotalElements(), - "totalPages", page.getTotalPages() - )); -} -``` - -```java -@Transactional(readOnly = true) -public Page search(Pageable pageable) { - return repository.findAll(pageable).map(PolicyCardResponse::from); -} -``` - -max page size는 코드의 `Math.min`이 아니라 Spring 설정으로 제한한다. - -```yaml -spring: - data: - web: - pageable: - max-page-size: 50 -``` - -금지 패턴: - -```java -@RequestParam(defaultValue = "0") int page -@RequestParam(defaultValue = "20") int size -int normalizedSize = Math.min(size, 50); -int totalPages = (int) Math.ceil(...); -``` - -## 12. 인증 / 세션 / Redis 규칙 - -이 프로젝트는 **Spring Security 세션 기반 인증** + **Spring Session(Redis 저장소)** + **OAuth 소셜 로그인(Google / Naver / Kakao)** 구조다. - -- 세션은 Spring Session을 통해 Redis에 저장한다(`spring-boot-starter-data-redis` + `spring-session-data-redis`). in-memory 세션에 의존하는 코드를 만들지 않는다. -- 인증 실패(미인증 접근)는 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려준다. -- OAuth client id/secret 등 secret 값은 코드에 하드코딩하지 않고 환경변수(`GOOGLE_OAUTH_CLIENT_ID` 등)로 주입한다. `.env`는 커밋하지 않는다. -- 인가 규칙(경로별 권한)은 `SecurityConfig`에서 관리하고 Controller에 흩뿌리지 않는다. -- 중복 실행을 막아야 하는 배치/스케줄 작업을 도입한다면 Redis lock(owner token + TTL, release 시 owner 확인)을 사용하고, scheduler는 설정으로 on/off 가능하게 둔다. (현재 도입 전이면 도입 시 이 규칙을 따른다.) - -## 13. 실행 / Docker 규칙 - -- **앱(백엔드)은 컨테이너화하지 않고 로컬 IDE / Gradle로 실행한다.** `docker-compose.yml`은 로컬 인프라(Redis, 필요 시 MySQL)만 띄운다. -- 로컬 인프라 기동: - - ```bash - docker compose up -d # Redis (필요 시 MySQL) 기동 - docker compose ps - docker compose down # 중지 (볼륨 데이터 보존) - docker compose down -v # 중지 + 볼륨 삭제(초기화) - ``` - -- 로컬 기본 DB는 H2 in-memory이므로 MySQL 없이도 앱이 뜬다. MySQL로 붙일 때만 compose의 MySQL 서비스와 관련 환경변수를 활성화한다. -- Compose/인프라 관련 변경 시 로컬에서 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. - -## 14. 테스트 규칙 - -- 기능 변경 시 관련 작은 테스트를 먼저 실행하고, 마지막에 전체 테스트를 실행한다. -- Controller validation은 MockMvc 테스트로 검증한다. -- Service 비즈니스 규칙은 Service 테스트로 검증한다. -- Repository/JPA 쿼리는 통합 테스트로 검증한다(테스트 프로파일은 H2 `create-drop`). -- PR 전 최소 `./gradlew test`(Windows는 `.\gradlew.bat test`)를 통과시킨다. - -## 15. API 검증 규칙 - -- 한글 query parameter를 curl로 검증할 때는 raw URL에 직접 넣지 말고 `--data-urlencode`를 사용한다. - - ```bash - curl -G http://localhost:8080/api/v1/policies \ - --data-urlencode 'keyword=월세' \ - --data-urlencode 'region=서울' - ``` - -- actuator health(`/actuator/health`)와 API health를 구분한다. -- Redis나 DB가 꺼져 있으면 actuator health가 DOWN일 수 있다. - -## 16. PR 전 체크리스트 - -- [ ] 도메인 단위 패키지 구조를 지켰다. -- [ ] Controller가 얇게 유지된다. -- [ ] 요청 DTO에 Bean Validation을 적용했다. -- [ ] `@RequestBody`에는 `@Valid`를 붙였다. -- [ ] Entity를 API 응답으로 직접 반환하지 않는다. -- [ ] 성공 응답을 `ApiResponse`로 감쌌고, 페이지 정보는 `meta`에 담았다. -- [ ] Service에서 직접 `RuntimeException`을 던지지 않는다. -- [ ] 예외 응답은 `global.error`를 통해 통일되고, `code`(접두어 규칙)를 포함한다. -- [ ] 새 에러코드를 추가했다면 프론트 메시지 매핑 필요 여부를 PR에 적었다. -- [ ] 목록 API는 `Pageable`/`Page`를 사용한다. -- [ ] secret/OAuth token/민감정보가 로그·응답에 노출되지 않는다. -- [ ] 작은 테스트와 전체 테스트를 실행했다. \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/rules.md b/rules.md deleted file mode 100644 index 4934016..0000000 --- a/rules.md +++ /dev/null @@ -1,373 +0,0 @@ -# BOP Back-end Agent Rules - -이 문서는 `back-end-proto`에서 작업하는 모든 에이전트와 개발자가 따라야 하는 백엔드 구현 규칙이다. 새 기능을 만들거나 기존 코드를 수정하기 전에 반드시 이 파일을 먼저 읽고, 이 규칙과 충돌하는 구현은 하지 않는다. - -## 1. 작업 흐름 - -- GitHub Issue를 만든 뒤 작업한다. -- 백엔드 작업은 issue 번호가 포함된 브랜치에서 진행한다. -- 구현 후에는 테스트를 직접 실행하고, PR에 실제 검증 결과를 적는다. -- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. - -## 2. 기술 기준 - -- Java 21을 사용한다. -- Spring Boot 3.5.16 기준으로 작성한다. -- Spring Web, Spring Data JPA, Spring Data Redis, Spring Security, Bean Validation을 프로젝트 기본 스택으로 본다. -- Gradle Wrapper(`./gradlew`)를 사용한다. - -```bash -export JAVA_HOME=/home/heeho3/.jdks/temurin-21 -export PATH="$JAVA_HOME/bin:$PATH" -./gradlew test -``` - -## 3. 패키지 구조 - -패키지는 도메인 단위 폴더 구조로 만든다. 다만 도메인 내부는 과하게 DDD식으로 나누지 않고 `controller`, `service`, `repository`, `dto`, `entity` 중심의 단순 구조를 사용한다. - -현재 기준 구조는 다음과 같다. - -```text -com.bop.youthpick -├── global -│ ├── common -│ ├── config -│ └── error -├── admin -│ └── controller -├── policy -│ ├── controller -│ ├── service -│ ├── repository -│ ├── dto -│ └── entity -└── sync - ├── controller - ├── service - ├── repository - ├── dto - └── entity -``` - -새 도메인을 추가할 때는 아래 형태를 따른다. - -```text -com.bop.youthpick.{domain} -├── controller -├── service -├── repository -├── dto -└── entity -``` - -각 패키지 역할은 다음과 같다. - -- `controller`: HTTP Controller와 API 입출력 조립을 담당한다. -- `service`: 비즈니스 흐름, scheduler, lock, 외부 API client, 기술 상세 구현을 둔다. -- `repository`: Spring Data JPA repository interface를 둔다. -- `dto`: 요청/응답 DTO, service 결과 DTO, 외부 API payload DTO를 둔다. -- `entity`: JPA Entity, domain enum을 둔다. -- `global`: 여러 도메인이 공유하는 공통 응답, 설정, 예외 처리만 둔다. - -금지 구조는 다음과 같다. - -```text -com.bop.youthpick.{domain} -├── api -├── application -├── domain -├── infrastructure -└── external -``` - -## 4. Controller 규칙 - -Controller는 얇게 유지한다. HTTP 요청 파싱, Bean Validation, Service 호출, 응답 DTO 변환만 담당한다. 비즈니스 판단, 중복 검사, 외부 API 호출, Entity 상태 변경 로직은 Controller에 두지 않는다. - -```java -@RestController -@RequestMapping("/api/v1/policies") -public class PolicyController { - - private final PolicySearchService policySearchService; - - public PolicyController(PolicySearchService policySearchService) { - this.policySearchService = policySearchService; - } -} -``` - -- 생성자 주입을 사용한다. -- 필드 주입(`@Autowired` field injection)은 금지한다. -- Entity를 API 응답으로 직접 반환하지 않는다. -- 요청 body를 받는 파라미터에는 `@Valid @RequestBody`를 붙인다. - -## 5. DTO 규칙 - -요청 DTO와 응답 DTO는 각 도메인의 `dto` 패키지에 둔다. DTO는 API 계약을 표현하는 객체이며 Entity를 외부에 노출하지 않기 위한 경계다. - -### 5.1 요청 DTO - -- 요청 DTO는 `record`를 사용할 수 있다. 다만 안정적인 field-level validation 응답이 필요한 경우 필드에 Bean Validation annotation을 명확히 붙인다. -- enum 값을 직접 바인딩하면 Jackson deserialization 단계에서 터질 수 있다. 잘못된 enum 값을 `C001` 입력값 오류로 내려야 하면 문자열 필드 + `@Pattern`을 사용한다. -- 모든 사용자 입력 필드는 의도를 드러내는 validation annotation을 붙인다. -- validation message는 한국어로 작성한다. - -```java -public record PolicySyncJobStartRequest( - @Pattern(regexp = "FULL|DELTA", message = "mode는 FULL 또는 DELTA만 허용됩니다.") - String mode -) { - public PolicySyncJobMode normalizedMode() { - if (mode == null || mode.isBlank()) { - return PolicySyncJobMode.FULL; - } - return PolicySyncJobMode.valueOf(mode); - } -} -``` - -요청 DTO에 validation이 없거나 Controller에서 `@Valid` 없이 받는 코드는 금지한다. - -### 5.2 응답 DTO - -- 응답 DTO는 Entity를 받아 정적 팩토리 메서드 `from(...)`으로 생성한다. -- 응답 DTO에서 Entity의 민감 필드, 내부 상태, raw payload, secret 값을 노출하지 않는다. -- 목록 응답은 필요하면 `items`와 `pagination`을 가진 별도 response DTO로 감싼다. - -```java -public record PolicySearchResponse( - List items, - Pagination pagination, - boolean degraded -) { - public static PolicySearchResponse from(PolicySearchResult result) { - return new PolicySearchResponse(result.items(), result.pagination(), result.degraded()); - } -} -``` - -## 6. Validation 규칙 - -- `spring-boot-starter-validation`을 사용한다. -- 문자열 필드는 보통 `@NotBlank`를 사용한다. `@NotNull`만으로는 공백 문자열을 막을 수 없다. -- 길이 제한은 `@Size`를 사용한다. -- 허용값이 제한된 문자열은 `@Pattern`을 사용한다. -- 숫자 범위는 `@Min`, `@Max`, `@Positive`를 사용한다. -- 중첩 DTO는 필드에도 `@Valid`를 붙인다. - -Validation 실패는 `GlobalExceptionHandler`에서 `ErrorCode.INVALID_INPUT_VALUE`(`C001`) 형식으로 통일한다. - -검증 오류 응답 계약은 프론트가 에러코드를 기준으로 메시지를 매핑할 수 있도록 아래 형태를 유지한다. - -```json -{ - "code": "C001", - "message": "입력 형태가 올바르지 않습니다.", - "details": [ - { - "field": "mode", - "value": "INVALID", - "reason": "mode는 FULL 또는 DELTA만 허용됩니다." - } - ] -} -``` - -- Bean Validation 실패(`MethodArgumentNotValidException`, `ConstraintViolationException`)는 백엔드에서 처리하고 HTTP 400 + `C001`로 내려준다. -- 프론트가 `code`를 안정적으로 확인할 수 있도록 validation 오류에서도 항상 `code`, `message`, `details` 필드를 유지한다. -- `details[].reason`에는 사용자에게 보여줄 수 있는 검증 메시지를 넣되, 내부 exception class, SQL, stack trace, secret 값은 넣지 않는다. -- Controller나 Service에서 validation 실패를 문자열 응답으로 직접 만들지 않는다. - -## 7. 예외 처리 규칙 - -공통 예외 처리는 `global.error` 패키지에 둔다. 예외 응답은 참고 레포(`SpringBootStudy`) 방식처럼 도메인별 `ErrorCode` enum을 기준으로 생성한다. - -`ErrorCode`는 각 도메인의 `exception` 패키지에 두고, 도메인별 상태 코드와 사용자 메시지를 함께 관리한다. `global.error`는 공통 응답 생성과 전역 예외 처리만 담당한다. - -```text -global.error -├── CustomException.java -├── ConflictException.java -├── ResourceNotFoundException.java -├── ErrorResponse.java -└── GlobalExceptionHandler.java -``` - -도메인별 `ErrorCode`는 HTTP 상태, 서비스 코드, 사용자 메시지를 함께 가진다. - -```java -public enum PolicyErrorCode { - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), - POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "일치하는 정책이 존재하지 않습니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."); - - private final HttpStatus status; - private final String code; - private final String message; -} -``` - -```text -policy -├── controller -├── service -├── repository -├── dto -└── exception - ├── PolicyErrorCode.java - └── PolicyException.java -``` - -- 비즈니스 예외는 `throw new CustomException(PolicyErrorCode.X)`처럼 해당 도메인의 `ErrorCode`를 사용하거나, 이를 상속한 구체 예외로 던진다. -- 존재하지 않는 리소스는 `ResourceNotFoundException(PolicyErrorCode.X_NOT_FOUND)`를 사용한다. -- 상태 충돌, 중복 실행, 중복 등록 등은 `ConflictException(PolicyErrorCode.X_CONFLICT)`를 사용한다. -- Service에서 `throw new RuntimeException("...")` 형태로 비즈니스 예외를 직접 던지지 않는다. -- Controller에서 `try-catch`로 에러 응답을 만들지 않는다. -- 내부 예외 메시지, SQL, stack trace, secret 값을 응답에 노출하지 않는다. -- validation field error는 `field`, `value`, `reason` detail을 포함한다. -- 프론트가 사용자 메시지를 일관되게 매핑할 수 있도록 모든 예외 응답은 `code`를 필수로 포함한다. -- 새 `ErrorCode`를 추가할 때는 도메인, 코드 체계, 사용자 메시지를 함께 정의하고, 프론트에서 매핑해야 하는 코드인지 PR 본문에 명시한다. -- 같은 오류 상황에 HTTP status만 다르게 내려주거나, 같은 `code`에 서로 다른 의미를 부여하지 않는다. - -## 8. Lombok 규칙 - -백엔드 레포는 Lombok을 사용한다. Lombok은 반복적인 생성자, getter, JPA 기본 생성자 보일러플레이트를 줄이는 용도로만 제한적으로 사용한다. - -- 생성자 주입은 `final` 필드와 `@RequiredArgsConstructor`를 기본으로 사용한다. -- JPA Entity에는 `@Getter`와 `@NoArgsConstructor(access = AccessLevel.PROTECTED)`를 사용할 수 있다. -- 예외 코드 enum, 커스텀 예외처럼 단순 getter만 필요한 타입에는 `@Getter`를 사용할 수 있다. -- DTO는 기존처럼 `record`를 우선 사용한다. DTO에 Lombok class를 도입해야 한다면 validation, 생성자, 불변성을 명확히 유지한다. -- `@Value` 주입 파라미터가 있는 생성자처럼 Lombok 적용이 오히려 의도를 흐리는 경우에는 명시적 생성자를 유지한다. - -다음은 금지한다. - -- `@Data` -- Entity의 `@Setter` -- Entity의 `@AllArgsConstructor` -- 양방향 연관관계 Entity의 무분별한 `@ToString` -- Entity의 무분별한 `@EqualsAndHashCode` -- Entity의 public all-args constructor - -## 9. Entity / JPA 규칙 - -- Entity는 각 도메인의 `entity` 패키지에 둔다. -- Entity 기본 생성자는 JPA용으로만 열고 외부 생성을 제한한다. -- Entity를 API request/response에 직접 사용하지 않는다. -- 상태 변경은 setter 대신 의미 있는 메서드로 표현한다. -- 컬럼 제약은 `@Column(nullable = false, length = ...)`처럼 명시한다. -- Repository는 각 도메인의 `repository` 패키지에 interface로 둔다. -- 조회 결과가 없을 수 있으면 `Optional`를 사용한다. - -## 10. Service 규칙 - -- Service는 각 도메인의 `service` 패키지에 둔다. -- 읽기 메서드는 `@Transactional(readOnly = true)`를 사용한다. -- 생성/수정/삭제/외부 동기화는 명시적으로 `@Transactional`을 사용한다. -- 외부 API 호출, Redis lock, scheduler 등은 application boundary에서 흐름을 명확히 나눈다. -- secret, API key, token 값은 로그에 남기지 않는다. - -## 11. Pageable 페이지네이션 규칙 - -목록 API는 직접 `page`, `size`를 받아 정규화하지 않는다. Spring Data `Pageable`/`Page`를 사용한다. - -Controller 기준: - -```java -@GetMapping -public ApiResponse searchPolicies( - @PageableDefault(size = 20) Pageable pageable -) { - return ApiResponse.ok(...); -} -``` - -Service / Repository 기준: - -```java -@Transactional(readOnly = true) -public Page list(Pageable pageable) { - return repository.findAll(pageable).map(Result::from); -} -``` - -max page size는 코드의 `Math.min`이 아니라 Spring 설정으로 제한한다. - -```yaml -spring: - data: - web: - pageable: - max-page-size: 50 -``` - -금지 패턴: - -```java -@RequestParam(defaultValue = "0") int page -@RequestParam(defaultValue = "20") int size -int normalizedPage = Math.max(page, 0); -int normalizedSize = Math.min(size, 50); -int totalPages = (int) Math.ceil(...); -``` - -## 12. Redis / Lock / Scheduler 규칙 - -- 중복 실행을 막아야 하는 작업은 Redis lock을 사용한다. -- Lock은 owner token과 TTL을 가진다. -- Lock release 시 owner token을 확인한다. -- Scheduler는 기본적으로 비활성화 가능한 설정을 둔다. -- 외부 API sync job은 retry/backoff 설정을 둔다. -- interrupt 발생 시 interrupt status를 복구한다. - -## 13. Docker / 실행 규칙 - -이 레포는 자체 `Dockerfile`과 `docker-compose.yml`을 소유한다. 별도 infra 레포나 루트 통합 Compose를 만들지 않는다. - -```bash -docker compose up -d --build -``` - -Compose에는 백엔드, DB, Redis 실행 기준을 둔다. Docker/Compose 관련 변경 시 로컬에서 가능한 범위로 `docker compose config`, build, runtime smoke를 검증한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. - -## 14. 테스트 규칙 - -- 기능 변경 시 관련 작은 Service 테스트를 먼저 실행하고, 마지막에 전체 테스트를 실행한다. -- Service 비즈니스 규칙은 Service 테스트로 검증한다. -- PR 전 최소 `./gradlew test`를 통과시킨다. - -```bash -export JAVA_HOME=/home/heeho3/.jdks/temurin-21 -export PATH="$JAVA_HOME/bin:$PATH" -./gradlew test -``` - -## 15. API 검증 규칙 - -- 한글 query parameter를 curl로 검증할 때는 raw URL에 직접 넣지 말고 `--data-urlencode`를 사용한다. - -```bash -curl -G http://localhost:8080/api/v1/policies \ - --data-urlencode 'keyword=월세' \ - --data-urlencode 'region=서울' -``` - -- actuator health와 API health를 구분한다. -- Redis나 DB가 꺼져 있으면 actuator health가 DOWN일 수 있다. - -## 16. PR 전 체크리스트 - -- [ ] 도메인 단위 패키지 구조를 지켰다. -- [ ] Controller가 얇게 유지된다. -- [ ] 요청 DTO에 Bean Validation을 적용했다. -- [ ] `@RequestBody`에는 `@Valid`를 붙였다. -- [ ] Entity를 API 응답으로 직접 반환하지 않는다. -- [ ] Service에서 직접 `RuntimeException`을 던지지 않는다. -- [ ] 예외 응답은 `global.error`를 통해 통일된다. -- [ ] 검증 오류는 백엔드에서 `C001` 등 `ErrorCode` 기반 응답으로 처리되며, 프론트가 확인할 `code`가 포함된다. -- [ ] 새 에러코드를 추가했다면 프론트 메시지 매핑 필요 여부를 PR에 적었다. -- [ ] 목록 API는 `Pageable`/`Page`를 사용한다. -- [ ] 민감정보가 로그/응답에 노출되지 않는다. -- [ ] 작은 테스트와 전체 테스트를 실행했다. -- [ ] docs/API/기획 변경이 필요한 경우 docs 레포에 반영했다. From 514a02564780a8c18bfcd3836db57bfdcaf55791 Mon Sep 17 00:00:00 2001 From: jowoojin030 Date: Wed, 8 Jul 2026 17:15:48 +0900 Subject: [PATCH 06/93] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A0=9C=EC=B6=9C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Region/User 레포지토리 및 신규 에러코드 추가 온보딩 프로필 생성에 필요한 조회/검증용 리포지토리와, 중복 프로필·존재하지 않는 지역코드에 대응하는 에러코드를 추가한다. Refs #13 * feat: UserProfile 생성 팩토리 메서드 추가 Entity에 setter를 두지 않는 규칙을 지키기 위해, User/Region/온보딩 입력값으로 UserProfile을 생성하는 정적 팩토리 메서드 create()를 추가한다. Refs #13 * feat: 온보딩 프로필 제출 API 추가 POST /api/v1/users/{userId}/profile 엔드포인트를 추가한다. 사용자 존재 여부, 중복 프로필 여부, 지역코드 유효성을 검증한 뒤 UserProfile을 생성한다. 인증 기능이 아직 없어 userId는 임시로 경로변수로 받으며, 인증 도입 후 principal 기반으로 교체할 예정이다(TODO 명시). Closes #13 * test: 온보딩 프로필 제출 API 테스트 추가 OnboardingService 단위테스트(정상 생성, 사용자 없음, 중복 프로필, 지역코드 없음)와 OnboardingController MockMvc 테스트(정상 요청 201, 필수값 누락 400)를 추가한다. Refs #13 * fix: 관심분야/관심키워드를 콤마 문자열 대신 배열로 주고받도록 수정 온보딩 화면에서 categories(대분류 5개 중 최대 3개)/keywords(19개 중 선택)는 체크박스 다중선택이라, 요청/응답 모두 List으로 받고 내려준다. 콤마 결합 문자열은 UserProfile 저장 포맷일 뿐이므로 서비스 계층에서만 join/split 처리한다. Refs #13 * fix: 프로필 저장 시 유니크 제약 위반을 PROFILE_ALREADY_EXISTS로 통일 existsByUserId() 확인과 save() 사이에 레이스가 있어, 동시 요청에서는 user_profiles.user_id UNIQUE 제약 위반이 DataIntegrityViolationException으로 전파되어 GlobalExceptionHandler의 일반 Exception 처리(500)로 떨어졌다. save()를 감싸 같은 도메인 에러(PROFILE_ALREADY_EXISTS, 409)로 변환한다. Refs #13 --- .../policy/exception/PolicyErrorCode.java | 3 +- .../bop/youthpick/policy/repository/.gitkeep | 0 .../policy/repository/RegionRepository.java | 7 ++ .../bop/youthpick/user/controller/.gitkeep | 0 .../user/controller/OnboardingController.java | 35 ++++++ .../java/com/bop/youthpick/user/dto/.gitkeep | 0 .../user/dto/OnboardingProfileRequest.java | 37 ++++++ .../user/dto/OnboardingProfileResponse.java | 38 ++++++ .../youthpick/user/entity/UserProfile.java | 20 +++ .../youthpick/user/exception/UserError.java | 3 +- .../bop/youthpick/user/repository/.gitkeep | 0 .../repository/UserProfileRepository.java | 9 ++ .../user/repository/UserRepository.java | 7 ++ .../com/bop/youthpick/user/service/.gitkeep | 0 .../user/service/OnboardingService.java | 65 ++++++++++ .../controller/OnboardingControllerTest.java | 87 +++++++++++++ .../user/service/OnboardingServiceTest.java | 116 ++++++++++++++++++ 17 files changed, 425 insertions(+), 2 deletions(-) delete mode 100644 src/main/java/com/bop/youthpick/policy/repository/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java delete mode 100644 src/main/java/com/bop/youthpick/user/controller/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/user/controller/OnboardingController.java delete mode 100644 src/main/java/com/bop/youthpick/user/dto/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java create mode 100644 src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java delete mode 100644 src/main/java/com/bop/youthpick/user/repository/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java create mode 100644 src/main/java/com/bop/youthpick/user/repository/UserRepository.java delete mode 100644 src/main/java/com/bop/youthpick/user/service/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/user/service/OnboardingService.java create mode 100644 src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java index 56e0ec3..0807e64 100644 --- a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -12,7 +12,8 @@ public enum PolicyErrorCode implements ErrorCode { POLICY_NOT_FOUND("P001", "일치하는 정책이 존재하지 않습니다.", HttpStatus.NOT_FOUND), - POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT); + POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT), + REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND); private final String code; private final String message; diff --git a/src/main/java/com/bop/youthpick/policy/repository/.gitkeep b/src/main/java/com/bop/youthpick/policy/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java new file mode 100644 index 0000000..75e4ffe --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.Region; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionRepository extends JpaRepository { +} diff --git a/src/main/java/com/bop/youthpick/user/controller/.gitkeep b/src/main/java/com/bop/youthpick/user/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java b/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java new file mode 100644 index 0000000..b45e674 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.user.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.user.dto.OnboardingProfileRequest; +import com.bop.youthpick.user.dto.OnboardingProfileResponse; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.service.OnboardingService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class OnboardingController { + + private final OnboardingService onboardingService; + + // TODO: 인증 도입 후 @PathVariable userId를 인증 principal 기반으로 교체한다. + @PostMapping("/{userId}/profile") + public ResponseEntity> submit( + @PathVariable Long userId, + @Valid @RequestBody OnboardingProfileRequest request + ) { + UserProfile profile = onboardingService.submit(userId, request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(OnboardingProfileResponse.from(profile))); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/.gitkeep b/src/main/java/com/bop/youthpick/user/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java new file mode 100644 index 0000000..56a8974 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java @@ -0,0 +1,37 @@ +package com.bop.youthpick.user.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * categories/keywords는 온보딩 화면에서 체크박스로 다중 선택되는 값이라 + * 콤마 문자열이 아니라 배열로 받는다. 콤마 결합(저장 포맷)은 서비스 계층 책임이다. + * categories는 대분류 5개 중 최대 3개, keywords는 전체 19개 중 선택. + */ +public record OnboardingProfileRequest( + @NotNull(message = "출생연도는 필수입니다.") + @Min(value = 1950, message = "출생연도가 올바르지 않습니다.") + @Max(value = 2015, message = "출생연도가 올바르지 않습니다.") + Integer birthYear, + + @NotBlank(message = "거주 지역은 필수입니다.") + @Size(max = 10, message = "지역 코드 형식이 올바르지 않습니다.") + String regionCode, + + @Size(max = 16, message = "취업상태 코드 형식이 올바르지 않습니다.") + String employmentStatus, + + @Size(max = 16, message = "학력 코드 형식이 올바르지 않습니다.") + String educationLevel, + + @Size(max = 3, message = "관심분야는 최대 3개까지 선택할 수 있습니다.") + List<@NotBlank String> categories, + + @Size(max = 19, message = "관심키워드는 최대 19개까지 선택할 수 있습니다.") + List<@NotBlank String> keywords +) { +} diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java new file mode 100644 index 0000000..763509e --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java @@ -0,0 +1,38 @@ +package com.bop.youthpick.user.dto; + +import com.bop.youthpick.user.entity.UserProfile; +import java.util.Arrays; +import java.util.List; + +public record OnboardingProfileResponse( + Long id, + Long userId, + Integer birthYear, + String regionCode, + String employmentStatus, + String educationLevel, + List categories, + List keywords, + String status +) { + public static OnboardingProfileResponse from(UserProfile profile) { + return new OnboardingProfileResponse( + profile.getId(), + profile.getUser().getId(), + profile.getBirthYear(), + profile.getRegion().getCode(), + profile.getEmploymentStatus(), + profile.getEducationLevel(), + splitToList(profile.getCategories()), + splitToList(profile.getKeywords()), + profile.getStatus() + ); + } + + private static List splitToList(String commaSeparated) { + if (commaSeparated == null || commaSeparated.isBlank()) { + return List.of(); + } + return Arrays.asList(commaSeparated.split(",")); + } +} diff --git a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java index d014da4..bf781a8 100644 --- a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -60,4 +60,24 @@ public class UserProfile extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + public static UserProfile create( + User user, + Region region, + Integer birthYear, + String employmentStatus, + String educationLevel, + String categories, + String keywords + ) { + UserProfile profile = new UserProfile(); + profile.user = user; + profile.region = region; + profile.birthYear = birthYear; + profile.employmentStatus = employmentStatus; + profile.educationLevel = educationLevel; + profile.categories = categories; + profile.keywords = keywords; + return profile; + } } diff --git a/src/main/java/com/bop/youthpick/user/exception/UserError.java b/src/main/java/com/bop/youthpick/user/exception/UserError.java index 2a9805b..3ba5ef9 100644 --- a/src/main/java/com/bop/youthpick/user/exception/UserError.java +++ b/src/main/java/com/bop/youthpick/user/exception/UserError.java @@ -10,7 +10,8 @@ public enum UserError implements ErrorCode { // --- 사용자(user) --- USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), - DUPLICATE_USERNAME(HttpStatus.CONFLICT, "U002", "이미 사용 중인 아이디입니다."); + DUPLICATE_USERNAME(HttpStatus.CONFLICT, "U002", "이미 사용 중인 아이디입니다."), + PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 온보딩 프로필이 등록되어 있습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/bop/youthpick/user/repository/.gitkeep b/src/main/java/com/bop/youthpick/user/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java new file mode 100644 index 0000000..097cb9d --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.user.repository; + +import com.bop.youthpick.user.entity.UserProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserProfileRepository extends JpaRepository { + + boolean existsByUserId(Long userId); +} diff --git a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java new file mode 100644 index 0000000..be79029 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.user.repository; + +import com.bop.youthpick.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} diff --git a/src/main/java/com/bop/youthpick/user/service/.gitkeep b/src/main/java/com/bop/youthpick/user/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java new file mode 100644 index 0000000..95154d3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java @@ -0,0 +1,65 @@ +package com.bop.youthpick.user.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.user.dto.OnboardingProfileRequest; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserProfileRepository; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OnboardingService { + + private final UserRepository userRepository; + private final UserProfileRepository userProfileRepository; + private final RegionRepository regionRepository; + + @Transactional + public UserProfile submit(Long userId, OnboardingProfileRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + + if (userProfileRepository.existsByUserId(userId)) { + throw new UserException(UserError.PROFILE_ALREADY_EXISTS); + } + + Region region = regionRepository.findById(request.regionCode()) + .orElseThrow(() -> new CustomException(PolicyErrorCode.REGION_NOT_FOUND)); + + UserProfile profile = UserProfile.create( + user, + region, + request.birthYear(), + request.employmentStatus(), + request.educationLevel(), + joinToCommaString(request.categories()), + joinToCommaString(request.keywords()) + ); + + try { + return userProfileRepository.save(profile); + } catch (DataIntegrityViolationException e) { + // existsByUserId() 확인 이후 다른 트랜잭션이 먼저 저장한 경우 + // (user_profiles.user_id UNIQUE 위반). 같은 도메인 에러로 통일한다. + throw new UserException(UserError.PROFILE_ALREADY_EXISTS); + } + } + + private String joinToCommaString(List values) { + if (values == null || values.isEmpty()) { + return null; + } + return String.join(",", values); + } +} diff --git a/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java new file mode 100644 index 0000000..406091a --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java @@ -0,0 +1,87 @@ +package com.bop.youthpick.user.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.service.OnboardingService; +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.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = OnboardingController.class) +@AutoConfigureMockMvc(addFilters = false) +class OnboardingControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OnboardingService onboardingService; + + private static final String VALID_BODY = """ + { + "birthYear": 2000, + "regionCode": "11110", + "employmentStatus": "EMPLOYED", + "educationLevel": "UNIVERSITY", + "categories": ["취업", "주거"], + "keywords": ["청년", "공모전"] + } + """; + + @Test + void 유효한_요청이면_201과_생성된_프로필을_반환한다() throws Exception { + UserProfile profile = mock(UserProfile.class); + User user = mock(User.class); + Region region = mock(Region.class); + when(user.getId()).thenReturn(1L); + when(region.getCode()).thenReturn("11110"); + when(profile.getId()).thenReturn(10L); + when(profile.getUser()).thenReturn(user); + when(profile.getRegion()).thenReturn(region); + when(profile.getBirthYear()).thenReturn(2000); + when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); + when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); + when(profile.getCategories()).thenReturn("취업,주거"); + when(profile.getKeywords()).thenReturn("청년,공모전"); + when(profile.getStatus()).thenReturn("COMPLETED"); + when(onboardingService.submit(eq(1L), any())).thenReturn(profile); + + mockMvc.perform(post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id").value(10)) + .andExpect(jsonPath("$.data.userId").value(1)) + .andExpect(jsonPath("$.data.regionCode").value("11110")) + .andExpect(jsonPath("$.data.categories[0]").value("취업")) + .andExpect(jsonPath("$.data.categories[1]").value("주거")); + } + + @Test + void 출생연도가_없으면_400과_C001을_반환한다() throws Exception { + String invalidBody = """ + { + "regionCode": "11110" + } + """; + + mockMvc.perform(post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidBody)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } +} diff --git a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java new file mode 100644 index 0000000..4dcef0c --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java @@ -0,0 +1,116 @@ +package com.bop.youthpick.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.user.dto.OnboardingProfileRequest; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserProfileRepository; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +@ExtendWith(MockitoExtension.class) +class OnboardingServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserProfileRepository userProfileRepository; + + @Mock + private RegionRepository regionRepository; + + private OnboardingService onboardingService; + + private static final Long USER_ID = 1L; + private static final OnboardingProfileRequest REQUEST = new OnboardingProfileRequest( + 2000, "11110", "EMPLOYED", "UNIVERSITY", + List.of("취업", "주거"), List.of("청년", "공모전") + ); + + @BeforeEach + void setUp() { + onboardingService = new OnboardingService(userRepository, userProfileRepository, regionRepository); + } + + @Test + void 온보딩_프로필을_정상적으로_생성한다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(false); + when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.of(mock(Region.class))); + when(userProfileRepository.save(any(UserProfile.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + UserProfile result = onboardingService.submit(USER_ID, REQUEST); + + assertThat(result.getBirthYear()).isEqualTo(REQUEST.birthYear()); + assertThat(result.getEmploymentStatus()).isEqualTo(REQUEST.employmentStatus()); + assertThat(result.getCategories()).isEqualTo("취업,주거"); + assertThat(result.getKeywords()).isEqualTo("청년,공모전"); + } + + @Test + void 존재하지_않는_사용자면_USER_NOT_FOUND_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.USER_NOT_FOUND); + } + + @Test + void 이미_프로필이_있으면_PROFILE_ALREADY_EXISTS_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(true); + + assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.PROFILE_ALREADY_EXISTS); + } + + @Test + void 저장_시점에_동시요청으로_유니크_제약이_위반되면_PROFILE_ALREADY_EXISTS_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(false); + when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.of(mock(Region.class))); + when(userProfileRepository.save(any(UserProfile.class))) + .thenThrow(new DataIntegrityViolationException("uk_user_profiles_user")); + + assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.PROFILE_ALREADY_EXISTS); + } + + @Test + void 존재하지_않는_지역코드면_REGION_NOT_FOUND_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(false); + when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.REGION_NOT_FOUND); + } +} From 81349a940050008fac06522bdf435c2a305e7c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Wed, 8 Jul 2026 17:20:18 +0900 Subject: [PATCH 07/93] =?UTF-8?q?chore:=20=EB=B0=B1=EC=97=94=EB=93=9C=20CI?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(GitHub=20Actions)=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 백엔드 CI 워크플로 추가 (GitHub Actions) dev/main 대상 PR·push에서 JDK 21(Temurin)로 ./gradlew build를 실행해 빌드·테스트를 자동 검증한다. 테스트는 H2 in-memory로 돌고 Redis 연결은 lazy라 외부 인프라 없이 통과한다. concurrency로 중복 실행을 취소하고, 실패 시 테스트 리포트를 아티팩트로 업로드한다. Co-Authored-By: Claude Opus 4.8 * chore: CI를 테스트 대신 Spotless 포맷 검사로 변경 - Spotless(google-java-format) 플러그인 추가, spotlessCheck를 CI 단계로 사용 - CI에서 ./gradlew build(테스트 포함) 제거 → spotlessCheck만 실행 - 기존 34개 java 파일을 google-java-format에 맞게 일괄 리포맷(spotlessApply) Co-Authored-By: Claude Opus 4.8 * chore: CI 최소권한/타임아웃/자격증명 정리 및 .editorconfig 추가 CodeRabbit 리뷰 반영: - 워크플로 permissions를 contents: read로 제한 - format job에 timeout-minutes 15 지정 - checkout에 persist-credentials: false 설정 google-java-format(2-space)과 일치하도록 .editorconfig 추가. Co-Authored-By: Claude Opus 4.8 * chore: CI에 빌드(assemble) 체크 추가 포맷 검사에 더해 컴파일/패키징 검증을 위해 ./gradlew assemble을 실행한다. 테스트는 CI에서 제외하므로 build 대신 assemble을 사용하고, job 이름을 Format & Build로 변경했다. Co-Authored-By: Claude Opus 4.8 * chore: CI에 H2 테스트 실행 추가 - 테스트 스텝 복원: ./gradlew build로 컴파일/패키징 + 테스트 실행 (spotlessCheck는 앞 스텝과 중복이라 -x로 제외) - 테스트가 H2 test 프로파일(application-test.yml, create-drop)을 쓰도록 YouthpickApplicationTests에 @ActiveProfiles("test") 추가 - 테스트 실패 시 리포트 아티팩트 업로드 복원 Co-Authored-By: Claude Opus 4.8 * chore: 포맷을 google-java-format AOSP(4-space)로 변경 - Spotless 설정을 googleJavaFormat().aosp()로 변경 - 기존 java 파일을 4-space로 일괄 리포맷 - .editorconfig의 java 들여쓰기를 4-space로 갱신 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .editorconfig | 26 +++++++ .github/workflows/ci.yml | 56 +++++++++++++++ build.gradle | 11 +++ .../bop/youthpick/YouthpickApplication.java | 1 - .../auth/exception/AuthErrorCode.java | 6 +- .../youthpick/board/entity/Attachment.java | 8 +-- .../bop/youthpick/board/entity/Comment.java | 7 +- .../com/bop/youthpick/board/entity/Post.java | 9 +-- .../youthpick/board/entity/PostCategory.java | 4 +- .../youthpick/global/common/ApiResponse.java | 13 ++-- .../global/config/JpaAuditingConfig.java | 3 +- .../config/RestAuthenticationEntryPoint.java | 15 ++-- .../global/config/SecurityConfig.java | 44 ++++++------ .../youthpick/global/entity/BaseEntity.java | 5 +- .../global/error/CustomException.java | 9 ++- .../bop/youthpick/global/error/ErrorCode.java | 8 ++- .../youthpick/global/error/ErrorResponse.java | 46 +++---------- .../global/error/GlobalExceptionHandler.java | 69 ++++++++++--------- .../com/bop/youthpick/log/entity/AppLog.java | 8 +-- .../bop/youthpick/log/entity/SearchLog.java | 8 +-- .../policy/entity/ApplicationChecklist.java | 7 +- .../policy/entity/ApplicationStatus.java | 8 +-- .../bop/youthpick/policy/entity/Policy.java | 17 ++--- .../policy/entity/PolicyApplication.java | 14 ++-- .../youthpick/policy/entity/PolicyRegion.java | 15 ++-- .../policy/entity/PolicyVisibility.java | 8 +-- .../bop/youthpick/policy/entity/Region.java | 5 +- .../policy/exception/PolicyErrorCode.java | 7 +- .../bop/youthpick/sync/entity/BatchMode.java | 3 +- .../youthpick/sync/entity/BatchStatus.java | 5 +- .../sync/entity/PolicyBatchHistory.java | 8 +-- .../com/bop/youthpick/user/entity/Role.java | 3 +- .../com/bop/youthpick/user/entity/User.java | 13 ++-- .../youthpick/user/entity/UserProfile.java | 10 +-- .../youthpick/user/exception/UserError.java | 2 +- .../youthpick/YouthpickApplicationTests.java | 6 +- 36 files changed, 267 insertions(+), 220 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0eb60a9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Java: google-java-format AOSP(Spotless) 기준 4-space 들여쓰기 +[*.java] +indent_style = space +indent_size = 4 + +# YAML / JSON: 2-space +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +# Gradle 빌드 스크립트 +[*.gradle] +indent_style = space +indent_size = 4 + +# Markdown: 트레일링 공백은 줄바꿈 의미가 있어 유지 +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b1b1caa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + +# 같은 브랜치/PR에서 새 커밋이 올라오면 이전 실행을 취소해 러너 낭비를 줄인다. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# 최소 권한 원칙: 포맷 검사에는 저장소 읽기 권한만 필요하다. +permissions: + contents: read + +jobs: + check: + name: Format, Build & Test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # 체크아웃 이후 GITHUB_TOKEN을 로컬 git 설정에 남기지 않는다. + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # google-java-format 기준으로 포맷 준수 여부만 검사한다. 위반 시 spotlessApply 안내와 함께 실패한다. + - name: Format Check (spotless) + run: ./gradlew spotlessCheck --no-daemon + + # 컴파일/패키징 + 테스트 검증. 테스트는 H2 in-memory(test 프로파일)로 외부 인프라 없이 돈다. + - name: Build & Test (H2) + run: ./gradlew build --no-daemon -x spotlessCheck + + - name: Upload test report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test + retention-days: 7 diff --git a/build.gradle b/build.gradle index 4df6efe..36117ac 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.16' id 'io.spring.dependency-management' version '1.1.7' + id 'com.diffplug.spotless' version '7.0.2' } group = 'com.bop' @@ -40,3 +41,13 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +spotless { + java { + target 'src/**/*.java' + googleJavaFormat().aosp() + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} diff --git a/src/main/java/com/bop/youthpick/YouthpickApplication.java b/src/main/java/com/bop/youthpick/YouthpickApplication.java index d74d7de..72af0da 100644 --- a/src/main/java/com/bop/youthpick/YouthpickApplication.java +++ b/src/main/java/com/bop/youthpick/YouthpickApplication.java @@ -9,5 +9,4 @@ public class YouthpickApplication { public static void main(String[] args) { SpringApplication.run(YouthpickApplication.class, args); } - } diff --git a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java index fbb06eb..55be90b 100644 --- a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java @@ -1,11 +1,9 @@ package com.bop.youthpick.auth.exception; -import org.springframework.http.HttpStatus; - import com.bop.youthpick.global.error.ErrorCode; - import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor @@ -14,6 +12,6 @@ public enum AuthErrorCode implements ErrorCode { INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "A002", "아이디 또는 비밀번호가 올바르지 않습니다."); private final HttpStatus status; - private final String code; + private final String code; private final String message; } diff --git a/src/main/java/com/bop/youthpick/board/entity/Attachment.java b/src/main/java/com/bop/youthpick/board/entity/Attachment.java index 7322876..dd6e8a8 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Attachment.java +++ b/src/main/java/com/bop/youthpick/board/entity/Attachment.java @@ -16,12 +16,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 게시글 첨부파일. - * [미정] 첨부 대상이 게시글로 확정되지 않음 — 대상 바뀌면 FK만 교체. - */ +/** 게시글 첨부파일. [미정] 첨부 대상이 게시글로 확정되지 않음 — 대상 바뀌면 FK만 교체. */ @Entity -@Table(name = "attachments", +@Table( + name = "attachments", indexes = @Index(name = "idx_attachments_post", columnList = "post_id")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/bop/youthpick/board/entity/Comment.java b/src/main/java/com/bop/youthpick/board/entity/Comment.java index 4c92c87..b0bc6e8 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Comment.java +++ b/src/main/java/com/bop/youthpick/board/entity/Comment.java @@ -17,12 +17,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 게시글 댓글. parent가 NULL이면 최상위, 있으면 대댓글(1단). - */ +/** 게시글 댓글. parent가 NULL이면 최상위, 있으면 대댓글(1단). */ @Entity -@Table(name = "comments", - indexes = @Index(name = "idx_comments_post", columnList = "post_id")) +@Table(name = "comments", indexes = @Index(name = "idx_comments_post", columnList = "post_id")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Comment extends BaseEntity { diff --git a/src/main/java/com/bop/youthpick/board/entity/Post.java b/src/main/java/com/bop/youthpick/board/entity/Post.java index dbdf46b..dd55064 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Post.java +++ b/src/main/java/com/bop/youthpick/board/entity/Post.java @@ -20,14 +20,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 정책 기반 게시판 글. policy가 NULL이면 자유글. - * 정책 행을 재활용하지 않고 FK로 참조만 — 정책(배치가 주인)과 - * 게시글(유저가 주인)은 수명이 다르므로. - */ +/** 정책 기반 게시판 글. policy가 NULL이면 자유글. 정책 행을 재활용하지 않고 FK로 참조만 — 정책(배치가 주인)과 게시글(유저가 주인)은 수명이 다르므로. */ @Entity -@Table(name = "posts", - indexes = @Index(name = "idx_posts_policy", columnList = "policy_id")) +@Table(name = "posts", indexes = @Index(name = "idx_posts_policy", columnList = "policy_id")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Post extends BaseEntity { diff --git a/src/main/java/com/bop/youthpick/board/entity/PostCategory.java b/src/main/java/com/bop/youthpick/board/entity/PostCategory.java index 1895c21..b3a48c5 100644 --- a/src/main/java/com/bop/youthpick/board/entity/PostCategory.java +++ b/src/main/java/com/bop/youthpick/board/entity/PostCategory.java @@ -1,5 +1,7 @@ package com.bop.youthpick.board.entity; public enum PostCategory { - QUESTION, REVIEW, FREE + QUESTION, + REVIEW, + FREE } diff --git a/src/main/java/com/bop/youthpick/global/common/ApiResponse.java b/src/main/java/com/bop/youthpick/global/common/ApiResponse.java index 9fd2034..5ddac55 100644 --- a/src/main/java/com/bop/youthpick/global/common/ApiResponse.java +++ b/src/main/java/com/bop/youthpick/global/common/ApiResponse.java @@ -3,16 +3,11 @@ import java.util.Map; /** - * 성공 응답 공통 봉투. 컨트롤러는 엔티티를 직접 반환하지 않고 항상 이 타입으로 감싼다. - * 대부분의 경우 (기본 http 상태 코드가 200인 경우) 응답 Response로 사용하면 된다. - * 상태 코드가 필요한 경우, ResponseEntity> ?에 타입을 넣고 맞춰서 사용 - * meta에는 page, totalCount 등 data가 아닌데 필요한 부가정보를 등록 - * 에러 응답은 이 타입이 아니라 {@code global.error.ErrorResponse} 형식으로 내려간다. + * 성공 응답 공통 봉투. 컨트롤러는 엔티티를 직접 반환하지 않고 항상 이 타입으로 감싼다. 대부분의 경우 (기본 http 상태 코드가 200인 경우) 응답 Response로 + * 사용하면 된다. 상태 코드가 필요한 경우, ResponseEntity> ?에 타입을 넣고 맞춰서 사용 meta에는 page, totalCount 등 + * data가 아닌데 필요한 부가정보를 등록 에러 응답은 이 타입이 아니라 {@code global.error.ErrorResponse} 형식으로 내려간다. */ -public record ApiResponse( - T data, - Map meta -) { +public record ApiResponse(T data, Map meta) { public static ApiResponse ok(T data) { return new ApiResponse<>(data, Map.of()); } diff --git a/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java b/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java index d794b62..b5c75e2 100644 --- a/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java @@ -5,5 +5,4 @@ @Configuration @EnableJpaAuditing -public class JpaAuditingConfig { -} +public class JpaAuditingConfig {} diff --git a/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java b/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java index adbd507..bf0409b 100644 --- a/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java +++ b/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java @@ -16,13 +16,12 @@ /** * 인증되지 않은 요청이 보호된 엔드포인트에 닿았을 때 호출되는 진입 거부 처리기. * - *

Spring Security 필터 단계에서 인증 실패(토큰 없음/만료 등)가 발생하면, 요청은 컨트롤러에 - * 도달하지 못하므로 {@code GlobalExceptionHandler}(@RestControllerAdvice)가 잡지 못한다. - * 그래서 여기서 직접 401 + {@link ErrorResponse} JSON을 응답 스트림에 써넣어, - * 컨트롤러 층의 에러 응답과 동일한 포맷을 유지한다. + *

Spring Security 필터 단계에서 인증 실패(토큰 없음/만료 등)가 발생하면, 요청은 컨트롤러에 도달하지 못하므로 {@code + * GlobalExceptionHandler}(@RestControllerAdvice)가 잡지 못한다. 그래서 여기서 직접 401 + {@link ErrorResponse} + * JSON을 응답 스트림에 써넣어, 컨트롤러 층의 에러 응답과 동일한 포맷을 유지한다. * - *

기본 동작은 Security가 HTML 로그인 페이지로 리다이렉트하는 것인데, REST API에서는 - * 그게 아니라 일관된 JSON 에러가 필요하므로 이 클래스를 SecurityConfig에 등록한다. + *

기본 동작은 Security가 HTML 로그인 페이지로 리다이렉트하는 것인데, REST API에서는 그게 아니라 일관된 JSON 에러가 필요하므로 이 클래스를 + * SecurityConfig에 등록한다. */ @Component @RequiredArgsConstructor @@ -34,8 +33,8 @@ public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { public void commence( HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException - ) throws IOException { + AuthenticationException authException) + throws IOException { ErrorCode errorCode = AuthErrorCode.UNAUTHORIZED; response.setStatus(errorCode.getStatus().value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index bd3577a..b051ae3 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -13,20 +13,23 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; /** - * 보안 기본 골격. REST API 기준으로 세션을 쓰지 않고(STATELESS), CSRF는 끄고, CORS만 켠다. - * 인증 실패는 {@link RestAuthenticationEntryPoint}가 JSON 401로 응답하도록 미리 연결해 둔다. + * 보안 기본 골격. REST API 기준으로 세션을 쓰지 않고(STATELESS), CSRF는 끄고, CORS만 켠다. 인증 실패는 {@link + * RestAuthenticationEntryPoint}가 JSON 401로 응답하도록 미리 연결해 둔다. * *

현재 상태: 개발용(모든 요청 허용)

- * 인증 기능(로그인)이 아직 없으므로, 팀원들이 각자 API를 개발·테스트할 수 있도록 - * {@code anyRequest().permitAll()}로 전부 열어 둔다. 보안 자체는 아래 절차로 언제든 켤 수 있다. + * + * 인증 기능(로그인)이 아직 없으므로, 팀원들이 각자 API를 개발·테스트할 수 있도록 {@code anyRequest().permitAll()}로 전부 열어 둔다. 보안 + * 자체는 아래 절차로 언제든 켤 수 있다. * *

인증 적용 절차 (팀원의 auth 코드가 준비되면)

+ * *
    - *
  1. {@code com.bop.youthpick.auth} 패키지(JwtAuthenticationFilter 등)를 추가한다. - * JWT/Redis 관련 의존성(jjwt 등)과 설정(application.yml의 youthpick.auth.*)도 함께 채운다.
  2. - *
  3. 아래 "AUTH ON" 주석 블록을 활성화하고, 현재 "DEV: 전부 허용" 블록을 제거한다. - * (JwtAuthenticationFilter 주입 필드 + addFilterBefore + 공개경로/authenticated)
  4. + *
  5. {@code com.bop.youthpick.auth} 패키지(JwtAuthenticationFilter 등)를 추가한다. JWT/Redis 관련 의존성(jjwt + * 등)과 설정(application.yml의 youthpick.auth.*)도 함께 채운다. + *
  6. 아래 "AUTH ON" 주석 블록을 활성화하고, 현재 "DEV: 전부 허용" 블록을 제거한다. (JwtAuthenticationFilter 주입 필드 + + * addFilterBefore + 공개경로/authenticated) *
+ * * 이렇게 두면 EntryPoint·CORS·STATELESS 골격은 그대로 재사용되고, 바뀌는 건 인가 규칙과 필터뿐이다. */ @Configuration @@ -41,16 +44,16 @@ public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> csrf.disable()) + http.csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(exception -> exception.authenticationEntryPoint(restAuthenticationEntryPoint)); + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling( + exception -> + exception.authenticationEntryPoint(restAuthenticationEntryPoint)); // ===== DEV: 전부 허용 (로그인 기능 생기기 전까지 개발 편의용) ===== - http.authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ); + http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); // ===== [AUTH ON] 2단계: 위 DEV 블록을 지우고 아래를 활성화한다 ===== // http.authorizeHttpRequests(auth -> auth @@ -69,7 +72,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // .anyRequest().authenticated() // ); // http.addFilterBefore(jwtAuthenticationFilter, - // org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); + // + // org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -78,11 +82,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); // 프론트엔드 개발 서버 주소. 실제 배포 도메인에 맞게 조정한다. - configuration.setAllowedOrigins(List.of( - "http://localhost:3000", - "http://localhost:5173" - )); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:5173")); + configuration.setAllowedMethods( + List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept")); configuration.setExposedHeaders(List.of("Location")); configuration.setAllowCredentials(false); diff --git a/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java b/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java index 132dccd..aed3c1e 100644 --- a/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java +++ b/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java @@ -9,10 +9,7 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -/** - * 생성/수정 시각 공통 필드. soft delete(deletedAt)는 테이블마다 유무가 달라 - * 각 엔티티가 직접 선언한다. - */ +/** 생성/수정 시각 공통 필드. soft delete(deletedAt)는 테이블마다 유무가 달라 각 엔티티가 직접 선언한다. */ @Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/com/bop/youthpick/global/error/CustomException.java b/src/main/java/com/bop/youthpick/global/error/CustomException.java index f44c62f..5ca9ec6 100644 --- a/src/main/java/com/bop/youthpick/global/error/CustomException.java +++ b/src/main/java/com/bop/youthpick/global/error/CustomException.java @@ -3,12 +3,11 @@ import lombok.Getter; /** - * 비즈니스 규칙 위반 예외. 서비스 계층에서 던지고 - * {@link GlobalExceptionHandler}가 {@link ErrorResponse}로 변환한다. + * 비즈니스 규칙 위반 예외. 서비스 계층에서 던지고 {@link GlobalExceptionHandler}가 {@link ErrorResponse}로 변환한다. * - *

사용: {@code throw new CustomException(ErrorCode.USER_NOT_FOUND);} - * 의미가 분명한 경우 {@link ResourceNotFoundException}/{@link ConflictException}을 사용한다. - * 서비스에서 raw {@code RuntimeException}으로 비즈니스 예외를 던지지 않는다. + *

사용: {@code throw new CustomException(ErrorCode.USER_NOT_FOUND);} 의미가 분명한 경우 {@link + * ResourceNotFoundException}/{@link ConflictException}을 사용한다. 서비스에서 raw {@code RuntimeException}으로 + * 비즈니스 예외를 던지지 않는다. */ @Getter public class CustomException extends RuntimeException { diff --git a/src/main/java/com/bop/youthpick/global/error/ErrorCode.java b/src/main/java/com/bop/youthpick/global/error/ErrorCode.java index cc2d287..790c454 100644 --- a/src/main/java/com/bop/youthpick/global/error/ErrorCode.java +++ b/src/main/java/com/bop/youthpick/global/error/ErrorCode.java @@ -3,7 +3,9 @@ import org.springframework.http.HttpStatus; public interface ErrorCode { - HttpStatus getStatus(); - String getCode(); // 임의의 코드를 정하세요. - String getMessage(); + HttpStatus getStatus(); + + String getCode(); // 임의의 코드를 정하세요. + + String getMessage(); } diff --git a/src/main/java/com/bop/youthpick/global/error/ErrorResponse.java b/src/main/java/com/bop/youthpick/global/error/ErrorResponse.java index 658f0f5..d3379f5 100644 --- a/src/main/java/com/bop/youthpick/global/error/ErrorResponse.java +++ b/src/main/java/com/bop/youthpick/global/error/ErrorResponse.java @@ -4,46 +4,28 @@ import java.util.List; /** - * 공통 에러 응답 계약. 프론트가 {@code code} 기준으로 메시지를 매핑할 수 있도록 - * 모든 에러 응답은 {@code code}를 필수로 포함한다. validation 실패 시 {@code errors}에 - * 필드 단위 상세가 담긴다. - * 예시보고 이해하시면 됩니다. - * POST /api/v1/users body: - * {"username":"hong","password":"","email":"hong@a.com"} - * { - * "status": 400, - * "code": "C001", - * "message": "입력 형태가 올바르지 - * "errors": [ - * { "field": "password", "value": "", "reason": "비밀번호는 필수입니다." } - * ], - * "timestamp": "2026-06-30T14:13:40.005" - * } + * 공통 에러 응답 계약. 프론트가 {@code code} 기준으로 메시지를 매핑할 수 있도록 모든 에러 응답은 {@code code}를 필수로 포함한다. validation + * 실패 시 {@code errors}에 필드 단위 상세가 담긴다. 예시보고 이해하시면 됩니다. POST /api/v1/users body: + * {"username":"hong","password":"","email":"hong@a.com"} { "status": 400, "code": "C001", + * "message": "입력 형태가 올바르지 "errors": [ { "field": "password", "value": "", "reason": "비밀번호는 필수입니다." + * } ], "timestamp": "2026-06-30T14:13:40.005" } * - * GET /api/v1/users/999 - * { - * "status": 404, - * "code": "U001", - * "message": "사용자를 찾을 수 없습니다.", - * "errors": [], - * "timestamp": "2026-06-30T14:16:00.900" - * } + *

GET /api/v1/users/999 { "status": 404, "code": "U001", "message": "사용자를 찾을 수 없습니다.", "errors": + * [], "timestamp": "2026-06-30T14:16:00.900" } */ public record ErrorResponse( int status, String code, String message, List errors, - LocalDateTime timestamp -) { + LocalDateTime timestamp) { public static ErrorResponse of(ErrorCode errorCode) { return new ErrorResponse( errorCode.getStatus().value(), errorCode.getCode(), errorCode.getMessage(), List.of(), - LocalDateTime.now() - ); + LocalDateTime.now()); } public static ErrorResponse of(ErrorCode errorCode, List errors) { @@ -52,15 +34,9 @@ public static ErrorResponse of(ErrorCode errorCode, List error errorCode.getCode(), errorCode.getMessage(), errors, - LocalDateTime.now() - ); + LocalDateTime.now()); } /** 내부 exception class, SQL, stack trace, secret 값은 넣지 않는다. */ - public record FieldErrorDetail( - String field, - String value, - String reason - ) { - } + public record FieldErrorDetail(String field, String value, String reason) {} } diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index 65eff60..58a218e 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -2,11 +2,9 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import java.util.List; import lombok.Getter; import lombok.RequiredArgsConstructor; - -import java.util.List; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; @@ -15,44 +13,50 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; /** - * 전역 예외 처리. 모든 컨트롤러 예외를 여기서 {@link ErrorResponse}로 변환한다. - * 컨트롤러는 try-catch로 에러 응답을 직접 만들지 않는다(여기로 위임). + * 전역 예외 처리. 모든 컨트롤러 예외를 여기서 {@link ErrorResponse}로 변환한다. 컨트롤러는 try-catch로 에러 응답을 직접 만들지 않는다(여기로 + * 위임). */ @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException exception) { + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException exception) { BindingResult bindingResult = exception.getBindingResult(); - List details = bindingResult.getFieldErrors() - .stream() - .map(error -> new ErrorResponse.FieldErrorDetail( - error.getField(), - error.getRejectedValue() == null ? "" : error.getRejectedValue().toString(), - error.getDefaultMessage() - )) - .toList(); + List details = + bindingResult.getFieldErrors().stream() + .map( + error -> + new ErrorResponse.FieldErrorDetail( + error.getField(), + error.getRejectedValue() == null + ? "" + : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .toList(); ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; - return ResponseEntity - .status(errorCode.getStatus()) + return ResponseEntity.status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode, details)); } @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolation(ConstraintViolationException exception) { - List details = exception.getConstraintViolations() - .stream() - .map(violation -> new ErrorResponse.FieldErrorDetail( - fieldName(violation), - violation.getInvalidValue() == null ? "" : violation.getInvalidValue().toString(), - violation.getMessage() - )) - .toList(); + public ResponseEntity handleConstraintViolation( + ConstraintViolationException exception) { + List details = + exception.getConstraintViolations().stream() + .map( + violation -> + new ErrorResponse.FieldErrorDetail( + fieldName(violation), + violation.getInvalidValue() == null + ? "" + : violation.getInvalidValue().toString(), + violation.getMessage())) + .toList(); ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; - return ResponseEntity - .status(errorCode.getStatus()) + return ResponseEntity.status(errorCode.getStatus()) .body(ErrorResponse.of(errorCode, details)); } @@ -60,18 +64,14 @@ public ResponseEntity handleConstraintViolation(ConstraintViolati @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException(CustomException exception) { ErrorCode errorCode = exception.getErrorCode(); - return ResponseEntity - .status(errorCode.getStatus()) - .body(ErrorResponse.of(errorCode)); + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } // 개발자가 예상치 못한 에러 @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { ErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; - return ResponseEntity - .status(errorCode.getStatus()) - .body(ErrorResponse.of(errorCode)); + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } private String fieldName(ConstraintViolation violation) { @@ -86,7 +86,8 @@ private String fieldName(ConstraintViolation violation) { @RequiredArgsConstructor @Getter enum GlobalErrorCode implements ErrorCode { - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), + INTERNAL_SERVER_ERROR( + HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."); private final HttpStatus status; diff --git a/src/main/java/com/bop/youthpick/log/entity/AppLog.java b/src/main/java/com/bop/youthpick/log/entity/AppLog.java index dc7a814..7ffcd58 100644 --- a/src/main/java/com/bop/youthpick/log/entity/AppLog.java +++ b/src/main/java/com/bop/youthpick/log/entity/AppLog.java @@ -15,12 +15,10 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -/** - * 앱 에러/요청 로그. userId는 연관관계 없이 값만 저장 — - * 로그는 유저 삭제와 무관하게 보존하고, 비로그인/배치는 NULL. - */ +/** 앱 에러/요청 로그. userId는 연관관계 없이 값만 저장 — 로그는 유저 삭제와 무관하게 보존하고, 비로그인/배치는 NULL. */ @Entity -@Table(name = "app_logs", +@Table( + name = "app_logs", indexes = @Index(name = "idx_app_logs_created", columnList = "created_at")) @EntityListeners(AuditingEntityListener.class) @Getter diff --git a/src/main/java/com/bop/youthpick/log/entity/SearchLog.java b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java index d088720..bce49cc 100644 --- a/src/main/java/com/bop/youthpick/log/entity/SearchLog.java +++ b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java @@ -15,12 +15,10 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -/** - * 검색 로그 (0건율·인기검색어 집계 원본. 집계 후 기한 지나면 삭제). - * userId는 연관관계 없이 값만 — 비로그인 검색은 NULL. - */ +/** 검색 로그 (0건율·인기검색어 집계 원본. 집계 후 기한 지나면 삭제). userId는 연관관계 없이 값만 — 비로그인 검색은 NULL. */ @Entity -@Table(name = "search_logs", +@Table( + name = "search_logs", indexes = @Index(name = "idx_search_logs_created", columnList = "created_at")) @EntityListeners(AuditingEntityListener.class) @Getter diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java index 0c37772..240f148 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java @@ -16,11 +16,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 신청관리별 준비 체크리스트 (제출서류 등). - */ +/** 신청관리별 준비 체크리스트 (제출서류 등). */ @Entity -@Table(name = "application_checklists", +@Table( + name = "application_checklists", indexes = @Index(name = "idx_application_checklists_app", columnList = "application_id")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java index a393232..b01273c 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java @@ -1,8 +1,8 @@ package com.bop.youthpick.policy.entity; -/** - * 정책 신청관리 상태. INTERESTED(관심)가 구 즐겨찾기 역할. - */ +/** 정책 신청관리 상태. INTERESTED(관심)가 구 즐겨찾기 역할. */ public enum ApplicationStatus { - INTERESTED, APPLIED, COMPLETED + INTERESTED, + APPLIED, + COMPLETED } diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java index 2b07970..e81b0ab 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Policy.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -18,17 +18,18 @@ import lombok.NoArgsConstructor; /** - * 청년정책 (온통청년 API 수집·전처리 결과). - * 각 필드 주석의 코드는 API 원본 키(plcyNo 등) — 매핑표 역할. - * [보류] 필드는 원천 데이터 품질 문제로 판정 로직 사용 금지, 적재만 한다. + * 청년정책 (온통청년 API 수집·전처리 결과). 각 필드 주석의 코드는 API 원본 키(plcyNo 등) — 매핑표 역할. [보류] 필드는 원천 데이터 품질 문제로 판정 로직 + * 사용 금지, 적재만 한다. */ @Entity -@Table(name = "policies", - uniqueConstraints = @UniqueConstraint(name = "uk_policies_policy_no", columnNames = "policy_no"), +@Table( + name = "policies", + uniqueConstraints = + @UniqueConstraint(name = "uk_policies_policy_no", columnNames = "policy_no"), indexes = { - @Index(name = "idx_policies_application_end", columnList = "application_end_date"), - @Index(name = "idx_policies_view_count", columnList = "view_count"), - @Index(name = "idx_policies_category", columnList = "category") + @Index(name = "idx_policies_application_end", columnList = "application_end_date"), + @Index(name = "idx_policies_view_count", columnList = "view_count"), + @Index(name = "idx_policies_category", columnList = "category") }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java index 365ee01..2ff3993 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java @@ -20,14 +20,16 @@ import lombok.NoArgsConstructor; /** - * 사용자별 정책 신청 진행관리 (관심 → 신청 → 완료). - * 기존 favorite_policies를 흡수 — status=INTERESTED가 즐겨찾기. - * UNIQUE(user, policy)로 같은 정책 중복 등록 방지. + * 사용자별 정책 신청 진행관리 (관심 → 신청 → 완료). 기존 favorite_policies를 흡수 — status=INTERESTED가 즐겨찾기. UNIQUE(user, + * policy)로 같은 정책 중복 등록 방지. */ @Entity -@Table(name = "policy_applications", uniqueConstraints = - @UniqueConstraint(name = "uk_policy_applications_user_policy", - columnNames = {"user_id", "policy_id"})) +@Table( + name = "policy_applications", + uniqueConstraints = + @UniqueConstraint( + name = "uk_policy_applications_user_policy", + columnNames = {"user_id", "policy_id"})) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PolicyApplication extends BaseEntity { diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java index 4e00d31..fbad73a 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java @@ -1,6 +1,5 @@ package com.bop.youthpick.policy.entity; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -15,14 +14,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 정책 적용지역 (zipCd 콤마목록 → 행 단위 정규화). - * 배치가 통째로 갈아끼우는 데이터라 타임스탬프/soft delete 없음. - */ +/** 정책 적용지역 (zipCd 콤마목록 → 행 단위 정규화). 배치가 통째로 갈아끼우는 데이터라 타임스탬프/soft delete 없음. */ @Entity -@Table(name = "policy_regions", - uniqueConstraints = @UniqueConstraint(name = "uk_policy_regions", - columnNames = {"policy_id", "region_code"}), +@Table( + name = "policy_regions", + uniqueConstraints = + @UniqueConstraint( + name = "uk_policy_regions", + columnNames = {"policy_id", "region_code"}), indexes = @Index(name = "idx_policy_regions_region", columnList = "region_code")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java index cbfdb20..554b3cb 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java @@ -1,9 +1,7 @@ package com.bop.youthpick.policy.entity; -/** - * 정책 노출 상태. deleted_at 대신 쓰는 이유: - * 배치가 원본 소실 정책을 숨겼다가 재등장 시 되살리는 "상태 전환"이라서. - */ +/** 정책 노출 상태. deleted_at 대신 쓰는 이유: 배치가 원본 소실 정책을 숨겼다가 재등장 시 되살리는 "상태 전환"이라서. */ public enum PolicyVisibility { - VISIBLE, HIDDEN + VISIBLE, + HIDDEN } diff --git a/src/main/java/com/bop/youthpick/policy/entity/Region.java b/src/main/java/com/bop/youthpick/policy/entity/Region.java index c468a6a..6337606 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Region.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Region.java @@ -8,10 +8,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 지역(시군구) 마스터. 행안부 법정동코드 ~250행 1회 적재. - * 코드는 국가 표준 불변값이라 대리키 없이 code가 PK (통화코드 KRW와 같은 급). - */ +/** 지역(시군구) 마스터. 행안부 법정동코드 ~250행 1회 적재. 코드는 국가 표준 불변값이라 대리키 없이 code가 PK (통화코드 KRW와 같은 급). */ @Entity @Table(name = "regions") @Getter diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java index 0807e64..8253e09 100644 --- a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -1,21 +1,18 @@ package com.bop.youthpick.policy.exception; -import org.springframework.http.HttpStatus; - import com.bop.youthpick.global.error.ErrorCode; - import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor public enum PolicyErrorCode implements ErrorCode { - POLICY_NOT_FOUND("P001", "일치하는 정책이 존재하지 않습니다.", HttpStatus.NOT_FOUND), POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT), REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND); private final String code; private final String message; - private final HttpStatus status; + private final HttpStatus status; } diff --git a/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java b/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java index 0139d1c..244944f 100644 --- a/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java +++ b/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java @@ -2,5 +2,6 @@ /** FULL = 전량 수집(기본, missing 감지 가능) / DELTA = 변경분만 */ public enum BatchMode { - FULL, DELTA + FULL, + DELTA } diff --git a/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java b/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java index 651cefa..f850fa0 100644 --- a/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java +++ b/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java @@ -1,5 +1,8 @@ package com.bop.youthpick.sync.entity; public enum BatchStatus { - REQUESTED, RUNNING, SUCCEEDED, FAILED + REQUESTED, + RUNNING, + SUCCEEDED, + FAILED } diff --git a/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java index a5f3e6d..5d357d4 100644 --- a/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java +++ b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java @@ -15,12 +15,12 @@ import lombok.NoArgsConstructor; /** - * 정책 수집 배치 작업 이력 + 실행 요청서(REQUESTED→RUNNING→...). - * 자체 시각(requested/started/finished)을 쓰므로 BaseEntity 미상속. - * 이력은 삭제하지 않는다 — soft delete 없음. + * 정책 수집 배치 작업 이력 + 실행 요청서(REQUESTED→RUNNING→...). 자체 시각(requested/started/finished)을 쓰므로 BaseEntity + * 미상속. 이력은 삭제하지 않는다 — soft delete 없음. */ @Entity -@Table(name = "policy_batch_history", +@Table( + name = "policy_batch_history", indexes = @Index(name = "idx_policy_batch_history_requested", columnList = "requested_at")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/bop/youthpick/user/entity/Role.java b/src/main/java/com/bop/youthpick/user/entity/Role.java index e4a5f84..d7491fc 100644 --- a/src/main/java/com/bop/youthpick/user/entity/Role.java +++ b/src/main/java/com/bop/youthpick/user/entity/Role.java @@ -1,5 +1,6 @@ package com.bop.youthpick.user.entity; public enum Role { - USER, ADMIN + USER, + ADMIN } diff --git a/src/main/java/com/bop/youthpick/user/entity/User.java b/src/main/java/com/bop/youthpick/user/entity/User.java index 29ce1bc..d8f7946 100644 --- a/src/main/java/com/bop/youthpick/user/entity/User.java +++ b/src/main/java/com/bop/youthpick/user/entity/User.java @@ -15,13 +15,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 서비스 회원 (소셜 로그인 전용). - * 식별은 (provider, providerId) — email은 카카오 미제공/미보유 가능이라 NULL 허용. - */ +/** 서비스 회원 (소셜 로그인 전용). 식별은 (provider, providerId) — email은 카카오 미제공/미보유 가능이라 NULL 허용. */ @Entity -@Table(name = "users", uniqueConstraints = - @UniqueConstraint(name = "uk_users_provider", columnNames = {"provider", "provider_id"})) +@Table( + name = "users", + uniqueConstraints = + @UniqueConstraint( + name = "uk_users_provider", + columnNames = {"provider", "provider_id"})) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseEntity { diff --git a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java index bf781a8..d994c80 100644 --- a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -18,12 +18,12 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 온보딩 프로필 (users와 1:1). 맞춤정책 점수계산(REC 6축)의 입력값. - */ +/** 온보딩 프로필 (users와 1:1). 맞춤정책 점수계산(REC 6축)의 입력값. */ @Entity -@Table(name = "user_profiles", uniqueConstraints = - @UniqueConstraint(name = "uk_user_profiles_user", columnNames = "user_id")) +@Table( + name = "user_profiles", + uniqueConstraints = + @UniqueConstraint(name = "uk_user_profiles_user", columnNames = "user_id")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserProfile extends BaseEntity { diff --git a/src/main/java/com/bop/youthpick/user/exception/UserError.java b/src/main/java/com/bop/youthpick/user/exception/UserError.java index 3ba5ef9..f5093b4 100644 --- a/src/main/java/com/bop/youthpick/user/exception/UserError.java +++ b/src/main/java/com/bop/youthpick/user/exception/UserError.java @@ -15,5 +15,5 @@ public enum UserError implements ErrorCode { private final HttpStatus status; private final String code; - private final String message; + private final String message; } diff --git a/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java b/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java index 1e8682b..e79a7a7 100644 --- a/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java +++ b/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java @@ -2,12 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class YouthpickApplicationTests { @Test - void contextLoads() { - } - + void contextLoads() {} } From 6178ec41b104ae515fbeefecd936436a7514a221 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Thu, 9 Jul 2026 16:09:32 +0900 Subject: [PATCH 08/93] =?UTF-8?q?feat:=20Flyway=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=BB=AC=20MySQL=20=EC=A0=84=ED=99=98=20(?= =?UTF-8?q?V1=20=EC=B4=88=EA=B8=B0=20=EC=8A=A4=ED=82=A4=EB=A7=88=20+=20V2?= =?UTF-8?q?=20missing=5Fcount)=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Flyway 도입 및 V1 초기 스키마 마이그레이션 추가 - flyway-core, flyway-mysql 의존성 추가 - docs/schema.sql(PR #10) 기반 V1__init_schema.sql 생성 - 기본 프로필 Flyway 비활성(H2는 ddl-auto 유지), prod(MySQL)에서만 활성 + ddl-auto=validate Co-Authored-By: Claude Fable 5 * feat: 로컬 MySQL 프로파일에 Flyway 적용 및 V2 마이그레이션 추가 - local 프로파일을 H2 -> docker compose MySQL + Flyway(validate)로 전환 - V2: policies.missing_count 추가 (3회 연속 누락 시 HIDDEN 처리용) - 테스트는 H2 유지: test 전용 application.yml이 main 설정을 가리도록 파일명 정렬 - docker-compose.yml MySQL 서비스 활성화 Co-Authored-By: Claude Fable 5 * docs: 로컬 DB 규칙을 Flyway + MySQL 기준으로 갱신 - infra.md: 로컬 기본 H2 문장을 compose MySQL + Flyway(validate)로 교체, 스키마 변경은 새 V{n} 마이그레이션 추가 원칙 명시 - 테스트는 H2 유지 + test 설정 shadowing 주의사항 추가 - AGENTS.md 기술 스택 요약 동기화 Co-Authored-By: Claude Fable 5 * docs: 로컬 DB 기본 계정이 compose 로컬 전용임을 주석으로 명시 CodeRabbit 리뷰 반영: fallback 제거 대신 로컬 전용 기본값임을 문서화. (제거 시 zero-config 로컬 실행이 깨지고, 같은 값이 compose fallback에도 있어 제거 실익이 없음) Co-Authored-By: Claude Fable 5 * docs: application.yml flyway 주석을 변경된 전략에 맞게 갱신 Co-Authored-By: Claude Fable 5 * refac: 리뷰 반영 — 테이블명 2건 변경 - application_checklists -> policy_application_checklists (부모 policy_applications와 prefix 정렬, 기존 단수 prefix 컨벤션 유지) - search_logs -> search_histories - V1 직접 수정: 아직 dev merge 전이라 rename 마이그레이션 대신 V1 정정 (적용된 로컬 DB는 볼륨 리셋 필요 — docker compose down -v) - 엔티티 @Table/@Index, docs/schema.sql 동기화 Co-Authored-By: Claude Fable 5 * chore: spotlessApply 포맷 정리 (CI spotlessJavaCheck 통과용) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: 손정원 --- .claude/rules/infra.md | 7 +- .env.example | 2 +- AGENTS.md | 2 +- build.gradle | 2 + docker-compose.yml | 46 +-- docs/schema.sql | 14 +- .../bop/youthpick/log/entity/SearchLog.java | 4 +- .../policy/entity/ApplicationChecklist.java | 7 +- .../policy/repository/RegionRepository.java | 3 +- .../user/controller/OnboardingController.java | 4 +- .../user/dto/OnboardingProfileRequest.java | 33 +- .../user/dto/OnboardingProfileResponse.java | 6 +- .../youthpick/user/entity/UserProfile.java | 3 +- .../user/repository/UserRepository.java | 3 +- .../user/service/OnboardingService.java | 30 +- src/main/resources/application-local.yml | 16 +- src/main/resources/application.yaml | 3 - src/main/resources/application.yml | 7 + .../db/migration/V1__init_schema.sql | 281 ++++++++++++++++++ .../V2__add_policies_missing_count.sql | 13 + .../controller/OnboardingControllerTest.java | 26 +- .../user/service/OnboardingServiceTest.java | 30 +- src/test/resources/application.yml | 30 ++ 23 files changed, 451 insertions(+), 121 deletions(-) delete mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V1__init_schema.sql create mode 100644 src/main/resources/db/migration/V2__add_policies_missing_count.sql create mode 100644 src/test/resources/application.yml diff --git a/.claude/rules/infra.md b/.claude/rules/infra.md index 6f7de38..d017e49 100644 --- a/.claude/rules/infra.md +++ b/.claude/rules/infra.md @@ -8,16 +8,17 @@ paths: # 실행 / Docker / API 수동 검증 -- **앱(백엔드)은 컨테이너화하지 않는다.** 로컬 IDE / Gradle로 실행하고, `docker-compose.yml`은 로컬 인프라(Redis, 필요 시 MySQL)만 띄운다. +- **앱(백엔드)은 컨테이너화하지 않는다.** 로컬 IDE / Gradle로 실행하고, `docker-compose.yml`은 로컬 인프라(MySQL, Redis)만 띄운다. ```bash -docker compose up -d # Redis (필요 시 MySQL) 기동 +docker compose up -d # MySQL + Redis 기동 (앱 실행 전 필수) docker compose ps docker compose down # 중지 (볼륨 보존) docker compose down -v # 중지 + 볼륨 삭제(초기화) ``` -- 로컬 기본 DB는 H2 in-memory이므로 MySQL 없이도 앱이 뜬다. MySQL로 붙일 때만 compose의 MySQL 서비스와 관련 환경변수를 활성화한다. +- 로컬 앱 실행은 compose MySQL + Flyway를 사용한다. 스키마의 주인은 Flyway 마이그레이션(`db/migration`)이고 JPA는 `ddl-auto: validate`로 검증만 한다. 스키마 변경은 기존 파일 수정이 아니라 새 `V{n}__*.sql` 추가로 한다. +- 테스트는 H2 in-memory(MySQL 모드, `create-drop`)로 Docker 없이 돈다. 테스트 설정은 `src/test/resources/application.yml`이 main 설정을 가리는(shadowing) 구조이므로 main 설정 파일명을 바꾸면 테스트 쪽도 같이 맞춘다. - Compose/인프라 변경 시 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. Docker가 없으면 PR 검증 결과에 명시한다. ## API 수동 검증 diff --git a/.env.example b/.env.example index 6e71343..ba71dfe 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,7 @@ DB_PORT=3306 # ===== Database (앱이 도커 MySQL 에 붙을 때 사용. H2 로 개발하면 비워둬도 됨) ===== DB_URL=jdbc:mysql://localhost:3306/youthpick?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul DB_DRIVER=com.mysql.cj.jdbc.Driver -JPA_DDL_AUTO=update +JPA_DDL_AUTO=validate # ===== Redis ===== REDIS_HOST=localhost diff --git a/AGENTS.md b/AGENTS.md index 23b436f..02836d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - 청년 정책 추천 서비스 **YouthPick** 백엔드 API 서버 - Java 21 · Spring Boot 3.5.16 · Gradle Wrapper(Groovy DSL, `build.gradle`) - Spring Web MVC / Data JPA / Security / Session(Redis) / Validation / Actuator -- DB: 로컬·테스트 H2 in-memory(MySQL 모드), 배포 MySQL — 프로파일/환경변수로 전환(기본 프로파일 `local`) +- DB: 로컬·배포 MySQL + Flyway(`db/migration`, JPA는 validate만) / 테스트만 H2 in-memory(MySQL 모드) — 프로파일/환경변수로 전환(기본 프로파일 `local`) - 인증: Spring Security 세션 기반 + OAuth 소셜 로그인(Google/Naver/Kakao), 세션은 Redis 저장 - 패키지 루트: `com.bop.youthpick` diff --git a/build.gradle b/build.gradle index 36117ac..38fbb8a 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ dependencies { implementation 'org.springframework.session:spring-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.flywaydb:flyway-core' + runtimeOnly 'org.flywaydb:flyway-mysql' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/docker-compose.yml b/docker-compose.yml index 3479de3..a406229 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,28 +8,28 @@ # docker compose down -v # 중지 + 데이터 볼륨 삭제(초기화) services: -# mysql: -# image: mysql:8.4 -# container_name: youthpick-mysql -# environment: -# MYSQL_DATABASE: ${DB_NAME:-youthpick} -# MYSQL_USER: ${DB_USERNAME:-youthpick} -# MYSQL_PASSWORD: ${DB_PASSWORD:-youthpick} -# MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root} -# TZ: Asia/Seoul -# command: -# - --character-set-server=utf8mb4 -# - --collation-server=utf8mb4_unicode_ci -# ports: -# - "${DB_PORT:-3306}:3306" -# volumes: -# - youthpick-mysql-data:/var/lib/mysql -# healthcheck: -# test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASSWORD:-root}"] -# interval: 10s -# timeout: 5s -# retries: 10 -# start_period: 20s + mysql: + image: mysql:8.4 + container_name: youthpick-mysql + environment: + MYSQL_DATABASE: ${DB_NAME:-youthpick} + MYSQL_USER: ${DB_USERNAME:-youthpick} + MYSQL_PASSWORD: ${DB_PASSWORD:-youthpick} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root} + TZ: Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + ports: + - "${DB_PORT:-3306}:3306" + volumes: + - youthpick-mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASSWORD:-root}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s redis: image: redis:7-alpine @@ -45,5 +45,5 @@ services: retries: 10 volumes: -# youthpick-mysql-data: + youthpick-mysql-data: youthpick-redis-data: diff --git a/docs/schema.sql b/docs/schema.sql index a650d99..ab0c529 100644 --- a/docs/schema.sql +++ b/docs/schema.sql @@ -183,10 +183,10 @@ CREATE TABLE policy_applications ( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '사용자별 정책 신청 진행관리 (관심~완료 전 단계, 즐겨찾기 통합)'; -- ------------------------------------------------------------ --- 7. application_checklists — 신청관리 체크리스트 +-- 7. policy_application_checklists — 신청관리 체크리스트 -- p_id → application_id, status TINYINT → is_checked -- ------------------------------------------------------------ -CREATE TABLE application_checklists ( +CREATE TABLE policy_application_checklists ( id BIGINT NOT NULL AUTO_INCREMENT, application_id BIGINT NOT NULL COMMENT 'policy_applications FK', content TEXT NOT NULL COMMENT '체크 항목 내용', @@ -195,8 +195,8 @@ CREATE TABLE application_checklists ( updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at DATETIME NULL COMMENT 'soft delete', PRIMARY KEY (id), - KEY idx_application_checklists_app (application_id), - CONSTRAINT fk_application_checklists_app FOREIGN KEY (application_id) REFERENCES policy_applications (id) + KEY idx_policy_application_checklists_app (application_id), + CONSTRAINT fk_policy_application_checklists_app FOREIGN KEY (application_id) REFERENCES policy_applications (id) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '신청관리별 준비 체크리스트'; -- ------------------------------------------------------------ @@ -302,10 +302,10 @@ CREATE TABLE app_logs ( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '앱 로그 (기한 지나면 삭제)'; -- ------------------------------------------------------------ --- 13. search_logs — 검색 로그 (ERDCloud `검색용 로그`) +-- 13. search_histories — 검색 로그 (ERDCloud `검색용 로그`) -- 개수 VARCHAR → INT, user_id NULL 허용, 물리명 영어로 -- ------------------------------------------------------------ -CREATE TABLE search_logs ( +CREATE TABLE search_histories ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NULL COMMENT '비로그인 NULL. FK 안 걺', query VARCHAR(200) NOT NULL COMMENT '입력 원문', @@ -313,7 +313,7 @@ CREATE TABLE search_logs ( result_count INT NOT NULL COMMENT '검색 결과 건수 (0 = 0건 검색)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), - KEY idx_search_logs_created (created_at) + KEY idx_search_histories_created (created_at) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '검색 로그 (집계 후 원본 정리)'; -- ============================================================ diff --git a/src/main/java/com/bop/youthpick/log/entity/SearchLog.java b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java index bce49cc..0b51059 100644 --- a/src/main/java/com/bop/youthpick/log/entity/SearchLog.java +++ b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java @@ -18,8 +18,8 @@ /** 검색 로그 (0건율·인기검색어 집계 원본. 집계 후 기한 지나면 삭제). userId는 연관관계 없이 값만 — 비로그인 검색은 NULL. */ @Entity @Table( - name = "search_logs", - indexes = @Index(name = "idx_search_logs_created", columnList = "created_at")) + name = "search_histories", + indexes = @Index(name = "idx_search_histories_created", columnList = "created_at")) @EntityListeners(AuditingEntityListener.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java index 240f148..21f0823 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java @@ -19,8 +19,11 @@ /** 신청관리별 준비 체크리스트 (제출서류 등). */ @Entity @Table( - name = "application_checklists", - indexes = @Index(name = "idx_application_checklists_app", columnList = "application_id")) + name = "policy_application_checklists", + indexes = + @Index( + name = "idx_policy_application_checklists_app", + columnList = "application_id")) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ApplicationChecklist extends BaseEntity { diff --git a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java index 75e4ffe..3551f68 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java @@ -3,5 +3,4 @@ import com.bop.youthpick.policy.entity.Region; import org.springframework.data.jpa.repository.JpaRepository; -public interface RegionRepository extends JpaRepository { -} +public interface RegionRepository extends JpaRepository {} diff --git a/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java b/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java index b45e674..84459cb 100644 --- a/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java +++ b/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java @@ -25,9 +25,7 @@ public class OnboardingController { // TODO: 인증 도입 후 @PathVariable userId를 인증 principal 기반으로 교체한다. @PostMapping("/{userId}/profile") public ResponseEntity> submit( - @PathVariable Long userId, - @Valid @RequestBody OnboardingProfileRequest request - ) { + @PathVariable Long userId, @Valid @RequestBody OnboardingProfileRequest request) { UserProfile profile = onboardingService.submit(userId, request); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.ok(OnboardingProfileResponse.from(profile))); diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java index 56a8974..b37c147 100644 --- a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java +++ b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java @@ -8,30 +8,17 @@ import java.util.List; /** - * categories/keywords는 온보딩 화면에서 체크박스로 다중 선택되는 값이라 - * 콤마 문자열이 아니라 배열로 받는다. 콤마 결합(저장 포맷)은 서비스 계층 책임이다. + * categories/keywords는 온보딩 화면에서 체크박스로 다중 선택되는 값이라 콤마 문자열이 아니라 배열로 받는다. 콤마 결합(저장 포맷)은 서비스 계층 책임이다. * categories는 대분류 5개 중 최대 3개, keywords는 전체 19개 중 선택. */ public record OnboardingProfileRequest( @NotNull(message = "출생연도는 필수입니다.") - @Min(value = 1950, message = "출생연도가 올바르지 않습니다.") - @Max(value = 2015, message = "출생연도가 올바르지 않습니다.") - Integer birthYear, - - @NotBlank(message = "거주 지역은 필수입니다.") - @Size(max = 10, message = "지역 코드 형식이 올바르지 않습니다.") - String regionCode, - - @Size(max = 16, message = "취업상태 코드 형식이 올바르지 않습니다.") - String employmentStatus, - - @Size(max = 16, message = "학력 코드 형식이 올바르지 않습니다.") - String educationLevel, - - @Size(max = 3, message = "관심분야는 최대 3개까지 선택할 수 있습니다.") - List<@NotBlank String> categories, - - @Size(max = 19, message = "관심키워드는 최대 19개까지 선택할 수 있습니다.") - List<@NotBlank String> keywords -) { -} + @Min(value = 1950, message = "출생연도가 올바르지 않습니다.") + @Max(value = 2015, message = "출생연도가 올바르지 않습니다.") + Integer birthYear, + @NotBlank(message = "거주 지역은 필수입니다.") @Size(max = 10, message = "지역 코드 형식이 올바르지 않습니다.") + String regionCode, + @Size(max = 16, message = "취업상태 코드 형식이 올바르지 않습니다.") String employmentStatus, + @Size(max = 16, message = "학력 코드 형식이 올바르지 않습니다.") String educationLevel, + @Size(max = 3, message = "관심분야는 최대 3개까지 선택할 수 있습니다.") List<@NotBlank String> categories, + @Size(max = 19, message = "관심키워드는 최대 19개까지 선택할 수 있습니다.") List<@NotBlank String> keywords) {} diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java index 763509e..93c8f42 100644 --- a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java +++ b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java @@ -13,8 +13,7 @@ public record OnboardingProfileResponse( String educationLevel, List categories, List keywords, - String status -) { + String status) { public static OnboardingProfileResponse from(UserProfile profile) { return new OnboardingProfileResponse( profile.getId(), @@ -25,8 +24,7 @@ public static OnboardingProfileResponse from(UserProfile profile) { profile.getEducationLevel(), splitToList(profile.getCategories()), splitToList(profile.getKeywords()), - profile.getStatus() - ); + profile.getStatus()); } private static List splitToList(String commaSeparated) { diff --git a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java index d994c80..d90b111 100644 --- a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -68,8 +68,7 @@ public static UserProfile create( String employmentStatus, String educationLevel, String categories, - String keywords - ) { + String keywords) { UserProfile profile = new UserProfile(); profile.user = user; profile.region = region; diff --git a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java index be79029..3589234 100644 --- a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java +++ b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java @@ -3,5 +3,4 @@ import com.bop.youthpick.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; -public interface UserRepository extends JpaRepository { -} +public interface UserRepository extends JpaRepository {} diff --git a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java index 95154d3..6603182 100644 --- a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java +++ b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java @@ -27,25 +27,29 @@ public class OnboardingService { @Transactional public UserProfile submit(Long userId, OnboardingProfileRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + User user = + userRepository + .findById(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); if (userProfileRepository.existsByUserId(userId)) { throw new UserException(UserError.PROFILE_ALREADY_EXISTS); } - Region region = regionRepository.findById(request.regionCode()) - .orElseThrow(() -> new CustomException(PolicyErrorCode.REGION_NOT_FOUND)); + Region region = + regionRepository + .findById(request.regionCode()) + .orElseThrow(() -> new CustomException(PolicyErrorCode.REGION_NOT_FOUND)); - UserProfile profile = UserProfile.create( - user, - region, - request.birthYear(), - request.employmentStatus(), - request.educationLevel(), - joinToCommaString(request.categories()), - joinToCommaString(request.keywords()) - ); + UserProfile profile = + UserProfile.create( + user, + region, + request.birthYear(), + request.employmentStatus(), + request.educationLevel(), + joinToCommaString(request.categories()), + joinToCommaString(request.keywords())); try { return userProfileRepository.save(profile); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 17c9202..5de5321 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,13 +1,19 @@ +# 로컬 개발: docker compose up -d 로 MySQL/Redis 먼저 띄운 뒤 앱 실행. +# 스키마는 Flyway(db/migration)가 만들고, JPA는 엔티티와 일치하는지 검증만 한다. spring: datasource: - url: ${DB_URL:jdbc:h2:mem:youthpick-local;MODE=MySQL;DATABASE_TO_LOWER=TRUE} - username: ${DB_USERNAME:sa} - password: ${DB_PASSWORD:} - driver-class-name: ${DB_DRIVER:org.h2.Driver} + url: ${DB_URL:jdbc:mysql://localhost:3306/youthpick?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul} + # 기본값 youthpick은 로컬 compose MySQL의 기본 계정(운영 자격증명 아님). + # docker-compose.yml의 fallback과 짝이며, .env로 계정을 바꿨다면 앱에도 같은 값을 env로 넘겨야 한다. + username: ${DB_USERNAME:youthpick} + password: ${DB_PASSWORD:youthpick} + driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} + ddl-auto: ${JPA_DDL_AUTO:validate} show-sql: true + flyway: + enabled: true data: redis: host: ${REDIS_HOST:localhost} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml deleted file mode 100644 index ef46c2a..0000000 --- a/src/main/resources/application.yaml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - profiles: - active: local \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..224faea --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,7 @@ +spring: + profiles: + active: local + flyway: + # 기본 비활성(안전 기본값) — MySQL을 쓰는 프로필(local, 추후 prod)이 각자 true로 켠다. + # test는 H2 + ddl-auto(create-drop)라 꺼진 기본값을 그대로 쓴다. + enabled: false \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..5b74c5a --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,281 @@ +-- ============================================================ +-- V1 — YouthPick 초기 스키마 +-- 원본: docs/schema.sql (2026-07-07 ERD 정리본, PR #10) +-- 대상: MySQL 8.x 전용 (H2 local/test는 Flyway 비활성, ddl-auto 사용) +-- 이 파일은 적용 후 수정 금지 — 변경은 V2+ 마이그레이션으로 추가 +-- ============================================================ + +-- ------------------------------------------------------------ +-- 1. users — 서비스 회원 (소셜 로그인 전용) +-- ------------------------------------------------------------ +CREATE TABLE users ( + id BIGINT NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL COMMENT 'google | naver | kakao', + provider_id VARCHAR(255) NOT NULL COMMENT 'OAuth subject', + email VARCHAR(255) NULL COMMENT 'provider 미제공 시 NULL', + nickname VARCHAR(100) NULL, + role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT 'USER | ADMIN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT '회원탈퇴 soft delete', + PRIMARY KEY (id), + UNIQUE KEY uk_users_provider (provider, provider_id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '서비스 회원'; + +-- ------------------------------------------------------------ +-- 2. regions — 지역 마스터 +-- ------------------------------------------------------------ +CREATE TABLE regions ( + code VARCHAR(10) NOT NULL COMMENT '시군구 코드 5자리 (앞 2자리 = 시도)', + sido_name VARCHAR(30) NOT NULL COMMENT '시도명', + name VARCHAR(50) NOT NULL COMMENT '시군구명', + PRIMARY KEY (code) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '지역 마스터 (행안부 법정동코드 시군구 ~250행)'; + +-- ------------------------------------------------------------ +-- 3. user_profiles — 온보딩 프로필 (users와 1:1) +-- ------------------------------------------------------------ +CREATE TABLE user_profiles ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + birth_year INT NOT NULL COMMENT '출생연도 (REC 나이 20점)', + region_code VARCHAR(10) NOT NULL COMMENT '거주 시군구코드 (REC 지역 25점)', + employment_status VARCHAR(16) NULL COMMENT 'jobCd 1개 (REC 취업 15점)', + education_level VARCHAR(16) NULL COMMENT 'schoolCd 1개 (REC 학력 10점)', + categories VARCHAR(500) NULL COMMENT '관심분야 콤마목록 (REC 분야 20점)', + keywords VARCHAR(700) NULL COMMENT '관심키워드 콤마목록 (REC 키워드 10점)', + status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT '프로필 삭제 soft delete', + PRIMARY KEY (id), + UNIQUE KEY uk_user_profiles_user (user_id), + CONSTRAINT fk_user_profiles_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_user_profiles_region FOREIGN KEY (region_code) REFERENCES regions (code) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '온보딩 프로필 (점수계산 입력값)'; + +-- ------------------------------------------------------------ +-- 4. policies — 청년정책 (물리명 = 영어, 논리명/설명 = COMMENT) +-- ------------------------------------------------------------ +CREATE TABLE policies ( + id BIGINT NOT NULL AUTO_INCREMENT, + policy_no VARCHAR(32) NOT NULL COMMENT 'plcyNo — 온통청년 식별자 (upsert 기준)', + -- 표시/검색 + title VARCHAR(300) NOT NULL COMMENT 'plcyNm — 정책명', + description VARCHAR(2000) NULL COMMENT 'plcyExplnCn — 목록 설명', + support_content TEXT NULL COMMENT 'plcySprtCn — 상세 지원내용', + keywords VARCHAR(500) NULL COMMENT 'plcyKywdNm — 키워드 콤마목록', + category VARCHAR(64) NULL COMMENT 'lclsfNm — 대분류', + middle_category VARCHAR(64) NULL COMMENT 'mclsfNm — 중분류', + organization_name VARCHAR(255) NULL COMMENT 'sprvsnInstCdNm 우선, operInstCdNm fallback', + -- 자격: 자동판정용 + min_age INT NULL COMMENT 'sprtTrgtMinAge — 0/NULL=제한없음', + max_age INT NULL COMMENT 'sprtTrgtMaxAge', + job_codes VARCHAR(255) NULL COMMENT 'jobCd — 콤마 다중', + school_codes VARCHAR(255) NULL COMMENT 'schoolCd — 콤마 다중', + -- 자격: 원문 노출용 + income_condition_code VARCHAR(16) NULL COMMENT 'earnCndSeCd', + income_max_amount INT NULL COMMENT 'earnMaxAmt — 연소득 상한(만원)', + income_etc_content TEXT NULL COMMENT 'earnEtcCn', + marital_status_code VARCHAR(16) NULL COMMENT 'mrgSttsCd', + major_codes VARCHAR(255) NULL COMMENT 'plcyMajorCd — 콤마 다중', + specialization_codes VARCHAR(255) NULL COMMENT 'sbizCd — 콤마 다중', + additional_qualification TEXT NULL COMMENT 'addAplyQlfcCndCn', + participation_restriction TEXT NULL COMMENT 'ptcpPrpTrgtCn', + -- 기간/상태 + application_period_type VARCHAR(16) NULL COMMENT 'aplyPrdSeCd', + application_period_raw VARCHAR(64) NULL COMMENT 'aplyYmd 원문', + application_start_date DATE NULL COMMENT '[파생] 신청 시작일', + application_end_date DATE NULL COMMENT '[파생] 마감일 — D-day 정렬/필터', + business_period_begin DATE NULL COMMENT 'bizPrdBgngYmd', + business_period_end DATE NULL COMMENT 'bizPrdEndYmd', + business_period_etc VARCHAR(64) NULL COMMENT 'bizPrdEtcCn', + -- 규모 + support_scale_count INT NULL COMMENT 'sprtSclCnt — 모집인원', + first_come_first_served BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'sprtArvlSeqYn — 선착순', + -- 링크/신청 + application_url VARCHAR(500) NULL COMMENT 'aplyUrlAddr', + reference_url1 VARCHAR(500) NULL COMMENT 'refUrlAddr1', + reference_url2 VARCHAR(500) NULL COMMENT 'refUrlAddr2', + application_method TEXT NULL COMMENT 'plcyAplyMthdCn', + submission_documents TEXT NULL COMMENT 'sbmsnDcmntCn', + screening_method TEXT NULL COMMENT 'srngMthdCn', + -- 보류 필드 (적재만, 판정 사용 금지) + age_limit_flag VARCHAR(4) NULL COMMENT '[보류] sprtTrgtAgeLmtYn', + income_min_amount INT NULL COMMENT '[보류] earnMinAmt', + support_scale_limit BOOLEAN NULL COMMENT '[보류] sprtSclLmtYn', + operating_institution_name VARCHAR(255) NULL COMMENT '[보류] operInstCdNm', + approval_status_code VARCHAR(16) NULL COMMENT '[보류] plcyAprvSttsCd', + etc_matters TEXT NULL COMMENT '[보류] etcMttrCn', + -- 메타/정렬/운영 + view_count INT NOT NULL DEFAULT 0 COMMENT 'inqCnt — 인기 정렬', + first_registered_at DATETIME NULL COMMENT 'frstRegDt — 최신순 정렬', + last_modified_at DATETIME NULL COMMENT 'lastMdfcnDt — 변경 감지', + visibility VARCHAR(20) NOT NULL DEFAULT 'VISIBLE' COMMENT 'VISIBLE | HIDDEN', + raw_payload LONGTEXT NULL COMMENT 'API 응답 원문 JSON', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_policies_policy_no (policy_no), + KEY idx_policies_application_end (application_end_date), + KEY idx_policies_view_count (view_count), + KEY idx_policies_category (category) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '청년정책 (온통청년 수집·전처리 결과)'; + +-- ------------------------------------------------------------ +-- 5. policy_regions — 정책 적용지역 (N:M) +-- ------------------------------------------------------------ +CREATE TABLE policy_regions ( + id BIGINT NOT NULL AUTO_INCREMENT, + policy_id BIGINT NOT NULL, + region_code VARCHAR(10) NOT NULL COMMENT 'zipCd 분해', + PRIMARY KEY (id), + UNIQUE KEY uk_policy_regions (policy_id, region_code), + KEY idx_policy_regions_region (region_code), + CONSTRAINT fk_policy_regions_policy FOREIGN KEY (policy_id) REFERENCES policies (id), + CONSTRAINT fk_policy_regions_region FOREIGN KEY (region_code) REFERENCES regions (code) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '정책 적용지역'; + +-- ------------------------------------------------------------ +-- 6. policy_applications — 정책 신청관리 (즐겨찾기 통합) +-- ------------------------------------------------------------ +CREATE TABLE policy_applications ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + policy_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'INTERESTED' COMMENT 'INTERESTED(관심=구 즐겨찾기) | APPLIED | COMPLETED', + memo TEXT NULL, + end_at DATETIME NULL COMMENT '마감일 (개인 설정)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '상태 전환(관심→신청→완료) 추적', + deleted_at DATETIME NULL COMMENT '관리 해제 = soft delete. 재등록 시 행 재활성화(UNIQUE 충돌 방지)', + PRIMARY KEY (id), + UNIQUE KEY uk_policy_applications_user_policy (user_id, policy_id), + CONSTRAINT fk_policy_applications_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_policy_applications_policy FOREIGN KEY (policy_id) REFERENCES policies (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '사용자별 정책 신청 진행관리 (관심~완료 전 단계, 즐겨찾기 통합)'; + +-- ------------------------------------------------------------ +-- 7. policy_application_checklists — 신청관리 체크리스트 +-- ------------------------------------------------------------ +CREATE TABLE policy_application_checklists ( + id BIGINT NOT NULL AUTO_INCREMENT, + application_id BIGINT NOT NULL COMMENT 'policy_applications FK', + content TEXT NOT NULL COMMENT '체크 항목 내용', + is_checked BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_policy_application_checklists_app (application_id), + CONSTRAINT fk_policy_application_checklists_app FOREIGN KEY (application_id) REFERENCES policy_applications (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '신청관리별 준비 체크리스트'; + +-- ------------------------------------------------------------ +-- 8. posts — 게시판 +-- ------------------------------------------------------------ +CREATE TABLE posts ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL COMMENT '글쓴이', + policy_id BIGINT NULL COMMENT '연관 정책 (NULL = 자유글)', + category VARCHAR(20) NOT NULL COMMENT 'QUESTION(질문) | REVIEW(후기) | FREE(자유)', + title VARCHAR(100) NOT NULL, + content TEXT NOT NULL, + view_count INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_posts_policy (policy_id), + CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_posts_policy FOREIGN KEY (policy_id) REFERENCES policies (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '정책 기반 게시판 글'; + +-- ------------------------------------------------------------ +-- 9. comments — 댓글 (1단 대댓글) +-- ------------------------------------------------------------ +CREATE TABLE comments ( + id BIGINT NOT NULL AUTO_INCREMENT, + post_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + parent_id BIGINT NULL COMMENT '대댓글 부모 (NULL = 최상위)', + content TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_comments_post (post_id), + CONSTRAINT fk_comments_post FOREIGN KEY (post_id) REFERENCES posts (id), + CONSTRAINT fk_comments_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_comments_parent FOREIGN KEY (parent_id) REFERENCES comments (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '게시글 댓글 (1단 대댓글)'; + +-- ------------------------------------------------------------ +-- 10. attachments — 첨부파일 +-- ------------------------------------------------------------ +CREATE TABLE attachments ( + id BIGINT NOT NULL AUTO_INCREMENT, + post_id BIGINT NOT NULL, + file_url VARCHAR(500) NOT NULL COMMENT '저장 경로/URL', + file_size BIGINT NULL COMMENT 'byte', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL COMMENT 'soft delete', + PRIMARY KEY (id), + KEY idx_attachments_post (post_id), + CONSTRAINT fk_attachments_post FOREIGN KEY (post_id) REFERENCES posts (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '게시글 첨부파일'; + +-- ------------------------------------------------------------ +-- 11. policy_batch_history — 배치 작업 이력 +-- ------------------------------------------------------------ +CREATE TABLE policy_batch_history ( + id BIGINT NOT NULL AUTO_INCREMENT, + mode VARCHAR(20) NOT NULL COMMENT 'FULL | DELTA', + status VARCHAR(20) NOT NULL COMMENT 'REQUESTED | RUNNING | SUCCEEDED | FAILED', + requested_at DATETIME NOT NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + new_count INT NOT NULL DEFAULT 0, + updated_count INT NOT NULL DEFAULT 0, + unchanged_count INT NOT NULL DEFAULT 0, + missing_count INT NOT NULL DEFAULT 0, + error_count INT NOT NULL DEFAULT 0, + failure_message VARCHAR(1000) NULL, + PRIMARY KEY (id), + KEY idx_policy_batch_history_requested (requested_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '정책 수집 배치 이력 (FK 없음 — 독립 이력)'; + +-- ------------------------------------------------------------ +-- 12. app_logs — 앱 에러/요청 로그 +-- ------------------------------------------------------------ +CREATE TABLE app_logs ( + id BIGINT NOT NULL AUTO_INCREMENT, + level VARCHAR(10) NOT NULL COMMENT 'INFO | WARN | ERROR', + message TEXT NOT NULL, + trace_id VARCHAR(64) NULL, + method VARCHAR(10) NULL COMMENT 'GET | POST ...', + uri TEXT NULL, + ip VARCHAR(45) NULL COMMENT 'IPv6 최대 45자', + exception_class VARCHAR(255) NULL, + exception_message TEXT NULL, + stack_trace TEXT NULL, + user_id BIGINT NULL COMMENT '비로그인/배치 = NULL. FK 안 걺', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_app_logs_created (created_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '앱 로그 (기한 지나면 삭제)'; + +-- ------------------------------------------------------------ +-- 13. search_histories — 검색 로그 +-- ------------------------------------------------------------ +CREATE TABLE search_histories ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NULL COMMENT '비로그인 NULL. FK 안 걺', + query VARCHAR(200) NOT NULL COMMENT '입력 원문', + normalized VARCHAR(200) NOT NULL COMMENT '소문자·공백정리 — 집계용', + result_count INT NOT NULL COMMENT '검색 결과 건수 (0 = 0건 검색)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_search_histories_created (created_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '검색 로그 (집계 후 원본 정리)'; diff --git a/src/main/resources/db/migration/V2__add_policies_missing_count.sql b/src/main/resources/db/migration/V2__add_policies_missing_count.sql new file mode 100644 index 0000000..3fe0786 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_policies_missing_count.sql @@ -0,0 +1,13 @@ +-- ============================================================ +-- V2 — policies.missing_count 추가 (2026-07-08 팀 결정) +-- 수집에서 안 보인 연속 횟수. 3회 도달 시 visibility=HIDDEN, +-- 다시 보이면 0 리셋 + VISIBLE (즉시 숨김 금지 — 기획 §6) +-- ※ 건별 변경/파싱에러 기록(policy_sync_events)은 소비자(알림· +-- 품질 리포트)가 생기는 시점에 별도 마이그레이션으로 추가한다. +-- 그전까지 파싱 실패는 log.warn으로만 남긴다. +-- ============================================================ + +ALTER TABLE policies + ADD COLUMN missing_count INT NOT NULL DEFAULT 0 + COMMENT '연속 누락 횟수 — 3회 도달 시 HIDDEN, 재등장 시 0 리셋' + AFTER visibility; \ No newline at end of file diff --git a/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java index 406091a..48dc15c 100644 --- a/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java @@ -24,13 +24,12 @@ @AutoConfigureMockMvc(addFilters = false) class OnboardingControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; - @MockitoBean - private OnboardingService onboardingService; + @MockitoBean private OnboardingService onboardingService; - private static final String VALID_BODY = """ + private static final String VALID_BODY = + """ { "birthYear": 2000, "regionCode": "11110", @@ -59,9 +58,10 @@ class OnboardingControllerTest { when(profile.getStatus()).thenReturn("COMPLETED"); when(onboardingService.submit(eq(1L), any())).thenReturn(profile); - mockMvc.perform(post("/api/v1/users/{userId}/profile", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(VALID_BODY)) + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.id").value(10)) .andExpect(jsonPath("$.data.userId").value(1)) @@ -72,15 +72,17 @@ class OnboardingControllerTest { @Test void 출생연도가_없으면_400과_C001을_반환한다() throws Exception { - String invalidBody = """ + String invalidBody = + """ { "regionCode": "11110" } """; - mockMvc.perform(post("/api/v1/users/{userId}/profile", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(invalidBody)) + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidBody)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("C001")); } diff --git a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java index 4dcef0c..f60e415 100644 --- a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java +++ b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java @@ -29,33 +29,36 @@ @ExtendWith(MockitoExtension.class) class OnboardingServiceTest { - @Mock - private UserRepository userRepository; + @Mock private UserRepository userRepository; - @Mock - private UserProfileRepository userProfileRepository; + @Mock private UserProfileRepository userProfileRepository; - @Mock - private RegionRepository regionRepository; + @Mock private RegionRepository regionRepository; private OnboardingService onboardingService; private static final Long USER_ID = 1L; - private static final OnboardingProfileRequest REQUEST = new OnboardingProfileRequest( - 2000, "11110", "EMPLOYED", "UNIVERSITY", - List.of("취업", "주거"), List.of("청년", "공모전") - ); + private static final OnboardingProfileRequest REQUEST = + new OnboardingProfileRequest( + 2000, + "11110", + "EMPLOYED", + "UNIVERSITY", + List.of("취업", "주거"), + List.of("청년", "공모전")); @BeforeEach void setUp() { - onboardingService = new OnboardingService(userRepository, userProfileRepository, regionRepository); + onboardingService = + new OnboardingService(userRepository, userProfileRepository, regionRepository); } @Test void 온보딩_프로필을_정상적으로_생성한다() { when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(false); - when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.of(mock(Region.class))); + when(regionRepository.findById(REQUEST.regionCode())) + .thenReturn(Optional.of(mock(Region.class))); when(userProfileRepository.save(any(UserProfile.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -92,7 +95,8 @@ void setUp() { void 저장_시점에_동시요청으로_유니크_제약이_위반되면_PROFILE_ALREADY_EXISTS_예외를_던진다() { when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(false); - when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.of(mock(Region.class))); + when(regionRepository.findById(REQUEST.regionCode())) + .thenReturn(Optional.of(mock(Region.class))); when(userProfileRepository.save(any(UserProfile.class))) .thenThrow(new DataIntegrityViolationException("uk_user_profiles_user")); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..14be887 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,30 @@ +# 테스트 전용 — Docker 없이 어디서든(CI 포함) 돌도록 H2 in-memory 사용. +# 테스트 클래스패스의 이 파일이 main의 application.yaml을 대체한다. +spring: + datasource: + url: jdbc:h2:mem:youthpick-test;MODE=MySQL;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + flyway: + enabled: false + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + +youthpick: + oauth: + providers: + google: + client-id: ${GOOGLE_OAUTH_CLIENT_ID:} + client-secret: ${GOOGLE_OAUTH_CLIENT_SECRET:} + naver: + client-id: ${NAVER_OAUTH_CLIENT_ID:} + client-secret: ${NAVER_OAUTH_CLIENT_SECRET:} + kakao: + client-id: ${KAKAO_OAUTH_CLIENT_ID:} + client-secret: ${KAKAO_OAUTH_CLIENT_SECRET:} From c5a9701c5a047be4cda4415fa31ec09625b86db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Fri, 10 Jul 2026 11:08:57 +0900 Subject: [PATCH 09/93] =?UTF-8?q?chore:=20IntelliJ=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20XML=20=EC=B6=94=EA=B0=80=20(googl?= =?UTF-8?q?e-java-format=20AOSP)=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: IntelliJ 코드 스타일 XML 추가 (google-java-format AOSP) - docs/ide/intellij-java-google-aosp-style.xml: 공식 GoogleStyle 스킴을 AOSP(4-space, continuation 8-space)로 변형한 IntelliJ import용 코드 스타일 - docs/ide/README.md: import 방법과 저장 시 자동 포맷 안내 - docs/README.md: 개발 환경 섹션에 IDE 가이드 링크 추가 .idea/는 gitignore 대상이라 커밋하지 않고 import용 XML만 문서 경로에 둔다. Co-Authored-By: Claude Opus 4.8 * chore: dev 잔여 포맷 위반 정리 (spotlessApply) Spotless(AOSP) 설정 이전에 머지된 온보딩 관련 9개 파일이 포맷을 위반해 dev의 spotlessCheck가 실패하던 것을 spotlessApply로 정리했다. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docs/README.md | 6 + docs/ide/README.md | 27 + docs/ide/intellij-java-google-aosp-style.xml | 598 +++++++++++++++++++ 3 files changed, 631 insertions(+) create mode 100644 docs/ide/README.md create mode 100644 docs/ide/intellij-java-google-aosp-style.xml diff --git a/docs/README.md b/docs/README.md index fe42ed7..2f85973 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,12 @@ | [`../AGENTS.md`](../AGENTS.md) | 모든 에이전트/개발자 공통 진입점. 개요 · 검증 명령 · 비협상 규칙 요약 · 규칙 문서 맵 | | [`../CLAUDE.md`](../CLAUDE.md) | Claude Code 진입점. `AGENTS.md`를 import하고 Claude 전용 안내만 추가 | +## 개발 환경 + +| 문서 | 내용 | +|------|------| +| [`ide/README.md`](./ide/README.md) | IntelliJ 코드 스타일(google-java-format AOSP) import 가이드 | + ## 협업 템플릿 (`.github/`) | 파일 | 내용 | diff --git a/docs/ide/README.md b/docs/ide/README.md new file mode 100644 index 0000000..56dd4d3 --- /dev/null +++ b/docs/ide/README.md @@ -0,0 +1,27 @@ +# IntelliJ 코드 스타일 설정 + +CI는 Spotless의 `googleJavaFormat().aosp()`(4-space 들여쓰기, 8-space continuation)로 포맷을 강제한다. IntelliJ 기본 스타일은 이와 달라, 이 스킴을 import해 두면 IDE에서 저장/Reformat 결과가 `./gradlew spotlessCheck`를 그대로 통과한다. + +## Import 방법 + +1. `Settings/Preferences → Editor → Code Style → Java` +2. 스킴 선택 옆 톱니바퀴(⚙️) → **Import Scheme → IntelliJ IDEA code style XML** +3. [`intellij-java-google-aosp-style.xml`](./intellij-java-google-aosp-style.xml) 선택 +4. 스킴 이름 `GoogleStyleAOSP` 확인 후 적용(Apply) + +## 저장 시 자동 포맷 (선택) + +- `Settings/Preferences → Tools → Actions on Save`에서 **Reformat code**, **Optimize imports** 체크 + +## 확인 + +import 후 아무 Java 파일을 `⌥⌘L`(Reformat)로 정리한 뒤 다음이 통과하면 정상이다. + +```bash +./gradlew spotlessCheck +``` + +## 참고 + +- 이 XML은 [google/styleguide](https://github.com/google/styleguide)의 `intellij-java-google-style.xml`을 기반으로, Java 들여쓰기를 AOSP 규칙(4/8-space)에 맞게 수정한 것이다. +- 포맷의 최종 기준(source of truth)은 CI의 Spotless이다. 스킴과 결과가 어긋나면 `./gradlew spotlessApply`로 맞춘다. diff --git a/docs/ide/intellij-java-google-aosp-style.xml b/docs/ide/intellij-java-google-aosp-style.xml new file mode 100644 index 0000000..aa2b611 --- /dev/null +++ b/docs/ide/intellij-java-google-aosp-style.xml @@ -0,0 +1,598 @@ + + + + + +

+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .*:.*Style + + http://schemas.android.com/apk/res/android + + + BY_NAME + +
+
+ + + + .*:layout_width + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_height + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_weight + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_margin + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginTop + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginBottom + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginStart + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginEnd + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginLeft + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_marginRight + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:layout_.* + + http://schemas.android.com/apk/res/android + + + BY_NAME + +
+
+ + + + .*:padding + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingTop + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingBottom + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingStart + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingEnd + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingLeft + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:paddingRight + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .* + http://schemas.android.com/apk/res/android + + + BY_NAME + +
+
+ + + + .* + http://schemas.android.com/apk/res-auto + + + BY_NAME + +
+
+ + + + .* + http://schemas.android.com/tools + + + BY_NAME + +
+
+ + + + .* + .* + + + BY_NAME + +
+ + + + + + + + + + From f7107245f2d70ac251098b14d2adaec75ffe63cc Mon Sep 17 00:00:00 2001 From: myh7754 Date: Fri, 10 Jul 2026 11:53:58 +0900 Subject: [PATCH 10/93] =?UTF-8?q?refac:=20ApiResponse=20meta=EB=A5=BC=20Ma?= =?UTF-8?q?p=EC=97=90=EC=84=9C=20PageMeta=20record=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refac: ApiResponse meta를 Map에서 PageMeta record로 변경 * chore: spotlessApply 포맷 적용 --- .claude/rules/api-design.md | 9 ++--- .../youthpick/global/common/ApiResponse.java | 16 +++++---- .../bop/youthpick/global/common/PageMeta.java | 10 ++++++ .../global/common/ApiResponseTest.java | 36 +++++++++++++++++++ 4 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/global/common/PageMeta.java create mode 100644 src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java diff --git a/.claude/rules/api-design.md b/.claude/rules/api-design.md index 91c0bd0..0c94d13 100644 --- a/.claude/rules/api-design.md +++ b/.claude/rules/api-design.md @@ -46,7 +46,8 @@ public record PolicyCardResponse(Long id, String title, String summary) { ## 공통 응답 봉투 `ApiResponse` (data + meta) -- 단일/객체 데이터는 `ApiResponse.ok(data)`. 페이지 등 부가정보(`page`, `totalCount` 등)는 **`data`가 아니라 `meta`에** 담는다. +- 단일/객체 데이터는 `ApiResponse.ok(data)`. 페이지 부가정보(`page`, `totalCount`, `totalPages`)는 **`data`가 아니라 `meta`(`PageMeta`)에** 담는다. +- `meta`는 Swagger(OpenAPI) 문서에 필드 스키마가 드러나도록 `Map`이 아니라 타입 있는 `PageMeta` record를 사용한다. 새 부가정보가 필요하면 `Map`으로 돌아가지 말고 `PageMeta`에 필드를 추가하거나 전용 meta 타입을 정의한다. - 200 외 상태 코드가 필요하면 `ResponseEntity>`로 감싼다. - 에러 응답은 `ApiResponse`가 아니라 `global.error.ErrorResponse`로 내려간다 (→ `error-handling.md`). @@ -56,11 +57,7 @@ public record PolicyCardResponse(Long id, String title, String summary) { @GetMapping public ApiResponse> search(@PageableDefault(size = 20) Pageable pageable) { Page page = policySearchService.search(pageable); - return ApiResponse.ok(page.getContent(), Map.of( - "page", page.getNumber(), - "totalCount", page.getTotalElements(), - "totalPages", page.getTotalPages() - )); + return ApiResponse.ok(page.getContent(), page); } ``` diff --git a/src/main/java/com/bop/youthpick/global/common/ApiResponse.java b/src/main/java/com/bop/youthpick/global/common/ApiResponse.java index 5ddac55..026ab6f 100644 --- a/src/main/java/com/bop/youthpick/global/common/ApiResponse.java +++ b/src/main/java/com/bop/youthpick/global/common/ApiResponse.java @@ -1,18 +1,20 @@ package com.bop.youthpick.global.common; -import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.data.domain.Page; /** * 성공 응답 공통 봉투. 컨트롤러는 엔티티를 직접 반환하지 않고 항상 이 타입으로 감싼다. 대부분의 경우 (기본 http 상태 코드가 200인 경우) 응답 Response로 - * 사용하면 된다. 상태 코드가 필요한 경우, ResponseEntity> ?에 타입을 넣고 맞춰서 사용 meta에는 page, totalCount 등 - * data가 아닌데 필요한 부가정보를 등록 에러 응답은 이 타입이 아니라 {@code global.error.ErrorResponse} 형식으로 내려간다. + * 사용하면 된다. 상태 코드가 필요한 경우, ResponseEntity> ?에 타입을 넣고 맞춰서 사용. 페이지 목록 응답은 {@code + * ok(data, page)}로 감싸면 meta에 page/totalCount/totalPages가 담긴다. meta가 없으면 응답 JSON에서 생략된다. 에러 응답은 이 + * 타입이 아니라 {@code global.error.ErrorResponse} 형식으로 내려간다. */ -public record ApiResponse(T data, Map meta) { +public record ApiResponse(T data, @JsonInclude(JsonInclude.Include.NON_NULL) PageMeta meta) { public static ApiResponse ok(T data) { - return new ApiResponse<>(data, Map.of()); + return new ApiResponse<>(data, null); } - public static ApiResponse ok(T data, Map meta) { - return new ApiResponse<>(data, meta); + public static ApiResponse ok(T data, Page page) { + return new ApiResponse<>(data, PageMeta.from(page)); } } diff --git a/src/main/java/com/bop/youthpick/global/common/PageMeta.java b/src/main/java/com/bop/youthpick/global/common/PageMeta.java new file mode 100644 index 0000000..c3db325 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/common/PageMeta.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.global.common; + +import org.springframework.data.domain.Page; + +/** 페이지네이션 부가정보. Map 대신 타입을 명시해 Swagger(OpenAPI) 문서에 필드 스키마가 그대로 드러나게 한다. */ +public record PageMeta(int page, long totalCount, int totalPages) { + public static PageMeta from(Page page) { + return new PageMeta(page.getNumber(), page.getTotalElements(), page.getTotalPages()); + } +} diff --git a/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java b/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java new file mode 100644 index 0000000..7b7f607 --- /dev/null +++ b/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java @@ -0,0 +1,36 @@ +package com.bop.youthpick.global.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +class ApiResponseTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void meta_없는_응답은_JSON에서_meta가_생략된다() throws Exception { + String json = objectMapper.writeValueAsString(ApiResponse.ok("hello")); + + JsonNode node = objectMapper.readTree(json); + assertThat(node.get("data").asText()).isEqualTo("hello"); + assertThat(node.has("meta")).isFalse(); + } + + @Test + void 페이지_응답은_meta에_page_totalCount_totalPages가_담긴다() throws Exception { + PageImpl page = new PageImpl<>(List.of("a", "b"), PageRequest.of(1, 2), 5); + + String json = objectMapper.writeValueAsString(ApiResponse.ok(page.getContent(), page)); + + JsonNode meta = objectMapper.readTree(json).get("meta"); + assertThat(meta.get("page").asInt()).isEqualTo(1); + assertThat(meta.get("totalCount").asLong()).isEqualTo(5); + assertThat(meta.get("totalPages").asInt()).isEqualTo(3); + } +} From c730cf013d9c6188087576ff66c7d99baa8fe263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Fri, 10 Jul 2026 13:58:09 +0900 Subject: [PATCH 11/93] =?UTF-8?q?docs:=20API=20=EA=B2=BD=EB=A1=9C=EB=A5=BC?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 프론트 연동 기준 API 명세 문서(api-spec.md) 추가 Closes #31 * docs: API 경로를 엔티티 리소스 중심으로 재설계 /me, /users/{userId} 없이 JWT 인증 정보로 사용자를 식별하고 user-profiles, policy-applications, policy-batch-histories 등 엔티티 리소스명을 경로에 직접 노출하는 방식으로 변경. Closes #31 * docs: OAuth 인가 URL/콜백 파라미터를 실제 구현에 맞게 정정 authorization-url은 redirect_uri/state를 프론트가 넘기는 게 아니라 서버가 고정값(youthpick.oauth.frontend-callback-uri)과 자체 생성 state(Redis TTL 저장)로 처리한다. 콜백도 redirectUri 대신 provider가 돌려준 state를 그대로 받아 CSRF 방어에 쓴다. feat/20-social-login(PR #22) 구현 기준으로 문서만 정정. --- docs/README.md | 7 +- docs/api-spec.md | 372 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 docs/api-spec.md diff --git a/docs/README.md b/docs/README.md index 2f85973..9bda977 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,12 @@ | [`../.claude/rules/testing.md`](../.claude/rules/testing.md) | 테스트 규칙 | | [`../.claude/rules/infra.md`](../.claude/rules/infra.md) | Docker/Compose, API 수동 검증 | +## 설계/명세 문서 + +| 문서 | 내용 | +|------|------| +| [`api-spec.md`](./api-spec.md) | 프론트 연동 기준 API 명세(엔드포인트, 메서드, 권한, 파라미터) | + ## 진입점 문서 (레포 루트) | 문서 | 내용 | @@ -50,7 +56,6 @@ ## 앞으로 추가하면 좋은 문서 (제안) -- `api-spec.md` — API 명세(엔드포인트, 요청/응답, 에러코드 매핑표) - `error-codes.md` — `ErrorCode` 카탈로그와 프론트 메시지 매핑 - `oauth-setup.md` — Google/Naver/Kakao OAuth 앱 등록 및 환경변수 세팅 가이드 - `local-setup.md` — 로컬 실행(H2/Redis/compose) 온보딩 가이드 diff --git a/docs/api-spec.md b/docs/api-spec.md new file mode 100644 index 0000000..2485ee3 --- /dev/null +++ b/docs/api-spec.md @@ -0,0 +1,372 @@ +# YouthPick 백엔드 API 경로 명세 + +Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 API 경로 명세다. 경로는 `YouthPick/back-end`의 엔티티 설계 방향을 반영해 리소스 중심으로 정리했다. 사용자 식별은 JWT 인증 정보에서 얻는 전제이므로 사용자별 API 경로에 `/me` 또는 `/users/{userId}`를 붙이지 않는다. + +백엔드 API를 추가하거나 수정할 때는 이 문서를 기준으로 컨트롤러 경로, HTTP 메서드, 파라미터, 권한을 맞춘다. 공통 응답 래퍼(`ApiResponse`), Bean Validation, 에러코드 체계 같은 구현 규칙은 [`../.claude/rules/api-design.md`](../.claude/rules/api-design.md), [`../.claude/rules/error-handling.md`](../.claude/rules/error-handling.md)를 따르며 이 문서에서 중복 서술하지 않는다. + +## 경로 설계 기준 + +| 기준 | 내용 | +|---|---| +| base path | `/api/v1` | +| 사용자 식별 | JWT 인증 정보에서 식별 | +| 사용자별 엔티티 경로 | `/me`, `/users/{userId}` 없이 엔티티 리소스명 사용 | +| 프로필 엔티티 | `/api/v1/user-profiles` | +| 정책 신청관리 엔티티 | `/api/v1/policy-applications` | +| 정책 배치 이력 엔티티 | `/api/v1/policy-batch-histories` | +| 챗봇 API | FastAPI 직접 호출 경로이므로 Spring Boot 구현 대상과 분리 | + +## API 요약 + +| 기능 | 이름 | 메서드 | 경로 | 권한 | 파라미터 | +|---|---|---|---|---|---| +| 인증 | 로그아웃 | `POST` | `/api/v1/auth/logout` | 회원 | body: refreshToken | +| 인증 | 내 로그인 사용자 조회 | `GET` | `/api/v1/auth/me` | 회원 | 없음 | +| 인증 | OAuth 인가 URL 생성 | `GET` | `/api/v1/auth/oauth/{provider}/authorization-url` | 비회원 | path: provider(kakao, naver, google). 파라미터 없음(state는 서버가 생성해 Redis에 저장, redirect_uri는 서버 설정값 고정) | +| 인증 | 소셜 로그인 콜백 | `POST` | `/api/v1/auth/oauth/{provider}/callback` | 비회원 | path: provider(kakao, naver, google). body: code, state(서버가 발급한 값을 그대로 반환, CSRF 방어용) | +| 인증 | 액세스 토큰 갱신 | `POST` | `/api/v1/auth/token/refresh` | 비회원 | body: refreshToken | +| 인증 | 회원 탈퇴 | `DELETE` | `/api/v1/users` | 회원 | body: confirmText="탈퇴합니다" | +| 온보딩/프로필 | 프로필 선택지 조회 | `GET` | `/api/v1/profile-options` | 비회원 | 없음 | +| 온보딩/프로필 | 내 프로필 조회 | `GET` | `/api/v1/user-profiles` | 회원 | 없음 | +| 온보딩/프로필 | 프로필 저장/수정 | `PUT` | `/api/v1/user-profiles` | 회원 | body: birthYear, region, subRegion, employmentStatus, educationLevel, categories[], keywords[] | +| 정책 검색 | 정책 검색/필터 | `GET` | `/api/v1/policies` | 비회원 | query: keyword 선택, region 선택, category 선택, page 기본 0, size 기본 20, sort 기본 relevance | +| 정책 검색 | 검색어 제안 | `GET` | `/api/v1/policies/search-suggestions` | 비회원 | query: keyword 선택 | +| 정책 검색 | 정책 상세 조회 | `GET` | `/api/v1/policies/{policyId}` | 비회원 | path: policyId | +| 맞춤 추천 | 맞춤정책 조회 | `GET` | `/api/v1/recommended-policies` | 회원 | query: region 선택, category 선택, keyword 선택 | +| 관심 정책 | 관심정책 목록 | `GET` | `/api/v1/policy-applications` | 회원 | 없음 | +| 관심 정책 | 관심정책 등록 | `PUT` | `/api/v1/policy-applications/{policyId}` | 회원 | path: policyId | +| 관심 정책 | 관심정책 해제 | `DELETE` | `/api/v1/policy-applications/{policyId}` | 회원 | path: policyId | +| 읽음 상태 | 읽음 상태 목록 조회 | `GET` | `/api/v1/policy-read-states` | 회원 | query: policyIds 반복 | +| 읽음 상태 | 정책 읽음 처리 | `PUT` | `/api/v1/policy-read-states/{policyId}` | 회원 | path: policyId | +| 정책 비교 | 정책 비교 생성 | `POST` | `/api/v1/policy-comparisons` | 비회원 | body: policyIds[] | +| 정책 비교 | 정책 비교 조회 | `GET` | `/api/v1/policy-comparisons/{comparisonId}` | 비회원 | path: comparisonId | +| 정책 동기화 | 정책 수집 이력 목록 | `GET` | `/api/v1/policy-batch-histories` | 관리자 | query: page 기본 0, size 기본 20 | +| 정책 동기화 | 정책 수집 실행 | `POST` | `/api/v1/policy-batch-histories` | 관리자 | body: mode(FULL 또는 DELTA) | +| 정책 동기화 | 검색 인덱스 재생성 | `POST` | `/api/v1/search-indexes/rebuild` | 관리자 | 없음 | +| 관리자/운영 | 운영 지표 조회 | `GET` | `/api/v1/admin/metrics` | 관리자 | 없음 | +| 챗봇 | 챗봇 일반 질의 | `POST` | `/chat` | 비회원 | body: message, thread_id 선택, user_profile 선택 | +| 챗봇 | 챗봇 스트리밍 질의 | `POST` | `/chat/stream` | 비회원 | body: message, thread_id 선택, user_profile 선택 | + +## 인증 + +### 로그아웃 + +현재 로그인 세션을 종료하고 refresh token을 무효화한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/auth/logout` | +| 권한 | 회원 | +| 파라미터 | body: refreshToken | + +### 내 로그인 사용자 조회 + +현재 액세스 토큰 기준 로그인 사용자와 권한 정보를 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/auth/me` | +| 권한 | 회원 | +| 파라미터 | 없음 | + +### OAuth 인가 URL 생성 + +소셜 로그인 시작을 위한 OAuth 제공자별 인가 URL을 생성한다. CSRF 방어용 `state`는 서버가 생성해 Redis에 짧은 TTL로 저장하고 응답 URL에 포함시킨다. provider로 리다이렉트할 `redirect_uri`도 서버 설정값(`youthpick.oauth.frontend-callback-uri`)으로 고정되어 있어 프론트가 별도로 넘길 파라미터는 없다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/auth/oauth/{provider}/authorization-url` | +| 권한 | 비회원 | +| 파라미터 | path: provider(kakao, naver, google). 그 외 파라미터 없음 | + +### 소셜 로그인 콜백 + +OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 발급받는다. 프론트는 자신의 콜백 라우트(`redirect_uri`)에서 provider가 붙여준 `code`/`state`를 그대로 읽어 이 API로 전달한다. `state`는 authorization-url 발급 시 서버가 준 값과 일치해야 하며, 1회 사용 후 폐기된다(CSRF 방어). + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/auth/oauth/{provider}/callback` | +| 권한 | 비회원 | +| 파라미터 | path: provider(kakao, naver, google). body: code, state | + +### 액세스 토큰 갱신 + +저장된 refresh token으로 새 access token과 refresh token을 발급받는다. 프론트는 이 API를 공통 `requestJson`이 아니라 직접 `fetch`로 호출하므로, 401 재시도 루프 없이 성공 시 새 토큰 쌍을 반환하고 실패 시 클라이언트가 로컬 인증 상태를 지우게 만든다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/auth/token/refresh` | +| 권한 | 비회원 | +| 파라미터 | body: refreshToken | + +### 회원 탈퇴 + +현재 로그인 사용자의 계정을 탈퇴 처리한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `DELETE` | +| 경로 | `/api/v1/users` | +| 권한 | 회원 | +| 파라미터 | body: confirmText="탈퇴합니다" | + +## 온보딩/프로필 + +### 프로필 선택지 조회 + +온보딩 프로필 입력에 필요한 지역, 상태, 카테고리, 키워드 선택지를 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/profile-options` | +| 권한 | 비회원 | +| 파라미터 | 없음 | + +### 내 프로필 조회 + +로그인 사용자의 저장된 정책 추천 프로필을 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/user-profiles` | +| 권한 | 회원 | +| 파라미터 | 없음 | + +### 프로필 저장/수정 + +로그인 사용자의 정책 추천 프로필을 저장하거나 수정한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PUT` | +| 경로 | `/api/v1/user-profiles` | +| 권한 | 회원 | +| 파라미터 | body: birthYear, region, subRegion, employmentStatus, educationLevel, categories[], keywords[] | + +## 정책 검색 + +### 정책 검색/필터 + +정책 목록을 키워드와 필터 조건으로 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policies` | +| 권한 | 비회원 | +| 파라미터 | query: keyword 선택, region 선택, category 선택, page 기본 0, size 기본 20, sort 기본 relevance | + +### 검색어 제안 + +입력 중인 키워드 기준으로 정책 검색어 추천 목록을 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policies/search-suggestions` | +| 권한 | 비회원 | +| 파라미터 | query: keyword 선택 | + +### 정책 상세 조회 + +단일 정책의 상세 정보를 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policies/{policyId}` | +| 권한 | 비회원 | +| 파라미터 | path: policyId | + +## 맞춤 추천 + +### 맞춤정책 조회 + +로그인 사용자의 프로필과 조건을 바탕으로 맞춤 정책을 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/recommended-policies` | +| 권한 | 회원 | +| 파라미터 | query: region 선택, category 선택, keyword 선택 | + +## 관심 정책 + +### 관심정책 목록 + +로그인 사용자가 저장한 관심 정책 목록을 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policy-applications` | +| 권한 | 회원 | +| 파라미터 | 없음 | + +### 관심정책 등록 + +특정 정책을 로그인 사용자의 관심 정책으로 저장한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PUT` | +| 경로 | `/api/v1/policy-applications/{policyId}` | +| 권한 | 회원 | +| 파라미터 | path: policyId | + +### 관심정책 해제 + +특정 정책을 로그인 사용자의 관심 정책 목록에서 제거한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `DELETE` | +| 경로 | `/api/v1/policy-applications/{policyId}` | +| 권한 | 회원 | +| 파라미터 | path: policyId | + +## 읽음 상태 + +### 읽음 상태 목록 조회 + +여러 정책에 대한 로그인 사용자의 읽음 상태를 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policy-read-states` | +| 권한 | 회원 | +| 파라미터 | query: policyIds 반복 | + +### 정책 읽음 처리 + +특정 정책을 읽음 상태로 표시한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PUT` | +| 경로 | `/api/v1/policy-read-states/{policyId}` | +| 권한 | 회원 | +| 파라미터 | path: policyId | + +## 정책 비교 + +### 정책 비교 생성 + +여러 정책 ID를 기준으로 비교 결과를 생성한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/policy-comparisons` | +| 권한 | 비회원 | +| 파라미터 | body: policyIds[] | + +### 정책 비교 조회 + +생성된 정책 비교 결과를 비교 ID로 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policy-comparisons/{comparisonId}` | +| 권한 | 비회원 | +| 파라미터 | path: comparisonId | + +## 정책 동기화 + +### 정책 수집 이력 목록 + +관리자용 정책 동기화 작업 이력 목록을 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policy-batch-histories` | +| 권한 | 관리자 | +| 파라미터 | query: page 기본 0, size 기본 20 | + +### 정책 수집 실행 + +관리자가 정책 데이터 수집 작업을 시작한다. 수집 실행은 같은 컬렉션에 작업 요청을 생성하는 것으로 해석한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/policy-batch-histories` | +| 권한 | 관리자 | +| 파라미터 | body: mode(FULL 또는 DELTA) | + +### 검색 인덱스 재생성 + +관리자가 정책 검색 인덱스 재생성 작업을 요청한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/search-indexes/rebuild` | +| 권한 | 관리자 | +| 파라미터 | 없음 | + +## 관리자/운영 + +### 운영 지표 조회 + +관리자 화면에서 사용할 정책, 사용자, 동기화 운영 지표를 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/admin/metrics` | +| 권한 | 관리자 | +| 파라미터 | 없음 | + +## 챗봇 FastAPI + +아래 경로는 프론트가 FastAPI 챗봇 서비스로 직접 호출하는 API다. Spring Boot 백엔드 엔티티 기반 경로와 별도로 취급하며, Spring Controller로 중복 구현하지 않는다. + +### 챗봇 일반 질의 + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/chat` | +| 권한 | 비회원 | +| 파라미터 | body: message, thread_id 선택, user_profile 선택 | + +### 챗봇 스트리밍 질의 + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/chat/stream` | +| 권한 | 비회원 | +| 파라미터 | body: message, thread_id 선택, user_profile 선택 | + +## 기존/보류 명세 + +아래 항목은 Notion DB에 유지된 기존 데이터다. 현재 프론트 필요 API 경로 변경 작업의 직접 대상이 아니므로 별도 확인 후 사용한다. + +| 기능 | 이름 | 메서드 | 경로 | 권한 | 파라미터 | +|---|---|---|---|---|---| +| 온보딩/프로필 | 프로필 삭제 | `DELETE` | `/api/v1/me/profile` | 회원 | 없음 | +| 정책 동기화 | 정책 수집 상세 | `GET` | `/api/v1/admin/policy-sync-jobs/{jobId}` | 관리자 | path: jobId | +| 관리자/운영 | 헬스 체크 | `GET` | `/api/v1/health` | 공통 | 없음 | +| 챗봇 | 챗봇 프로필 사용 동의 설정 | `POST` | `/api/v1/policy-chat/profile-consent` | 회원 | 명세 기준 확인 필요 | +| 챗봇 | 정책 탐색 챗봇 질문 | `POST` | `/api/v1/policy-chat/queries` | 비회원 | 명세 기준 확인 필요 | + +## 백엔드 구현 메모 + +사용자별 데이터는 JWT에서 얻은 사용자 식별자를 서비스 계층에서 사용한다. URL 경로는 `user-profiles`, `policy-applications`, `policy-read-states`처럼 엔티티 리소스를 직접 드러내고, 컨트롤러 내부에서 인증 principal과 매핑한다. + +`policy_applications`는 기존 즐겨찾기 개념을 흡수한 신청관리 엔티티다. 관심정책 등록과 해제는 별도 `favorites` 리소스보다 `policy-applications`의 생성/삭제 또는 상태 변경으로 구현하는 편이 현재 스키마와 맞다. + +`policy-batch-histories`는 정책 수집 작업 이력을 나타낸다. 수집 실행은 같은 컬렉션에 작업 요청을 생성하는 `POST /api/v1/policy-batch-histories`로 해석한다. From 4a59c2b360bcf6d1127a95a61df5ef26c3cfa614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Mon, 13 Jul 2026 15:10:50 +0900 Subject: [PATCH 12/93] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4,=20?= =?UTF-8?q?=EA=B5=AC=EA=B8=80,=20=EB=84=A4=EC=9D=B4=EB=B2=84=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 카카오, 구글, 네이버 소셜 로그인 인증 기능 추가 authorization-url 발급 -> provider 콜백 -> access token 교환/유저정보 조회 -> 회원 findOrCreate -> Redis 세션(Spring Session) 저장 흐름으로 세션 기반 로그인을 구현한다. /api/v1/auth/me, /api/v1/auth/logout 추가, SecurityConfig는 STATELESS를 걷어내고 세션(IF_REQUIRED) + SecurityContextRepository로 정리한다. Closes #20 Co-Authored-By: Claude Sonnet 5 * refac: 소셜 로그인 인증 방식을 세션에서 JWT(access/refresh)로 전환 Redis 세션 대신 JWT access/refresh token으로 인증한다. refresh token은 사용자별로 Redis에 TTL과 함께 저장하고, /api/v1/auth/token/refresh에서 서명·만료 검증 후 Redis 저장값과 일치할 때만 rotate한다. OAuth state도 세션이 아니라 Redis(OAuthStateStore)로 옮겨 완전한 stateless 구조로 만든다. - SecurityConfig: STATELESS 유지, JwtAuthenticationFilter로 Authorization Bearer 헤더를 해석해 SecurityContext를 채운다. - AuthController: 콜백을 POST(JSON body)로 변경해 토큰을 직접 반환하고, token/refresh 엔드포인트를 추가한다. - OAuth redirect_uri를 프론트 콜백 라우트로 통일(youthpick.oauth.frontend-callback-uri). - build.gradle: spring-session-data-redis 제거, jjwt 추가. - AGENTS.md / auth-security.md / security-review 스킬 문서를 JWT 구조에 맞게 갱신. - 부수 수정: UserRepositoryTest가 @DataJpaTest 슬라이스에서 JpaAuditingConfig를 못 읽어 간헐적으로 실패하던 문제를 @Import로 고침. Co-Authored-By: Claude Sonnet 5 * feat: 로그인 사용자 조회용 @CurrentUser 파라미터 리졸버 추가 컨트롤러에서 @AuthenticationPrincipal + authService.getCurrentUser 수동 호출을 반복하는 대신 @CurrentUser User user로 바로 주입받도록 한다. SecurityContext의 AuthPrincipal에서 userId를 읽어 조회하고, 인증되지 않았으면 AuthException(A001)을 던진다. 인가 규칙은 여전히 SecurityConfig의 경로 매처로 관리한다(어노테이션 기반 인가 데코레이터는 시도했다가 경로기반으로 되돌렸다). WebMvcConfigurer로 전역 등록되어 모든 @WebMvcTest 슬라이스에 로드되므로, @CurrentUser를 쓰지 않는 OnboardingControllerTest에도 슬라이스 부팅을 위해 @MockitoBean AuthService를 추가했다. Co-Authored-By: Claude Sonnet 5 * refac: @CurrentUser가 User 엔티티 대신 userId(Long)를 반환하도록 변경 CurrentUserArgumentResolver가 DB 조회 없이 SecurityContext의 AuthPrincipal.userId()를 그대로 주입한다. User 엔티티가 필요한 곳(AuthController.me)은 Controller에서 authService.getCurrentUser(userId)를 직접 호출한다. 리졸버가 AuthService 의존을 잃어서 OnboardingControllerTest의 불필요한 @MockitoBean AuthService도 제거했다. Co-Authored-By: Claude Sonnet 5 * fix: CodeRabbit 리뷰 반영 — 패키지 구조/보안/트랜잭션 이슈 수정 - 패키지 구조: auth.client/config/jwt를 도메인 우선 구조(controller/service/ repository/dto/entity/exception)에 맞춰 auth.service로 통합 - OAuthClient: 전역 RestClient connect/read timeout(spring.http.client.*) 추가, provider 응답의 providerId가 blank면 예외로 거부(계정 오귀속 방지) - OAuthProperties.Registration: toString에서 clientSecret 마스킹 - RefreshTokenStore: Redis에 refresh token 원문 대신 SHA-256 해시만 저장, 재발급을 Lua 스크립트 기반 compare-and-rotate로 원자화해 동시 refresh 요청의 중복 발급을 막음 - AuthService: login()에서 provider 통신을 트랜잭션 밖으로 분리(DB 커넥션 점유 방지), find-or-create 유니크 제약 충돌 시 재조회로 복구, refresh()를 RefreshTokenStore.rotate 기반으로 재작성, logout()이 Redis만 다뤄 @Transactional 대상이 아님을 문서화 - application-local.yml: JWT_SECRET 하드코딩 fallback 제거(다른 DB 계정 fallback과 달리 토큰 위조 위험과 직결되는 값이라 환경변수 필수로 변경) - CSRF 관련 지적(logout/SecurityConfig)은 세션 기반 코드 리뷰 시점의 지적으로, 현재는 STATELESS + Bearer 토큰 인증이라 CSRF 대상이 아니어서 반영하지 않음 Co-Authored-By: Claude Sonnet 5 * feat: API 명세 기준 인가 규칙 및 관리자(403) 처리 추가 docs/api-spec-db.md의 권한 컬럼(관리자/회원/비회원/공통)을 SecurityConfig의 authorizeHttpRequests에 반영한다. 관리자 전용 경로는 hasRole("ADMIN")으로 막고, 아직 컨트롤러가 없는 명세상 경로도 미리 규칙을 걸어 컨트롤러가 추가돼도 기본이 열려 있지 않게 한다. - AuthErrorCode.FORBIDDEN(A008, 403) + RestAccessDeniedHandler 추가: 권한 부족 시 RestAuthenticationEntryPoint(401)와 동일한 JSON 포맷으로 응답 - SecurityConfigTest: 관리자/회원/공개 경로의 401·403·인가통과 케이스 검증 Co-Authored-By: Claude Sonnet 5 * fix: GitHub 리뷰 반영 — JWT 파싱 방어/성능, 테스트 보강 - JwtTokenProvider.getUserId: subject가 숫자가 아니면 NumberFormatException이 그대로 전파되던 것을 AuthException(INVALID_TOKEN)으로 감싼다 - validateAccessToken/validateRefreshToken이 검증된 Claims를 반환하도록 바꿔 JwtAuthenticationFilter/AuthService.refresh()가 토큰을 요청당 1회만 파싱 하도록 정리(기존엔 검증+userId+role 조회에서 최대 3번 재파싱) - JwtAuthenticationFilter: role이 null인 토큰은 "ROLE_null" 권한을 만들지 않고 인증을 채우지 않은 채 통과시킨다 - OAuthClientTest: Google userinfo 정규화 테스트 추가(Kakao/Naver만 있었음) Co-Authored-By: Claude Sonnet 5 * docs: API 명세 DB에 OAuth 콜백/토큰 재발급 엔드포인트 추가 이번 PR에서 새로 구현한 POST /api/v1/auth/oauth/{provider}/callback, POST /api/v1/auth/token/refresh가 명세 표에 빠져 있어 SecurityConfig의 permitAll() 대상과 문서가 불일치했다. 두 엔드포인트 모두 비회원(로그인 전) 호출이라 권한은 비회원으로 기재한다. Co-Authored-By: Claude Sonnet 5 * feat: 회원 탈퇴(DELETE /api/v1/users) 경로/권한 선점 docs/31-api-spec의 새 명세는 회원 탈퇴 경로를 /api/v1/auth/me가 아니라 엔티티 중심의 DELETE /api/v1/users로 정의한다. 실제 탈퇴 로직(soft delete, confirmText 검증 등)은 아직 구현하지 않고, SecurityConfig에 인증 필수 경로만 먼저 등록해 컨트롤러가 없는 지금도 인가 규칙이 어긋나지 않게 한다. Co-Authored-By: Claude Sonnet 5 * docs: Spring 요청 처리(Filter/Interceptor)와 JWT 인증/인가 학습 노트 추가 서블릿 Filter 체인 vs HandlerInterceptor vs HandlerMethodArgumentResolver의 차이, Spring Security FilterChainProxy 내부 필터 순서, 이 프로젝트의 JwtAuthenticationFilter/JwtTokenProvider/RefreshTokenStore/@CurrentUser가 실제로 어떻게 연결되는지, 인증 실패(401)와 인가 실패(403)가 왜 각각 RestAuthenticationEntryPoint/RestAccessDeniedHandler에서 처리되고 GlobalExceptionHandler로는 못 잡는지를 정리한 학습 노트. Co-Authored-By: Claude Sonnet 5 * refac: refresh token을 HttpOnly 쿠키로 전달 XSS로 JS가 refresh token을 읽지 못하도록 access token은 그대로 body로 내려주되 refresh token은 HttpOnly 쿠키(refresh_token, path=/api/v1/auth)로 전달하도록 바꿨다. 배포 시 프론트/백엔드가 크로스사이트라 SameSite=None; Secure가 필요하고, 로컬은 SameSite=Lax로 완화한다(RefreshCookieProperties, 프로필별 override). - AuthController: callback/refresh가 Set-Cookie를 실어야 해서 ResponseEntity>로 응답을 감쌌다. refresh는 @RequestBody 대신 @CookieValue로 refresh token을 받고, logout은 clear-cookie 헤더를 추가한다. - refresh 엔드포인트는 쿠키만으로 preflight 없이도 도달 가능해져 CSRF 여지가 생기므로, Origin 헤더가 있는데 허용 목록에 없으면 거부한다 (SecurityConfig.ALLOWED_ORIGINS 공유, logout은 Bearer 헤더 인증이라 영향 없음). - CORS allowCredentials를 true로 켜야 브라우저가 쿠키를 실어 보낸다. - TokenResponse(accessToken+refreshToken)는 AuthService 내부 반환 타입으로만 남기고, 공개 응답 DTO는 refreshToken이 없는 AccessTokenResponse로 분리했다. part of #20 --------- Co-authored-by: Claude Sonnet 5 --- .claude/rules/auth-security.md | 15 +- .claude/skills/security-review/SKILL.md | 6 +- .claude/skills/security-review/checklist.md | 13 +- .env.example | 9 + AGENTS.md | 6 +- build.gradle | 4 +- docs/api-spec-db.md | 28 ++ .../spring-request-processing-and-jwt-auth.md | 250 ++++++++++++++++++ .../bop/youthpick/YouthpickApplication.java | 2 + .../auth/controller/AuthController.java | 97 +++++++ .../auth/dto/AccessTokenResponse.java | 10 + .../bop/youthpick/auth/dto/AuthPrincipal.java | 4 + .../youthpick/auth/dto/AuthUserResponse.java | 11 + .../dto/OAuthAuthorizationUrlResponse.java | 3 + .../auth/dto/OAuthCallbackRequest.java | 7 + .../bop/youthpick/auth/dto/OAuthUserInfo.java | 4 + .../bop/youthpick/auth/dto/TokenResponse.java | 9 + .../auth/exception/AuthErrorCode.java | 8 +- .../auth/exception/AuthException.java | 11 + .../youthpick/auth/service/AuthService.java | 155 +++++++++++ .../youthpick/auth/service/CurrentUser.java | 15 ++ .../service/CurrentUserArgumentResolver.java | 40 +++ .../auth/service/JwtAuthenticationFilter.java | 66 +++++ .../youthpick/auth/service/JwtProperties.java | 9 + .../auth/service/JwtTokenProvider.java | 104 ++++++++ .../youthpick/auth/service/OAuthClient.java | 134 ++++++++++ .../auth/service/OAuthProperties.java | 24 ++ .../youthpick/auth/service/OAuthProvider.java | 54 ++++ .../auth/service/OAuthStateStore.java | 39 +++ .../auth/service/RefreshCookieProperties.java | 11 + .../service/RefreshTokenCookieSupport.java | 38 +++ .../auth/service/RefreshTokenStore.java | 74 ++++++ .../config/RestAccessDeniedHandler.java | 40 +++ .../global/config/SecurityConfig.java | 102 ++++--- .../youthpick/global/config/WebMvcConfig.java | 20 ++ .../com/bop/youthpick/user/entity/User.java | 10 + .../user/repository/UserRepository.java | 6 +- src/main/resources/application-local.yml | 14 + src/main/resources/application.yml | 17 +- .../youthpick/YouthpickApplicationTests.java | 2 - .../auth/controller/AuthControllerTest.java | 210 +++++++++++++++ .../auth/service/AuthServiceTest.java | 236 +++++++++++++++++ .../CurrentUserArgumentResolverTest.java | 79 ++++++ .../service/JwtAuthenticationFilterTest.java | 96 +++++++ .../auth/service/JwtTokenProviderTest.java | 93 +++++++ .../auth/service/OAuthClientTest.java | 158 +++++++++++ .../auth/service/OAuthPropertiesTest.java | 19 ++ .../auth/service/OAuthProviderTest.java | 22 ++ .../auth/service/OAuthStateStoreTest.java | 61 +++++ .../auth/service/RefreshTokenStoreTest.java | 83 ++++++ .../global/config/SecurityConfigTest.java | 106 ++++++++ .../user/repository/UserRepositoryTest.java | 37 +++ src/test/resources/application-test.yml | 14 - src/test/resources/application.yml | 10 + 54 files changed, 2615 insertions(+), 80 deletions(-) create mode 100644 docs/api-spec-db.md create mode 100644 docs/spring-request-processing-and-jwt-auth.md create mode 100644 src/main/java/com/bop/youthpick/auth/controller/AuthController.java create mode 100644 src/main/java/com/bop/youthpick/auth/dto/AccessTokenResponse.java create mode 100644 src/main/java/com/bop/youthpick/auth/dto/AuthPrincipal.java create mode 100644 src/main/java/com/bop/youthpick/auth/dto/AuthUserResponse.java create mode 100644 src/main/java/com/bop/youthpick/auth/dto/OAuthAuthorizationUrlResponse.java create mode 100644 src/main/java/com/bop/youthpick/auth/dto/OAuthCallbackRequest.java create mode 100644 src/main/java/com/bop/youthpick/auth/dto/OAuthUserInfo.java create mode 100644 src/main/java/com/bop/youthpick/auth/dto/TokenResponse.java create mode 100644 src/main/java/com/bop/youthpick/auth/exception/AuthException.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/AuthService.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/CurrentUser.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/JwtProperties.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/OAuthClient.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/OAuthProperties.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/OAuthProvider.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/RefreshCookieProperties.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/RefreshTokenCookieSupport.java create mode 100644 src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java create mode 100644 src/main/java/com/bop/youthpick/global/config/RestAccessDeniedHandler.java create mode 100644 src/main/java/com/bop/youthpick/global/config/WebMvcConfig.java create mode 100644 src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/OAuthClientTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/OAuthPropertiesTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/OAuthProviderTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java create mode 100644 src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java create mode 100644 src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java create mode 100644 src/test/java/com/bop/youthpick/user/repository/UserRepositoryTest.java delete mode 100644 src/test/resources/application-test.yml diff --git a/.claude/rules/auth-security.md b/.claude/rules/auth-security.md index 9fa508f..0c2fcd6 100644 --- a/.claude/rules/auth-security.md +++ b/.claude/rules/auth-security.md @@ -5,13 +5,18 @@ paths: - ".env.example" --- -# 인증 / 세션 / Redis / Secret +# 인증 / JWT / Redis / Secret -Spring Security **세션 기반 인증** + Spring Session(**Redis 저장소**) + OAuth 소셜 로그인(Google / Naver / Kakao) 구조다. +Spring Security **STATELESS** + **JWT(access/refresh) 인증** + OAuth 소셜 로그인(Google / Naver / Kakao) 구조다. 서버 세션(`HttpSession`)은 쓰지 않는다. refresh token만 예외적으로 HttpOnly 쿠키를 쓴다(아래 참고) — 그 외 새 세션/쿠키 기반 인증을 추가하지 않는다. -- 세션은 Spring Session을 통해 Redis에 저장한다(`spring-session-data-redis`). in-memory 세션에 의존하는 코드를 만들지 않는다. +- access token 인증은 `Authorization: Bearer ` 헤더로 이루어진다. `JwtAuthenticationFilter`가 매 요청에서 토큰을 검증해 `SecurityContext`를 채우고, `SecurityConfig`는 `SessionCreationPolicy.STATELESS`를 유지한다. +- refresh token은 **HttpOnly 쿠키**(`RefreshTokenCookieSupport`, 쿠키명 `RefreshTokenCookieSupport.COOKIE_NAME`)로만 클라이언트에 전달한다. JS가 `document.cookie`로 읽을 수 없게 해 XSS로부터 보호하기 위함이다 — 요청 body/응답 body 어디에도 refresh token 원문을 담지 않는다(`AuthController`는 `AccessTokenResponse`로 accessToken만 body에 내려준다). 쿠키 속성(`secure`/`sameSite`/`path`)은 `RefreshCookieProperties`(`youthpick.auth.refresh-cookie.*`)로 프로필별 관리한다 — 프론트와 백엔드가 크로스사이트인 배포 환경은 `SameSite=None; Secure` 필수, `localhost` 로컬 개발은 `SameSite=Lax`로 완화한다. +- refresh token은 발급 시 `RefreshTokenStore`(Redis, `youthpick.jwt.refresh-token-expiration` TTL)에 사용자별로 저장한다(쿠키는 전달 경로일 뿐이고, 유효성의 근원은 여전히 Redis다). `/api/v1/auth/token/refresh`는 쿠키에서 꺼낸 토큰의 JWT 서명·만료를 검증한 뒤 Redis에 저장된 값과 일치하는지 확인하고, 통과하면 access/refresh를 모두 재발급(rotate)해 새 쿠키로 교체한다. 로그아웃은 Redis의 refresh token을 삭제하고 쿠키도 함께 지운다(`RefreshTokenCookieSupport.clear()`). +- refresh 엔드포인트는 쿠키만으로 동작해 preflight 없는 단순 요청도 도달할 수 있으므로, `AuthController.refresh()`는 `Origin` 헤더가 있는데 `SecurityConfig.ALLOWED_ORIGINS`에 없으면 서비스 호출 전에 거부한다(CSRF 최소 방어). 새로 쿠키를 소비하는 엔드포인트를 추가하면 동일한 가드를 적용할지 검토한다. +- OAuth authorization-url 발급 시 CSRF 방지용 `state`는 세션이 아니라 `OAuthStateStore`(Redis, TTL)에 저장하고 콜백에서 1회 소비한다. - 인증 실패(미인증 접근)는 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려준다. -- 인가 규칙(경로별 권한)은 `SecurityConfig`에서 관리하고 Controller에 흩뿌리지 않는다. -- OAuth client id/secret 등 secret 값은 하드코딩하지 않고 환경변수(`GOOGLE_OAUTH_CLIENT_ID` 등)로 주입한다. 새 환경변수는 `.env.example`에 placeholder로 추가한다. +- 인가 규칙(경로별 권한)은 `SecurityConfig`의 `authorizeHttpRequests`에서 경로 매처로 관리하고 Controller에 흩뿌리지 않는다. 새 엔드포인트를 인증 필수로 만들려면 이 파일의 `requestMatchers(...).authenticated()` 목록에 경로를 추가한다. +- 로그인한 사용자의 id가 필요하면 컨트롤러 파라미터에 `@CurrentUser Long userId`를 붙인다. `CurrentUserArgumentResolver`가 `SecurityContext`의 `AuthPrincipal.userId()`를 그대로 주입한다(DB 조회 없음). 인증되지 않았으면 `AuthException(UNAUTHORIZED)`를 던진다. `User` 엔티티 자체가 필요하면 컨트롤러에서 `authService.getCurrentUser(userId)`를 호출한다(Controller→Service 호출 규칙을 그대로 따름). +- OAuth client id/secret, JWT secret 등 secret 값은 하드코딩하지 않고 환경변수(`GOOGLE_OAUTH_CLIENT_ID`, `JWT_SECRET` 등)로 주입한다. 새 환경변수는 `.env.example`에 placeholder로 추가한다. - secret / token 값을 코드·로그·응답 어디에도 노출하지 않는다. `.env`는 커밋하지 않는다. - 중복 실행을 막아야 하는 배치/스케줄 작업을 도입하면 Redis lock(owner token + TTL, release 시 owner 확인)을 사용하고, scheduler는 설정으로 on/off 가능하게 둔다. diff --git a/.claude/skills/security-review/SKILL.md b/.claude/skills/security-review/SKILL.md index 62ec5b2..bd6e163 100644 --- a/.claude/skills/security-review/SKILL.md +++ b/.claude/skills/security-review/SKILL.md @@ -1,11 +1,11 @@ --- name: security-review -description: YouthPick 백엔드 변경분의 보안 점검. 인증/세션/OAuth, secret 노출, 입력 검증, 인가, 민감정보 응답 노출을 검토한다. PR 전 또는 auth/user/secret 관련 코드를 만졌을 때 사용. +description: YouthPick 백엔드 변경분의 보안 점검. 인증/JWT/OAuth, secret 노출, 입력 검증, 인가, 민감정보 응답 노출을 검토한다. PR 전 또는 auth/user/secret 관련 코드를 만졌을 때 사용. --- # Security Review -YouthPick 백엔드(세션 기반 인증 + OAuth 소셜 로그인 + Redis 세션) 변경분을 보안 관점에서 점검한다. +YouthPick 백엔드(STATELESS + JWT access/refresh 인증 + OAuth 소셜 로그인, refresh token은 Redis TTL 저장) 변경분을 보안 관점에서 점검한다. ## 절차 @@ -19,6 +19,6 @@ YouthPick 백엔드(세션 기반 인증 + OAuth 소셜 로그인 + Redis 세션 ## 원칙 -- secret / OAuth client secret / 세션 토큰 값이 코드·로그·응답·예외 메시지에 노출되는지 최우선으로 본다. +- secret / OAuth client secret / JWT access·refresh 토큰 값이 코드·로그·응답·예외 메시지에 노출되는지 최우선으로 본다. - 규칙 근거는 [`.claude/rules/auth-security.md`](../../rules/auth-security.md), [`.claude/rules/error-handling.md`](../../rules/error-handling.md)와 일치시킨다. - 이 스킬은 **점검·보고만** 한다. 수정이 필요하면 항목을 제시하고, 실제 수정은 사용자 승인 후 진행한다. diff --git a/.claude/skills/security-review/checklist.md b/.claude/skills/security-review/checklist.md index c5e734d..96157f1 100644 --- a/.claude/skills/security-review/checklist.md +++ b/.claude/skills/security-review/checklist.md @@ -1,17 +1,20 @@ # 보안 점검 체크리스트 (YouthPick 백엔드) -## 인증 / 세션 / OAuth +## 인증 / JWT / OAuth - [ ] 미인증 접근이 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려가는가. -- [ ] 세션이 Spring Session(Redis)에 저장되는가. in-memory 세션에 의존하는 코드가 새로 생기지 않았는가. +- [ ] `SecurityConfig`가 `SessionCreationPolicy.STATELESS`를 유지하는가. 세션/쿠키 기반 인증 코드가 새로 생기지 않았는가. +- [ ] access token은 짧은 TTL, refresh token은 발급 시 `RefreshTokenStore`(Redis)에 저장되고 재발급(rotate)/로그아웃 시 갱신·삭제되는가. +- [ ] `/api/v1/auth/token/refresh`가 JWT 서명·만료뿐 아니라 Redis에 저장된 값과 일치하는지도 검증하는가(탈취된 구 토큰 재사용 방지). - [ ] 인가 규칙(경로별 권한)이 `SecurityConfig`에 모여 있는가. Controller에 `if (권한)` 식으로 흩뿌려지지 않았는가. +- [ ] 새로 추가된 인증 필요 엔드포인트가 `SecurityConfig`의 `authenticated()` 목록에 반영됐는가(빠지면 `anyRequest().permitAll()`로 누구나 접근 가능). - [ ] 로그인/회원가입에서 비밀번호가 평문 저장·로그되지 않는가. 인증 실패 메시지가 아이디 존재 여부를 흘리지 않는가(`A002`로 통일). -- [ ] OAuth 콜백에서 state/redirect_uri 검증이 있는가. 외부에서 받은 값을 검증 없이 신뢰하지 않는가. +- [ ] OAuth 콜백에서 state 검증이 있는가(`OAuthStateStore` 1회 소비). 외부에서 받은 값을 검증 없이 신뢰하지 않는가. ## Secret / 민감정보 -- [ ] OAuth client id/secret 등이 하드코딩되지 않고 환경변수로 주입되는가. 새 secret은 `.env.example`에 placeholder만 추가됐는가. -- [ ] secret / token / 세션 값이 로그(`log.info` 등)·응답 DTO·예외 메시지에 노출되지 않는가. +- [ ] OAuth client id/secret, `JWT_SECRET` 등이 하드코딩되지 않고 환경변수로 주입되는가. 새 secret은 `.env.example`에 placeholder만 추가됐는가. +- [ ] secret / access·refresh 토큰 값이 로그(`log.info` 등)·응답 DTO·예외 메시지에 노출되지 않는가. - [ ] `.env`가 커밋 대상에 포함되지 않았는가. - [ ] 응답 DTO가 Entity의 민감 필드(비밀번호 해시, 내부 상태, raw OAuth payload)를 노출하지 않는가. diff --git a/.env.example b/.env.example index ba71dfe..d8c281d 100644 --- a/.env.example +++ b/.env.example @@ -29,9 +29,18 @@ REDIS_PORT=6379 # ===== OAuth (인증 기능 구현 시 채운다. 지금은 비워둬도 앱이 뜬다) ===== # Kakao 는 "REST API 키" 를 client-id 로 사용한다(JavaScript 키 아님). +# 각 provider 콘솔에 redirect_uri 로 "OAUTH_FRONTEND_CALLBACK_URI" 값을 그대로 등록해야 한다(프론트 라우트). GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= NAVER_OAUTH_CLIENT_ID= NAVER_OAUTH_CLIENT_SECRET= KAKAO_OAUTH_CLIENT_ID= KAKAO_OAUTH_CLIENT_SECRET= +OAUTH_FRONTEND_CALLBACK_URI=http://localhost:3000/oauth/callback + +# ===== JWT ===== +# JWT_SECRET 은 fallback 이 없다(토큰 위조와 직결되는 값이라 DB 계정과 달리 하드코딩 기본값을 두지 않음). +# 로컬에서도 아래 값을 .env 에 채우고 셸/IDE 환경변수로 넘겨야 앱이 뜬다. 운영에서는 반드시 별도 값으로 교체한다. +JWT_SECRET=local-dev-only-secret-key-please-change-this-32bytes-min +JWT_ACCESS_TOKEN_EXPIRATION=30m +JWT_REFRESH_TOKEN_EXPIRATION=14d diff --git a/AGENTS.md b/AGENTS.md index 02836d6..6a855c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,9 @@ - 청년 정책 추천 서비스 **YouthPick** 백엔드 API 서버 - Java 21 · Spring Boot 3.5.16 · Gradle Wrapper(Groovy DSL, `build.gradle`) -- Spring Web MVC / Data JPA / Security / Session(Redis) / Validation / Actuator +- Spring Web MVC / Data JPA / Security / Redis / Validation / Actuator - DB: 로컬·배포 MySQL + Flyway(`db/migration`, JPA는 validate만) / 테스트만 H2 in-memory(MySQL 모드) — 프로파일/환경변수로 전환(기본 프로파일 `local`) -- 인증: Spring Security 세션 기반 + OAuth 소셜 로그인(Google/Naver/Kakao), 세션은 Redis 저장 +- 인증: Spring Security STATELESS + JWT(access/refresh) + OAuth 소셜 로그인(Google/Naver/Kakao). refresh token은 Redis에 TTL로 저장 - 패키지 루트: `com.bop.youthpick` ## 검증 명령 @@ -48,7 +48,7 @@ | [`.claude/rules/entity-jpa.md`](./.claude/rules/entity-jpa.md) | Entity/Repository/JPA 규칙 | `src/main/java` | | [`.claude/rules/service.md`](./.claude/rules/service.md) | Service 계층, 트랜잭션 | `src/main/java` | | [`.claude/rules/lombok.md`](./.claude/rules/lombok.md) | Lombok 허용/금지 목록 | `src/main/java` | -| [`.claude/rules/auth-security.md`](./.claude/rules/auth-security.md) | 인증/세션/Redis, secret 취급 | `src/main/java`, `resources` | +| [`.claude/rules/auth-security.md`](./.claude/rules/auth-security.md) | 인증(JWT)/Redis, secret 취급 | `src/main/java`, `resources` | | [`.claude/rules/testing.md`](./.claude/rules/testing.md) | 테스트 작성/실행 규칙 | `src/test` | | [`.claude/rules/infra.md`](./.claude/rules/infra.md) | Docker/Compose, 프로파일, API 수동 검증 | compose·resources | diff --git a/build.gradle b/build.gradle index 38fbb8a..16a8425 100644 --- a/build.gradle +++ b/build.gradle @@ -24,9 +24,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.session:spring-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' implementation 'org.flywaydb:flyway-core' runtimeOnly 'org.flywaydb:flyway-mysql' compileOnly 'org.projectlombok:lombok' diff --git a/docs/api-spec-db.md b/docs/api-spec-db.md new file mode 100644 index 0000000..88783df --- /dev/null +++ b/docs/api-spec-db.md @@ -0,0 +1,28 @@ +# API 명세 DB + +> 기획안의 기능을 기준으로 작성한 REST API 명세입니다. + +| 이름 | 메서드 | 경로 | 권한 | 기능 | 상태 | 요청 | 응답 | +|---|---|---|---|---|---|---|---| +| 정책 수집 실행 | POST | `/api/v1/admin/policy-sync-jobs` | 관리자 | SYNC | 확정 | | | +| 로그아웃 | POST | `/api/v1/auth/logout` | 회원 | AUTH | 확정 | | | +| 정책 탐색 챗봇 질문 | POST | `/api/v1/policy-chat/queries` | 비회원 | CHAT | 확정 | | | +| 관심정책 등록 | PUT | `/api/v1/me/favorites/{policyId}` | 회원 | FAV | 확정 | | | +| 정책 비교 생성 | POST | `/api/v1/policy-comparisons` | 비회원 | CMP | 확정 | | | +| OAuth 인가 URL 생성 | GET | `/api/v1/auth/oauth/{provider}/authorization-url` | 비회원 | AUTH | 확정 | | | +| OAuth 콜백(로그인) | POST | `/api/v1/auth/oauth/{provider}/callback` | 비회원 | AUTH | 확정 | `{code, state}` | body: `{accessToken, tokenType, expiresIn}`, `Set-Cookie: refresh_token`(HttpOnly) | +| 액세스 토큰 재발급 | POST | `/api/v1/auth/token/refresh` | 비회원 | AUTH | 확정 | 쿠키: `refresh_token`(요청 body 없음) | body: `{accessToken, tokenType, expiresIn}`, `Set-Cookie: refresh_token`(HttpOnly, 재발급값으로 교체) | +| 프로필 선택지 조회 | GET | `/api/v1/meta/profile-options` | 비회원 | ONB | 확정 | | | +| 정책 수집 이력 목록 | GET | `/api/v1/admin/policy-sync-jobs` | 관리자 | SYNC | 확정 | | | +| 맞춤정책 조회 | GET | `/api/v1/me/recommended-policies` | 회원 | REC | 확정 | | | +| 회원 탈퇴 | DELETE | `/api/v1/auth/me` | 회원 | AUTH | 확정 | | | +| 정책 상세 조회 | GET | `/api/v1/policies/{policyId}` | 비회원 | SEARCH | 확정 | | | +| 챗봇 프로필 사용 동의 설정 | POST | `/api/v1/policy-chat/profile-consent` | 회원 | CHAT | 확정 | | | +| 읽음 상태 목록 조회 | GET | `/api/v1/me/policy-read-states` | 회원 | READ | 확정 | | | +| 관심정책 해제 | DELETE | `/api/v1/me/favorites/{policyId}` | 회원 | FAV | 확정 | | | +| 관심정책 목록 | GET | `/api/v1/me/favorites` | 회원 | FAV | 확정 | | | +| 프로필 저장/수정 | PUT | `/api/v1/me/profile` | 회원 | ONB | 확정 | | | +| 헬스 체크 | GET | `/api/v1/health` | 공통 | ADMIN | 확정 | | | +| 내 로그인 사용자 조회 | GET | `/api/v1/auth/me` | 회원 | AUTH | 확정 | | | +| 정책 비교 조회 | GET | `/api/v1/policy-comparisons/{comparisonId}` | 비회원 | CMP | 확정 | | | +| 정책 검색/필터 | GET | `/api/v1/policies` | 비회원 | SEARCH | 확정 | | | diff --git a/docs/spring-request-processing-and-jwt-auth.md b/docs/spring-request-processing-and-jwt-auth.md new file mode 100644 index 0000000..17ff873 --- /dev/null +++ b/docs/spring-request-processing-and-jwt-auth.md @@ -0,0 +1,250 @@ +# Spring 요청 처리 & Spring Security 필터 & JWT 인증/인가 학습 노트 + +이 문서는 Spring MVC가 요청을 처리하는 방식(Filter/Interceptor)과 Spring Security의 필터 체인이 그 안에 어떻게 끼어드는지, 그리고 이 저장소(`feat/20-social-login`)에 실제로 적용된 JWT 인증/인가 구현을 연결해서 이해하기 위한 노트다. 코드 참조는 실제 파일 기준이며, 구현이 바뀌면 이 문서도 같이 갱신한다. + +## 1. 서블릿 컨테이너 관점에서 본 요청 처리 순서 + +Spring Boot(내장 Tomcat) 기준으로 HTTP 요청 하나가 컨트롤러 메서드까지 도달하는 경로는 이렇다. + +```text +Client + │ + ▼ +[Servlet Filter 체인] ← web.xml/FilterRegistrationBean으로 등록된 모든 Filter (순서 있음) + │ Spring Security는 여기에 "FilterChainProxy"라는 단 하나의 Filter로 끼어든다 + ▼ +DispatcherServlet (Spring MVC의 진입점, 그 자체도 사실 하나의 Servlet) + │ + ├─ HandlerMapping: 어떤 컨트롤러 메서드가 이 요청을 처리할지 결정 + │ + ▼ +[HandlerInterceptor 체인] ← preHandle + │ + ▼ +HandlerAdapter → Controller 메서드 실행 + │ (파라미터 바인딩 시점에 HandlerMethodArgumentResolver가 개입 — 예: @CurrentUser) + ▼ +[HandlerInterceptor 체인] ← postHandle → afterCompletion + │ + ▼ +Response +``` + +핵심은 **Filter는 DispatcherServlet 바깥(서블릿 표준), Interceptor는 DispatcherServlet 안쪽(Spring MVC 전용)** 이라는 것이다. + +## 2. Filter vs Interceptor vs ArgumentResolver + +| | Filter | HandlerInterceptor | HandlerMethodArgumentResolver | +|---|---|---|---| +| 표준 | Jakarta Servlet 표준(`jakarta.servlet.Filter`) | Spring MVC 전용 | Spring MVC 전용 | +| 위치 | DispatcherServlet **이전/이후** | HandlerMapping이 컨트롤러를 특정한 **이후**, 컨트롤러 호출 **직전/직후** | 컨트롤러 메서드 **파라미터 바인딩 시점** | +| 아는 것 | `HttpServletRequest`/`Response`만 | 실행될 `HandlerMethod`(어떤 컨트롤러/메서드인지)까지 | 해당 파라미터의 타입·애노테이션 | +| 요청 자체를 바꿀 수 있는가 | O (Request/Response 래핑 가능) | 제한적 (진행 여부만 결정) | 해당 파라미터 값만 생성 | +| 이 프로젝트에서의 예 | `JwtAuthenticationFilter`, Spring Security의 각종 내부 필터 | 현재 사용 안 함(과거 `LoginRequiredInterceptor` 시도가 있었지만 경로 기반 방식으로 되돌림) | `CurrentUserArgumentResolver`(`@CurrentUser`) | + +Spring Security는 **Filter**로 동작한다. 즉 "인증됐는지"는 컨트롤러가 실행되기 한참 전, DispatcherServlet에 도달하기도 전에 이미 결정된다. 그래서 인증/인가 실패는 `@RestControllerAdvice`([`GlobalExceptionHandler`](../src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java))로 잡히지 않고, Security 전용 처리기가 따로 필요하다(3장 참고). + +## 3. Spring Security = 하나의 커다란 Filter (`FilterChainProxy`) + +Spring Security를 켜면 서블릿 Filter 체인에 `FilterChainProxy`라는 필터가 딱 하나 등록된다. 이 안에서 우리가 [`SecurityConfig`](../src/main/java/com/bop/youthpick/global/config/SecurityConfig.java)에 정의한 `SecurityFilterChain`(보안 전용 필터들의 체인)이 실행된다. 이 프로젝트(STATELESS + JWT, CSRF 비활성) 기준으로 관련 있는 필터만 순서대로 나열하면: + +```text +FilterChainProxy + └─ SecurityFilterChain + ├─ SecurityContextHolderFilter ← 요청마다 SecurityContext를 준비하고, 끝나면 정리 + ├─ CorsFilter ← corsConfigurationSource() 적용 + ├─ (LogoutFilter — 우리는 컨트롤러에서 직접 로그아웃 처리해서 크게 안 씀) + ├─ JwtAuthenticationFilter ← 우리가 addFilterBefore로 직접 끼워넣은 커스텀 필터 + ├─ UsernamePasswordAuthenticationFilter (폼 로그인용, 사용 안 하지만 기본 존재) + ├─ ExceptionTranslationFilter ← AuthenticationException/AccessDeniedException을 여기서 가로챔 + └─ AuthorizationFilter ← authorizeHttpRequests(...) 규칙을 실제로 검사(구버전 이름: FilterSecurityInterceptor) +``` + +`SecurityConfig`의 코드와 1:1로 대응된다. + +```java +http.csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(restAuthenticationEntryPoint) // ExceptionTranslationFilter가 씀 + .accessDeniedHandler(restAccessDeniedHandler)); // ExceptionTranslationFilter가 씀 + +http.authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .requestMatchers(...).permitAll() + .requestMatchers(...).authenticated() + .anyRequest().permitAll()); // AuthorizationFilter가 이 규칙표로 매 요청을 검사 + +http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class); // 우리 필터를 체인의 특정 위치에 삽입 +``` + +`addFilterBefore(필터, 기준필터.class)`는 "이 필터를 기준필터보다 앞에 넣어라"는 뜻이다. `JwtAuthenticationFilter`가 `UsernamePasswordAuthenticationFilter`보다 먼저 실행돼야, 우리가 Bearer 토큰으로 인증을 채운 뒤에 이후 필터들(특히 `AuthorizationFilter`)이 그 인증 정보를 보고 판단할 수 있다. + +## 4. `authorizeHttpRequests`의 매칭 규칙 — 순서가 중요하다 + +`requestMatchers(...)`는 **위에서부터 먼저 매칭되는 규칙이 이긴다.** 그래서 [`SecurityConfig`](../src/main/java/com/bop/youthpick/global/config/SecurityConfig.java)는 좁은 규칙(관리자 전용, 회원 전용)을 먼저 쓰고 마지막에 `anyRequest().permitAll()`로 나머지를 다 열어둔다. + +```java +.requestMatchers("/api/v1/admin/**").hasRole("ADMIN") // 1. 관리자만 +.requestMatchers("/api/v1/auth/oauth/**", ... ).permitAll() // 2. 비회원 공개 +.requestMatchers("/api/v1/auth/me", "/api/v1/me/**", ...).authenticated() // 3. 로그인 필요 +.anyRequest().permitAll() // 4. 나머지(다른 도메인 미구현분)는 개발 편의상 전부 허용 +``` + +- `hasRole("ADMIN")`은 내부적으로 `ROLE_ADMIN`이라는 `GrantedAuthority`를 요구한다(Spring Security가 자동으로 `ROLE_` 접두어를 붙여서 비교함). +- `authenticated()`는 "누구든 로그인만 했으면 됨"이고, `permitAll()`은 "인증 여부 안 봄"이다. +- 새 엔드포인트를 인증 필수로 만들 때는 이 규칙표에 경로를 추가해야 한다. 여기 안 넣으면 마지막 `anyRequest().permitAll()`에 걸려 누구나 접근 가능해진다(`docs`의 보안 체크리스트에도 이 항목이 있다). + +## 5. JWT 인증 흐름 — 로그인부터 매 요청까지 + +### 5-1. 로그인(OAuth 콜백) 시 토큰 발급 + +```mermaid +sequenceDiagram + participant FE as 프론트 + participant AC as AuthController + participant AS as AuthService + participant JTP as JwtTokenProvider + participant RTS as RefreshTokenStore(Redis) + + FE->>AC: POST /api/v1/auth/oauth/{provider}/callback (code, state) + AC->>AS: login(provider, code, state) + AS->>AS: OAuthStateStore로 state 1회 검증 + AS->>AS: provider에서 accessToken/사용자정보 조회, User find-or-create + AS->>JTP: createAccessToken / createRefreshToken + AS->>RTS: save(userId, refreshToken 해시, TTL) + AS-->>AC: TokenResponse(accessToken, refreshToken, ...) + AC-->>FE: 200 OK + accessToken(body) + Set-Cookie: refresh_token(HttpOnly) +``` + +`RefreshTokenStore`([RefreshTokenStore.java](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java))는 refresh token **원문을 저장하지 않고 SHA-256 해시만** 저장한다. Redis가 유출되더라도 그 값으로 바로 로그인할 수 없게 하기 위함이다. + +`AuthService`가 반환하는 `TokenResponse`는 access/refresh token을 둘 다 담고 있지만, 이 값이 그대로 JSON으로 나가지는 않는다. `AuthController`가 [`RefreshTokenCookieSupport`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenCookieSupport.java)로 refreshToken을 HttpOnly 쿠키(`Set-Cookie`)로 옮겨 담고, 응답 body는 refreshToken이 빠진 `AccessTokenResponse`로 감싼다. XSS로 실행된 스크립트가 JS에서 refresh token을 읽지 못하게 하기 위함이다 — access token은 여전히 body로 내려가 `Authorization: Bearer` 헤더로 쓰인다. + +### 5-2. 이후 모든 요청 — `Authorization: Bearer ` + +```mermaid +sequenceDiagram + participant FE as 프론트 + participant Filter as JwtAuthenticationFilter + participant AuthzFilter as AuthorizationFilter + participant Ctrl as Controller + + FE->>Filter: GET /api/v1/me/favorites (Authorization: Bearer ...) + Filter->>Filter: validateAccessToken(token) → Claims (1회 파싱) + Filter->>Filter: getUserId(claims), getRole(claims) + Filter->>Filter: SecurityContextHolder에 Authentication 세팅 + Filter->>AuthzFilter: 다음 필터로 진행 + AuthzFilter->>AuthzFilter: SecurityConfig 규칙표와 현재 Authentication 비교 + alt 인가 통과 + AuthzFilter->>Ctrl: 컨트롤러 실행 (@CurrentUser Long userId 로 principal 꺼냄) + else 인가 거부 + AuthzFilter->>AuthzFilter: AccessDeniedException / AuthenticationException 발생 + end +``` + +[`JwtAuthenticationFilter`](../src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java)는 `OncePerRequestFilter`를 상속한다. 서블릿 스펙상 요청이 내부적으로 forward/include 되면 필터가 여러 번 실행될 수 있는데, `OncePerRequestFilter`는 그걸 막아 **요청당 정확히 한 번만** 실행되게 해준다. + +이 필터가 하는 일은 딱 세 가지다. + +1. `Authorization` 헤더에서 `Bearer ` 토큰을 꺼낸다(`resolveToken`). +2. [`JwtTokenProvider.validateAccessToken(token)`](../src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java)로 서명·만료·토큰 타입(`access`)을 검증하고 `Claims`를 돌려받는다. **검증된 `Claims`를 그대로 재사용**해서 `getUserId(claims)`/`getRole(claims)`를 호출한다 — 예전에는 `validateAccessToken`/`getUserId`/`getRole`이 각각 내부에서 토큰을 다시 파싱해서 요청 하나당 서명검증을 3번 하던 걸 리팩터링으로 1번으로 줄였다. +3. 검증에 성공하면 `AuthPrincipal(userId, role)`을 principal로 담은 `Authentication`을 만들어 `SecurityContextHolder`에 넣는다. **role이 null이면 `"ROLE_null"`이라는 이상한 권한을 만들지 않고 그냥 인증 정보를 채우지 않는다.** + +토큰이 없거나 검증에 실패해도 이 필터는 요청을 막지 않고 그냥 다음 필터로 넘긴다(`filterChain.doFilter` 항상 호출). **실제 차단은 이 필터가 아니라 뒤쪽의 `AuthorizationFilter`가 한다** — 그래야 공개 경로(`permitAll()`)는 토큰이 없어도 정상 처리되고, 보호된 경로만 인증 요구가 걸린다. + +## 6. 인증 실패 vs 인가 실패 — 응답이 갈리는 지점 + +| 상황 | 예 | 필터 체인에서 발생하는 예외 | 처리기 | 응답 | +|---|---|---|---|---| +| 토큰이 없음/유효하지 않음(로그인 안 된 상태로 보호된 경로 접근) | `GET /api/v1/auth/me`에 토큰 없이 접근 | `AuthenticationException` | [`RestAuthenticationEntryPoint`](../src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java) | 401 `A001` | +| 로그인은 됐지만 권한(role)이 부족 | 일반 회원이 `/api/v1/admin/**` 접근 | `AccessDeniedException` | [`RestAccessDeniedHandler`](../src/main/java/com/bop/youthpick/global/config/RestAccessDeniedHandler.java) | 403 `A008` | +| 컨트롤러 진입 후 비즈니스 로직에서 던진 예외 | `AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN)` 등 | (필터 단계 아님, 일반 예외) | [`GlobalExceptionHandler`](../src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java)의 `handleCustomException` | 각 에러코드의 status | + +앞의 두 경우는 **`ExceptionTranslationFilter`가 필터 체인 안에서 잡는다.** 이 시점엔 아직 `DispatcherServlet`도, 그 안의 `@RestControllerAdvice`도 실행되지 않았기 때문에 `GlobalExceptionHandler`가 절대 못 잡는다. 그래서 `RestAuthenticationEntryPoint`/`RestAccessDeniedHandler`가 각각 필터 레벨에서 직접 `HttpServletResponse`에 JSON을 써서 응답한다 — 다만 **응답 JSON 포맷(`ErrorResponse.of(errorCode)`)은 `GlobalExceptionHandler`가 만드는 것과 동일한 모양으로 통일**해서, 클라이언트 입장에서는 어디서 막혔는지 상관없이 항상 같은 에러 응답 구조를 받는다. + +세 번째 경우(컨트롤러/서비스 계층에서 던진 예외)는 이미 `DispatcherServlet`을 통과해 컨트롤러까지 도달한 뒤라 일반적인 Spring MVC 예외 처리(`@RestControllerAdvice`)로 잡힌다. + +## 7. `@CurrentUser` — Filter/Interceptor와는 다른 지점 + +[`CurrentUserArgumentResolver`](../src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java)는 `HandlerMethodArgumentResolver`다. 이건 필터도 인터셉터도 아니고, **컨트롤러 메서드를 호출하기 직전, 파라미터 값을 채우는 단계**에서 동작한다(1장 다이어그램의 "HandlerAdapter → Controller 메서드 실행" 부분). + +```java +@GetMapping("/me") +public ApiResponse me(@CurrentUser Long userId) { ... } +``` + +이 리졸버는 DB나 토큰을 다시 보지 않는다. `JwtAuthenticationFilter`가 이미 채워둔 `SecurityContextHolder`의 `AuthPrincipal.userId()`를 그대로 꺼내줄 뿐이다(값이 없으면 `AuthException(UNAUTHORIZED)`). 그래서 인증 여부 판단은 필터 단계에서 이미 끝나 있고, 이 리졸버는 "이미 인증된 요청에서 누가 로그인했는지"만 편하게 꺼내 쓰는 도구다. + +등록은 [`WebMvcConfig`](../src/main/java/com/bop/youthpick/global/config/WebMvcConfig.java)의 `addArgumentResolvers`로 한다(`WebMvcConfigurer`는 `HandlerInterceptor` 등록도 같은 곳에서 하지만, 지금은 인터셉터를 쓰지 않는다). + +## 8. Refresh Token 재발급이 "검증"까지 하는 이유 + +`POST /api/v1/auth/token/refresh`는 JWT 자체 검증(서명/만료/타입)만으로 끝나지 않는다. [`AuthService.refresh()`](../src/main/java/com/bop/youthpick/auth/service/AuthService.java)를 보면: + +```java +Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); // ① JWT 자체 검증 +Long userId = jwtTokenProvider.getUserId(claims); +... +boolean rotated = refreshTokenStore.rotate(userId, refreshToken, newRefreshToken, ttl); // ② Redis 대조 + 교체 +if (!rotated) throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); +``` + +JWT는 서명이 유효하고 만료 전이면 그 자체로는 "위조되지 않았다"만 증명할 뿐, "아직 살아있는(로그아웃되지 않은, 이미 한 번 쓰이지 않은) 토큰인가"는 증명하지 못한다. 그래서 [`RefreshTokenStore.rotate(...)`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java)가 Redis에 저장된 해시와 제출된 토큰의 해시를 **Lua 스크립트로 원자적으로 대조 후 교체**한다. + +- 일치하면 새 refresh token 해시로 교체(재사용 방지를 위한 rotate) → 성공. +- 불일치(이미 로그아웃됨/이미 재발급에 쓰여서 폐기됨)면 거부. +- "대조"와 "교체"를 하나의 Lua 스크립트로 묶은 이유는 동시에 같은 refresh token으로 두 번 재발급 요청이 와도 **하나만 성공**하게 만들기 위해서다(따로 하면 둘 다 대조를 통과한 뒤 각자 교체해버리는 race condition이 생긴다). + +`AuthController.refresh()`는 더 이상 요청 body로 refreshToken을 받지 않고 `@CookieValue`로 `refresh_token` 쿠키에서 읽는다. 쿠키만으로 동작하면 preflight 없는 단순 요청(cross-site `
` 제출 등)도 쿠키를 자동으로 실어 도달할 수 있어서, `Origin` 헤더가 있는데 `SecurityConfig.ALLOWED_ORIGINS`에 없으면 서비스 호출 전에 `AuthErrorCode.FORBIDDEN`(403 `A008`)으로 거부한다(헤더 자체가 없으면 통과 — 브라우저가 아닌 클라이언트를 막지 않기 위해서다). + +## 9. 전체 그림 한 장 + +```mermaid +flowchart TB + subgraph "서블릿 Filter 체인" + A["JwtAuthenticationFilter\n(Bearer 헤더 파싱 → SecurityContext 세팅)"] + B["ExceptionTranslationFilter\n(인증/인가 예외 가로챔)"] + C["AuthorizationFilter\n(SecurityConfig 규칙표 검사)"] + A --> B --> C + end + subgraph "Spring MVC (DispatcherServlet 안쪽)" + D["CurrentUserArgumentResolver\n(@CurrentUser Long userId 채움)"] + E["Controller / Service"] + D --> E + end + C -- 인가 통과 --> D + C -- 미인증 --> F["RestAuthenticationEntryPoint → 401 A001"] + C -- 권한부족 --> G["RestAccessDeniedHandler → 403 A008"] + E -- 비즈니스 예외 --> H["GlobalExceptionHandler → 각 에러코드"] +``` + +## 10. 관련 코드 인덱스 + +| 개념 | 파일 | +|---|---| +| 보안 필터 체인/인가 규칙 정의 | [`SecurityConfig`](../src/main/java/com/bop/youthpick/global/config/SecurityConfig.java) | +| JWT Bearer 헤더 해석 필터 | [`JwtAuthenticationFilter`](../src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java) | +| JWT 발급/검증 | [`JwtTokenProvider`](../src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java) | +| Redis 기반 refresh token 저장/원자적 rotate | [`RefreshTokenStore`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java) | +| refresh token HttpOnly 쿠키 발급/삭제 | [`RefreshTokenCookieSupport`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenCookieSupport.java) / [`RefreshCookieProperties`](../src/main/java/com/bop/youthpick/auth/service/RefreshCookieProperties.java) | +| 인증 principal DTO | [`AuthPrincipal`](../src/main/java/com/bop/youthpick/auth/dto/AuthPrincipal.java) | +| 인증 실패(401) 처리 | [`RestAuthenticationEntryPoint`](../src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java) | +| 인가 실패(403) 처리 | [`RestAccessDeniedHandler`](../src/main/java/com/bop/youthpick/global/config/RestAccessDeniedHandler.java) | +| `@CurrentUser` 파라미터 리졸버 | [`CurrentUser`](../src/main/java/com/bop/youthpick/auth/service/CurrentUser.java) / [`CurrentUserArgumentResolver`](../src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java) | +| ArgumentResolver 등록 | [`WebMvcConfig`](../src/main/java/com/bop/youthpick/global/config/WebMvcConfig.java) | +| 비즈니스 예외 → 에러 응답 변환 | [`GlobalExceptionHandler`](../src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java) | +| OAuth 로그인/토큰 발급/재발급/로그아웃 흐름 | [`AuthService`](../src/main/java/com/bop/youthpick/auth/service/AuthService.java) | + +## 11. 자주 헷갈리는 부분 Q&A + +**Q. Interceptor 대신 Filter를 쓴 이유는?** +인증 여부 판단은 Spring MVC의 handler 매핑보다 먼저, 그리고 Security의 표준 필터 체인(`ExceptionTranslationFilter`, `AuthorizationFilter` 등)과 나란히 동작해야 하기 때문이다. Interceptor는 Spring MVC 내부 개념이라 Security의 필터 체인과 결합하기 어렵다. (실제로 이 프로젝트에서 `LoginRequiredInterceptor`로 인가를 처리해보려던 시도가 있었지만, `SecurityConfig`의 경로 기반 규칙으로 되돌렸다 — 인가 판단의 단일 소스를 `SecurityConfig`로 유지하기 위함.) + +**Q. `@CurrentUser`가 왜 DB를 안 보고도 동작하나?** +JWT의 `sub`(subject) 클레임에 이미 userId가 들어있고, `JwtAuthenticationFilter`가 이걸 미리 `SecurityContextHolder`에 넣어두기 때문이다. `@CurrentUser`는 그 값을 꺼내기만 한다. `User` 엔티티 전체가 필요하면(예: `AuthController.me()`) 컨트롤러에서 `authService.getCurrentUser(userId)`를 별도로 호출해 DB를 조회한다. + +**Q. `anyRequest().permitAll()`이 마지막에 있는데 안전한가?** +지금은 다른 도메인(정책/즐겨찾기 등) 컨트롤러가 아직 없어서 개발 편의상 열어둔 상태다. 새 컨트롤러를 추가할 때마다 `SecurityConfig`의 규칙표에 그 경로를 명시적으로 추가해야 하며, 빠뜨리면 그 경로는 인증 없이 접근 가능해진다(보안 리뷰 체크리스트 항목). diff --git a/src/main/java/com/bop/youthpick/YouthpickApplication.java b/src/main/java/com/bop/youthpick/YouthpickApplication.java index 72af0da..8e47c9c 100644 --- a/src/main/java/com/bop/youthpick/YouthpickApplication.java +++ b/src/main/java/com/bop/youthpick/YouthpickApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class YouthpickApplication { public static void main(String[] args) { diff --git a/src/main/java/com/bop/youthpick/auth/controller/AuthController.java b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java new file mode 100644 index 0000000..8a87657 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java @@ -0,0 +1,97 @@ +package com.bop.youthpick.auth.controller; + +import com.bop.youthpick.auth.dto.AccessTokenResponse; +import com.bop.youthpick.auth.dto.AuthUserResponse; +import com.bop.youthpick.auth.dto.OAuthAuthorizationUrlResponse; +import com.bop.youthpick.auth.dto.OAuthCallbackRequest; +import com.bop.youthpick.auth.dto.TokenResponse; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.auth.service.AuthService; +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.auth.service.RefreshTokenCookieSupport; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.global.config.SecurityConfig; +import com.bop.youthpick.user.entity.User; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.CookieValue; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final RefreshTokenCookieSupport refreshTokenCookieSupport; + + @GetMapping("/oauth/{provider}/authorization-url") + public ApiResponse authorizationUrl( + @PathVariable String provider) { + String url = authService.buildAuthorizationUrl(provider); + return ApiResponse.ok(new OAuthAuthorizationUrlResponse(url)); + } + + @PostMapping("/oauth/{provider}/callback") + public ResponseEntity> callback( + @PathVariable String provider, @Valid @RequestBody OAuthCallbackRequest request) { + TokenResponse tokens = authService.login(provider, request.code(), request.state()); + return withRefreshCookie(tokens); + } + + /** + * refresh token은 더 이상 body가 아니라 {@link RefreshTokenCookieSupport#COOKIE_NAME} 쿠키로 받는다. 쿠키만으로 + * 동작해 preflight 없는 단순 요청(cross-site form 등)도 도달할 수 있으므로, Origin 헤더가 있는데 허용 목록에 없으면 거부한다. + */ + @PostMapping("/token/refresh") + public ResponseEntity> refresh( + @CookieValue(name = RefreshTokenCookieSupport.COOKIE_NAME, required = false) + String refreshToken, + @RequestHeader(value = HttpHeaders.ORIGIN, required = false) String origin) { + verifyOrigin(origin); + if (!StringUtils.hasText(refreshToken)) { + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + TokenResponse tokens = authService.refresh(refreshToken); + return withRefreshCookie(tokens); + } + + @PostMapping("/logout") + public ResponseEntity logout(@CurrentUser Long userId, HttpServletResponse response) { + authService.logout(userId); + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookieSupport.clear().toString()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/me") + public ApiResponse me(@CurrentUser Long userId) { + User user = authService.getCurrentUser(userId); + return ApiResponse.ok(AuthUserResponse.from(user)); + } + + private ResponseEntity> withRefreshCookie( + TokenResponse tokens) { + ResponseCookie cookie = refreshTokenCookieSupport.issue(tokens.refreshToken()); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(ApiResponse.ok(AccessTokenResponse.from(tokens))); + } + + private void verifyOrigin(String origin) { + if (StringUtils.hasText(origin) && !SecurityConfig.ALLOWED_ORIGINS.contains(origin)) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } + } +} diff --git a/src/main/java/com/bop/youthpick/auth/dto/AccessTokenResponse.java b/src/main/java/com/bop/youthpick/auth/dto/AccessTokenResponse.java new file mode 100644 index 0000000..ccd7c7b --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/dto/AccessTokenResponse.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.auth.dto; + +/** refresh token은 HttpOnly 쿠키로만 내려가므로, 공개 API 응답에는 access token 관련 필드만 노출한다. */ +public record AccessTokenResponse(String accessToken, String tokenType, long expiresIn) { + + public static AccessTokenResponse from(TokenResponse tokenResponse) { + return new AccessTokenResponse( + tokenResponse.accessToken(), tokenResponse.tokenType(), tokenResponse.expiresIn()); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/dto/AuthPrincipal.java b/src/main/java/com/bop/youthpick/auth/dto/AuthPrincipal.java new file mode 100644 index 0000000..fe4321f --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/dto/AuthPrincipal.java @@ -0,0 +1,4 @@ +package com.bop.youthpick.auth.dto; + +/** JWT access token에서 추출한 인증 principal. {@code JwtAuthenticationFilter}가 요청마다 채운다. */ +public record AuthPrincipal(Long userId, String role) {} diff --git a/src/main/java/com/bop/youthpick/auth/dto/AuthUserResponse.java b/src/main/java/com/bop/youthpick/auth/dto/AuthUserResponse.java new file mode 100644 index 0000000..105d4f8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/dto/AuthUserResponse.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.auth.dto; + +import com.bop.youthpick.user.entity.User; + +public record AuthUserResponse(Long id, String email, String nickname, String role) { + + public static AuthUserResponse from(User user) { + return new AuthUserResponse( + user.getId(), user.getEmail(), user.getNickname(), user.getRole().name()); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/dto/OAuthAuthorizationUrlResponse.java b/src/main/java/com/bop/youthpick/auth/dto/OAuthAuthorizationUrlResponse.java new file mode 100644 index 0000000..e5ac2cc --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/dto/OAuthAuthorizationUrlResponse.java @@ -0,0 +1,3 @@ +package com.bop.youthpick.auth.dto; + +public record OAuthAuthorizationUrlResponse(String authorizationUrl) {} diff --git a/src/main/java/com/bop/youthpick/auth/dto/OAuthCallbackRequest.java b/src/main/java/com/bop/youthpick/auth/dto/OAuthCallbackRequest.java new file mode 100644 index 0000000..fab67b0 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/dto/OAuthCallbackRequest.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record OAuthCallbackRequest( + @NotBlank(message = "code는 필수입니다.") String code, + @NotBlank(message = "state는 필수입니다.") String state) {} diff --git a/src/main/java/com/bop/youthpick/auth/dto/OAuthUserInfo.java b/src/main/java/com/bop/youthpick/auth/dto/OAuthUserInfo.java new file mode 100644 index 0000000..00258e2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/dto/OAuthUserInfo.java @@ -0,0 +1,4 @@ +package com.bop.youthpick.auth.dto; + +/** provider별 userinfo 응답을 정규화한 값. */ +public record OAuthUserInfo(String provider, String providerId, String email, String nickname) {} diff --git a/src/main/java/com/bop/youthpick/auth/dto/TokenResponse.java b/src/main/java/com/bop/youthpick/auth/dto/TokenResponse.java new file mode 100644 index 0000000..4b9415e --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/dto/TokenResponse.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.auth.dto; + +public record TokenResponse( + String accessToken, String refreshToken, String tokenType, long expiresIn) { + + public static TokenResponse of(String accessToken, String refreshToken, long expiresIn) { + return new TokenResponse(accessToken, refreshToken, "Bearer", expiresIn); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java index 55be90b..68e0bfd 100644 --- a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java @@ -9,7 +9,13 @@ @RequiredArgsConstructor public enum AuthErrorCode implements ErrorCode { UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "로그인이 필요합니다."), - INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "A002", "아이디 또는 비밀번호가 올바르지 않습니다."); + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "A002", "아이디 또는 비밀번호가 올바르지 않습니다."), + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "A003", "지원하지 않는 로그인 방식입니다."), + INVALID_OAUTH_STATE(HttpStatus.BAD_REQUEST, "A004", "로그인 요청이 유효하지 않습니다. 다시 시도해주세요."), + OAUTH_PROVIDER_ERROR(HttpStatus.BAD_GATEWAY, "A005", "소셜 로그인 서버와 통신 중 오류가 발생했습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A006", "유효하지 않은 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "A007", "리프레시 토큰이 유효하지 않습니다. 다시 로그인해주세요."), + FORBIDDEN(HttpStatus.FORBIDDEN, "A008", "접근 권한이 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/bop/youthpick/auth/exception/AuthException.java b/src/main/java/com/bop/youthpick/auth/exception/AuthException.java new file mode 100644 index 0000000..3f0b5ba --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/exception/AuthException.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.auth.exception; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.global.error.ErrorCode; + +public class AuthException extends CustomException { + + public AuthException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/AuthService.java b/src/main/java/com/bop/youthpick/auth/service/AuthService.java new file mode 100644 index 0000000..9b639bf --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/AuthService.java @@ -0,0 +1,155 @@ +package com.bop.youthpick.auth.service; + +import com.bop.youthpick.auth.dto.OAuthUserInfo; +import com.bop.youthpick.auth.dto.TokenResponse; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import io.jsonwebtoken.Claims; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 카카오/구글/네이버 OAuth2 authorization code 로그인 흐름. 인증은 JWT access/refresh token으로 발급하며, refresh token은 + * {@code RefreshTokenStore}(Redis, TTL)로 관리한다. + */ +@Service +@RequiredArgsConstructor +public class AuthService { + + private final OAuthProperties oAuthProperties; + private final OAuthClient oAuthClient; + private final OAuthStateStore oAuthStateStore; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; + + public String buildAuthorizationUrl(String registrationId) { + OAuthProvider provider = OAuthProvider.from(registrationId); + OAuthProperties.Registration registration = registration(provider); + + String state = UUID.randomUUID().toString(); + oAuthStateStore.save(state, provider.name()); + + UriComponentsBuilder builder = + UriComponentsBuilder.fromUriString(provider.getAuthorizationUri()) + .queryParam("response_type", "code") + .queryParam("client_id", registration.clientId()) + .queryParam("redirect_uri", oAuthProperties.frontendCallbackUri()) + .queryParam("state", state); + if (StringUtils.hasText(provider.getScope())) { + builder.queryParam("scope", provider.getScope()); + } + return builder.encode().build().toUriString(); + } + + /** + * DB 저장 없이 API 호출만: provider 통신(state 검증 이후)은 트랜잭션 밖에서 수행해 네트워크 지연 동안 DB 커넥션을 점유하지 않는다. 실제 DB + * 쓰기(find-or-create)는 {@link #findOrCreateUser}의 각 리포지토리 호출이 Spring Data JPA 기본 트랜잭션으로 처리한다. + */ + public TokenResponse login(String registrationId, String code, String state) { + OAuthProvider provider = OAuthProvider.from(registrationId); + if (!oAuthStateStore.consume(state, provider.name())) { + throw new AuthException(AuthErrorCode.INVALID_OAUTH_STATE); + } + + OAuthProperties.Registration registration = registration(provider); + String accessToken = + oAuthClient.exchangeCodeForAccessToken( + provider, + registration.clientId(), + registration.clientSecret(), + oAuthProperties.frontendCallbackUri(), + code); + OAuthUserInfo userInfo = oAuthClient.fetchUserInfo(provider, accessToken); + + User user = findOrCreateUser(provider, userInfo); + return issueTokens(user); + } + + @Transactional(readOnly = true) + public TokenResponse refresh(String refreshToken) { + Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); + Long userId = jwtTokenProvider.getUserId(claims); + + User user = + userRepository + .findById(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.UNAUTHORIZED)); + + String newAccessToken = + jwtTokenProvider.createAccessToken(user.getId(), user.getRole().name()); + String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + boolean rotated = + refreshTokenStore.rotate( + userId, + refreshToken, + newRefreshToken, + jwtTokenProvider.refreshTokenExpiration()); + if (!rotated) { + // 저장된 refresh token과 불일치(이미 재발급됐거나 로그아웃됨) — 동시 재발급 요청 중 하나만 성공시킨다. + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + return TokenResponse.of( + newAccessToken, newRefreshToken, jwtTokenProvider.accessTokenExpirationSeconds()); + } + + // Redis(refresh token 삭제)만 다루고 JPA 트랜잭션 리소스를 쓰지 않아 @Transactional 대상이 아니다. + public void logout(Long userId) { + refreshTokenStore.delete(userId); + } + + @Transactional(readOnly = true) + public User getCurrentUser(Long userId) { + return userRepository + .findById(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.UNAUTHORIZED)); + } + + private User findOrCreateUser(OAuthProvider provider, OAuthUserInfo userInfo) { + return userRepository + .findByProviderAndProviderId(provider.name(), userInfo.providerId()) + .orElseGet(() -> createUser(provider, userInfo)); + } + + private User createUser(OAuthProvider provider, OAuthUserInfo userInfo) { + try { + return userRepository.save( + User.createSocialUser( + provider.name(), + userInfo.providerId(), + userInfo.email(), + userInfo.nickname())); + } catch (DataIntegrityViolationException e) { + // uk_users_provider 위반: 동시 콜백 요청이 먼저 저장한 경우, 그 사용자로 재조회해 복구한다. + return userRepository + .findByProviderAndProviderId(provider.name(), userInfo.providerId()) + .orElseThrow(() -> e); + } + } + + private TokenResponse issueTokens(User user) { + String accessToken = + jwtTokenProvider.createAccessToken(user.getId(), user.getRole().name()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + refreshTokenStore.save( + user.getId(), refreshToken, jwtTokenProvider.refreshTokenExpiration()); + return TokenResponse.of( + accessToken, refreshToken, jwtTokenProvider.accessTokenExpirationSeconds()); + } + + private OAuthProperties.Registration registration(OAuthProvider provider) { + OAuthProperties.Registration registration = + oAuthProperties.providers().get(provider.registrationId()); + if (registration == null || !StringUtils.hasText(registration.clientId())) { + throw new AuthException(AuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + return registration; + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java b/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java new file mode 100644 index 0000000..c182a29 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java @@ -0,0 +1,15 @@ +package com.bop.youthpick.auth.service; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code Long} 파라미터에 붙이면 {@code Authorization} 헤더의 JWT access token(= {@code + * JwtAuthenticationFilter}가 채운 인증 principal)에서 userId를 읽어 그대로 주입한다. DB 조회는 하지 않는다. 인증되지 않은 요청이면 + * {@code AuthErrorCode.UNAUTHORIZED}로 거부된다. + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUser {} diff --git a/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java b/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..9efcea8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java @@ -0,0 +1,40 @@ +package com.bop.youthpick.auth.service; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * {@link CurrentUser}가 붙은 {@code Long} 파라미터를 SecurityContext의 {@link AuthPrincipal} userId로 채운다. + */ +@Component +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && Long.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !(authentication.getPrincipal() instanceof AuthPrincipal principal)) { + throw new AuthException(AuthErrorCode.UNAUTHORIZED); + } + return principal.userId(); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java b/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java new file mode 100644 index 0000000..08344a7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java @@ -0,0 +1,66 @@ +package com.bop.youthpick.auth.service; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.exception.AuthException; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * {@code Authorization: Bearer }을 읽어 인증을 채운다. 토큰이 없거나 유효하지 않으면 그냥 통과시키고, 보호된 엔드포인트라면 + * {@code RestAuthenticationEntryPoint}가 401(A001)로 응답한다. + */ +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String token = resolveToken(request); + if (token != null) { + try { + // access token 파싱을 한 번만 수행하고, 검증된 Claims에서 userId/role을 함께 꺼낸다. + Claims claims = jwtTokenProvider.validateAccessToken(token); + Long userId = jwtTokenProvider.getUserId(claims); + String role = jwtTokenProvider.getRole(claims); + if (role != null) { + AuthPrincipal principal = new AuthPrincipal(userId, role); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (AuthException e) { + SecurityContextHolder.clearContext(); + } + } + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(header) && header.startsWith(BEARER_PREFIX)) { + return header.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/JwtProperties.java b/src/main/java/com/bop/youthpick/auth/service/JwtProperties.java new file mode 100644 index 0000000..aa9f278 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/JwtProperties.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.auth.service; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** {@code youthpick.jwt.*} 설정 바인딩. secret은 환경변수로 주입하고 코드에 하드코딩하지 않는다. */ +@ConfigurationProperties(prefix = "youthpick.jwt") +public record JwtProperties( + String secret, Duration accessTokenExpiration, Duration refreshTokenExpiration) {} diff --git a/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java b/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java new file mode 100644 index 0000000..71918f3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java @@ -0,0 +1,104 @@ +package com.bop.youthpick.auth.service; + +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.stereotype.Component; + +/** JWT access/refresh 토큰 발급·검증. refresh 토큰의 실제 유효성(폐기 여부)은 {@code RefreshTokenStore}가 관리한다. */ +@Component +public class JwtTokenProvider { + + private static final String CLAIM_TYPE = "type"; + private static final String CLAIM_ROLE = "role"; + private static final String TOKEN_TYPE_ACCESS = "access"; + private static final String TOKEN_TYPE_REFRESH = "refresh"; + + private final SecretKey key; + private final JwtProperties jwtProperties; + + public JwtTokenProvider(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.key = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(Long userId, String role) { + return createToken(userId, TOKEN_TYPE_ACCESS, role, jwtProperties.accessTokenExpiration()); + } + + public String createRefreshToken(Long userId) { + return createToken( + userId, TOKEN_TYPE_REFRESH, null, jwtProperties.refreshTokenExpiration()); + } + + public long accessTokenExpirationSeconds() { + return jwtProperties.accessTokenExpiration().toSeconds(); + } + + public Duration refreshTokenExpiration() { + return jwtProperties.refreshTokenExpiration(); + } + + /** access token의 서명·만료·타입을 검증하고, 요청당 재파싱을 피하기 위해 {@link Claims}를 그대로 반환한다. */ + public Claims validateAccessToken(String token) { + return requireClaims(token, TOKEN_TYPE_ACCESS); + } + + /** refresh token의 서명·만료·타입을 검증하고 {@link Claims}를 반환한다. */ + public Claims validateRefreshToken(String token) { + return requireClaims(token, TOKEN_TYPE_REFRESH); + } + + public Long getUserId(Claims claims) { + try { + return Long.valueOf(claims.getSubject()); + } catch (NumberFormatException e) { + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + } + + public String getRole(Claims claims) { + return claims.get(CLAIM_ROLE, String.class); + } + + private Claims requireClaims(String token, String expectedType) { + Claims claims = parseClaims(token); + if (!expectedType.equals(claims.get(CLAIM_TYPE, String.class))) { + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + return claims; + } + + private Claims parseClaims(String token) { + try { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload(); + } catch (JwtException | IllegalArgumentException e) { + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + } + + private String createToken(Long userId, String type, String role, Duration ttl) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + ttl.toMillis()); + + JwtBuilder builder = + Jwts.builder() + .subject(String.valueOf(userId)) + .claim(CLAIM_TYPE, type) + .issuedAt(now) + .expiration(expiration) + .signWith(key); + if (role != null) { + builder.claim(CLAIM_ROLE, role); + } + return builder.compact(); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/OAuthClient.java b/src/main/java/com/bop/youthpick/auth/service/OAuthClient.java new file mode 100644 index 0000000..cde5f51 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/OAuthClient.java @@ -0,0 +1,134 @@ +package com.bop.youthpick.auth.service; + +import com.bop.youthpick.auth.dto.OAuthUserInfo; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +/** + * OAuth2 authorization code를 access token으로 교환하고, provider별 userinfo를 정규화해 조회한다. + * + *

connect/read timeout은 {@code spring.http.client.*}(application.yml)로 전역 설정한다. 여기서 직접 {@code + * RestClient.Builder}의 requestFactory를 덮어쓰면 테스트의 {@code MockRestServiceServer}가 주입한 factory를 지워버려 + * 실제 네트워크로 요청이 나간다. + */ +@Component +public class OAuthClient { + + private final RestClient restClient; + + public OAuthClient(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.build(); + } + + public String exchangeCodeForAccessToken( + OAuthProvider provider, + String clientId, + String clientSecret, + String redirectUri, + String code) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "authorization_code"); + form.add("client_id", clientId); + form.add("client_secret", clientSecret); + form.add("redirect_uri", redirectUri); + form.add("code", code); + + JsonNode response = post(provider.getTokenUri(), form); + JsonNode accessToken = response.get("access_token"); + if (accessToken == null || accessToken.asText().isBlank()) { + throw new AuthException(AuthErrorCode.OAUTH_PROVIDER_ERROR); + } + return accessToken.asText(); + } + + public OAuthUserInfo fetchUserInfo(OAuthProvider provider, String accessToken) { + JsonNode response; + try { + response = + restClient + .get() + .uri(provider.getUserInfoUri()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(JsonNode.class); + } catch (RestClientException e) { + throw new AuthException(AuthErrorCode.OAUTH_PROVIDER_ERROR); + } + if (response == null) { + throw new AuthException(AuthErrorCode.OAUTH_PROVIDER_ERROR); + } + + return switch (provider) { + case GOOGLE -> parseGoogle(response); + case NAVER -> parseNaver(response); + case KAKAO -> parseKakao(response); + }; + } + + private JsonNode post(String uri, MultiValueMap form) { + try { + JsonNode response = + restClient + .post() + .uri(uri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .accept(MediaType.APPLICATION_JSON) + .body(form) + .retrieve() + .body(JsonNode.class); + if (response == null) { + throw new AuthException(AuthErrorCode.OAUTH_PROVIDER_ERROR); + } + return response; + } catch (RestClientException e) { + throw new AuthException(AuthErrorCode.OAUTH_PROVIDER_ERROR); + } + } + + private OAuthUserInfo parseGoogle(JsonNode node) { + return new OAuthUserInfo( + OAuthProvider.GOOGLE.name(), + requireProviderId(node.path("sub")), + textOrNull(node.path("email")), + textOrNull(node.path("name"))); + } + + private OAuthUserInfo parseNaver(JsonNode node) { + JsonNode account = node.path("response"); + return new OAuthUserInfo( + OAuthProvider.NAVER.name(), + requireProviderId(account.path("id")), + textOrNull(account.path("email")), + textOrNull(account.path("name"))); + } + + private OAuthUserInfo parseKakao(JsonNode node) { + JsonNode kakaoAccount = node.path("kakao_account"); + JsonNode profile = kakaoAccount.path("profile"); + return new OAuthUserInfo( + OAuthProvider.KAKAO.name(), + requireProviderId(node.path("id")), + textOrNull(kakaoAccount.path("email")), + textOrNull(profile.path("nickname"))); + } + + private String requireProviderId(JsonNode node) { + String providerId = node.asText(); + if (providerId.isBlank()) { + throw new AuthException(AuthErrorCode.OAUTH_PROVIDER_ERROR); + } + return providerId; + } + + private String textOrNull(JsonNode node) { + return node.isMissingNode() || node.isNull() ? null : node.asText(); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/OAuthProperties.java b/src/main/java/com/bop/youthpick/auth/service/OAuthProperties.java new file mode 100644 index 0000000..c76cbbb --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/OAuthProperties.java @@ -0,0 +1,24 @@ +package com.bop.youthpick.auth.service; + +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * {@code youthpick.oauth.*} 설정 바인딩. client-id/secret은 provider별로 {@code application-local.yml}에서 + * 환경변수로 주입한다. + * + *

{@code frontendCallbackUri}는 각 provider 콘솔에 등록하는 OAuth redirect_uri와 동일한 프론트엔드 라우트다(예: {@code + * http://localhost:3000/oauth/callback}). provider는 이 uri로 브라우저를 리다이렉트하고, 프론트가 그 화면에서 code/state를 + * 읽어 백엔드 콜백 API를 호출한다. + */ +@ConfigurationProperties(prefix = "youthpick.oauth") +public record OAuthProperties(String frontendCallbackUri, Map providers) { + + public record Registration(String clientId, String clientSecret) { + + @Override + public String toString() { + return "Registration[clientId=" + clientId + ", clientSecret=****]"; + } + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/OAuthProvider.java b/src/main/java/com/bop/youthpick/auth/service/OAuthProvider.java new file mode 100644 index 0000000..badd721 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/OAuthProvider.java @@ -0,0 +1,54 @@ +package com.bop.youthpick.auth.service; + +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import lombok.Getter; + +/** + * 소셜 로그인 provider별 authorize/token/userinfo 엔드포인트. client-id/secret은 {@code OAuthProperties}에서 받는다. + */ +@Getter +public enum OAuthProvider { + GOOGLE( + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + "https://www.googleapis.com/oauth2/v3/userinfo", + "openid email profile"), + NAVER( + "https://nid.naver.com/oauth2.0/authorize", + "https://nid.naver.com/oauth2.0/token", + "https://openapi.naver.com/v1/nid/me", + null), + KAKAO( + "https://kauth.kakao.com/oauth/authorize", + "https://kauth.kakao.com/oauth/token", + "https://kapi.kakao.com/v2/user/me", + null); + + private final String authorizationUri; + private final String tokenUri; + private final String userInfoUri; + private final String scope; + + OAuthProvider(String authorizationUri, String tokenUri, String userInfoUri, String scope) { + this.authorizationUri = authorizationUri; + this.tokenUri = tokenUri; + this.userInfoUri = userInfoUri; + this.scope = scope; + } + + public static OAuthProvider from(String registrationId) { + if (registrationId == null) { + throw new AuthException(AuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + try { + return OAuthProvider.valueOf(registrationId.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new AuthException(AuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + } + + public String registrationId() { + return name().toLowerCase(); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java b/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java new file mode 100644 index 0000000..2092ef1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java @@ -0,0 +1,39 @@ +package com.bop.youthpick.auth.service; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * OAuth authorization-url 발급 시점에 CSRF 방지용 state를 Redis에 짧은 TTL로 저장하고, 콜백에서 1회 검증 후 소비한다. 세션을 쓰지 않는 + * stateless 인증이라 서버 쪽 임시 상태는 여기 Redis에 둔다. + */ +@Component +@RequiredArgsConstructor +public class OAuthStateStore { + + private static final String KEY_PREFIX = "auth:oauth-state:"; + private static final Duration TTL = Duration.ofMinutes(5); + + private final StringRedisTemplate redisTemplate; + + public void save(String state, String provider) { + redisTemplate.opsForValue().set(key(state), provider, TTL); + } + + /** state에 저장된 provider와 일치하면 소비(삭제)하고 true를 반환한다. */ + public boolean consume(String state, String provider) { + String key = key(state); + String savedProvider = redisTemplate.opsForValue().get(key); + if (savedProvider == null || !savedProvider.equals(provider)) { + return false; + } + redisTemplate.delete(key); + return true; + } + + private String key(String state) { + return KEY_PREFIX + state; + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/RefreshCookieProperties.java b/src/main/java/com/bop/youthpick/auth/service/RefreshCookieProperties.java new file mode 100644 index 0000000..a70f14a --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/RefreshCookieProperties.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.auth.service; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * {@code youthpick.auth.refresh-cookie.*} 설정 바인딩. 쿠키 이름은 {@link + * RefreshTokenCookieSupport#COOKIE_NAME}로 고정하고, 배포 환경마다 달라지는 secure/sameSite/path만 프로필별로 + * override한다. + */ +@ConfigurationProperties(prefix = "youthpick.auth.refresh-cookie") +public record RefreshCookieProperties(String path, boolean secure, String sameSite) {} diff --git a/src/main/java/com/bop/youthpick/auth/service/RefreshTokenCookieSupport.java b/src/main/java/com/bop/youthpick/auth/service/RefreshTokenCookieSupport.java new file mode 100644 index 0000000..36e9b89 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/RefreshTokenCookieSupport.java @@ -0,0 +1,38 @@ +package com.bop.youthpick.auth.service; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +/** + * refresh token을 HttpOnly 쿠키로 발급/삭제한다. access token은 이전처럼 응답 body로 내려간다. 크로스사이트 배포(SameSite=None)와 + * 로컬(SameSite=Lax, http)의 secure/sameSite 차이는 {@link RefreshCookieProperties}로 프로필별 override한다. + */ +@Component +@RequiredArgsConstructor +public class RefreshTokenCookieSupport { + + public static final String COOKIE_NAME = "refresh_token"; + + private final RefreshCookieProperties properties; + private final JwtProperties jwtProperties; + + public ResponseCookie issue(String refreshToken) { + return build(refreshToken, jwtProperties.refreshTokenExpiration()); + } + + public ResponseCookie clear() { + return build("", Duration.ZERO); + } + + private ResponseCookie build(String value, Duration maxAge) { + return ResponseCookie.from(COOKIE_NAME, value) + .httpOnly(true) + .secure(properties.secure()) + .sameSite(properties.sameSite()) + .path(properties.path()) + .maxAge(maxAge) + .build(); + } +} diff --git a/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java b/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java new file mode 100644 index 0000000..ec0deca --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java @@ -0,0 +1,74 @@ +package com.bop.youthpick.auth.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +/** + * 사용자별 refresh token을 Redis에 TTL과 함께 저장한다. Redis가 유출되더라도 원문 토큰이 그대로 노출되지 않도록 SHA-256 해시만 저장하고, + * 재발급(rotate)은 Lua 스크립트로 "저장값 일치 확인 + 교체"를 원자적으로 처리해 동시 요청으로 인한 중복 발급을 막는다. + */ +@Component +@RequiredArgsConstructor +public class RefreshTokenStore { + + private static final String KEY_PREFIX = "auth:refresh-token:"; + + private static final RedisScript ROTATE_SCRIPT = + new DefaultRedisScript<>( + """ + local stored = redis.call('GET', KEYS[1]) + if stored == ARGV[1] then + redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3]) + return 1 + else + return 0 + end + """, + Long.class); + + private final StringRedisTemplate redisTemplate; + + public void save(Long userId, String refreshToken, Duration ttl) { + redisTemplate.opsForValue().set(key(userId), hash(refreshToken), ttl); + } + + /** 저장된 값이 {@code oldRefreshToken}의 해시와 일치할 때만 {@code newRefreshToken}으로 원자적으로 교체한다. */ + public boolean rotate( + Long userId, String oldRefreshToken, String newRefreshToken, Duration ttl) { + Long result = + redisTemplate.execute( + ROTATE_SCRIPT, + List.of(key(userId)), + hash(oldRefreshToken), + hash(newRefreshToken), + String.valueOf(ttl.toMillis())); + return result != null && result == 1L; + } + + public void delete(Long userId) { + redisTemplate.delete(key(userId)); + } + + private String key(Long userId) { + return KEY_PREFIX + userId; + } + + private String hash(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 알고리즘을 사용할 수 없습니다.", e); + } + } +} diff --git a/src/main/java/com/bop/youthpick/global/config/RestAccessDeniedHandler.java b/src/main/java/com/bop/youthpick/global/config/RestAccessDeniedHandler.java new file mode 100644 index 0000000..686c3e9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/RestAccessDeniedHandler.java @@ -0,0 +1,40 @@ +package com.bop.youthpick.global.config; + +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.global.error.ErrorCode; +import com.bop.youthpick.global.error.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +/** + * 인증은 됐지만 권한(role)이 부족해 거부된 요청을 처리한다(예: 일반 회원이 관리자 전용 API 접근). + * + *

{@link RestAuthenticationEntryPoint}와 동일한 이유로, 필터 단계의 거부이므로 {@code GlobalExceptionHandler}가 잡지 + * 못한다. 여기서 직접 403 + {@link ErrorResponse} JSON을 응답한다. + */ +@Component +@RequiredArgsConstructor +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) + throws IOException { + ErrorCode errorCode = AuthErrorCode.FORBIDDEN; + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), ErrorResponse.of(errorCode)); + } +} diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index b051ae3..f5534c3 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -1,46 +1,46 @@ package com.bop.youthpick.global.config; +import com.bop.youthpick.auth.service.JwtAuthenticationFilter; +import com.bop.youthpick.auth.service.JwtTokenProvider; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; /** - * 보안 기본 골격. REST API 기준으로 세션을 쓰지 않고(STATELESS), CSRF는 끄고, CORS만 켠다. 인증 실패는 {@link - * RestAuthenticationEntryPoint}가 JSON 401로 응답하도록 미리 연결해 둔다. + * 보안 기본 골격. REST API 기준으로 세션을 쓰지 않고(STATELESS) CSRF는 끄고, CORS만 켠다. 인증은 JWT access token 기반이며 {@code + * JwtAuthenticationFilter}가 매 요청의 {@code Authorization: Bearer} 헤더를 해석해 SecurityContext를 채운다. + * refresh token은 XSS로부터 보호하기 위해 HttpOnly 쿠키로만 전달하며({@code RefreshTokenCookieSupport}), 이 때문에 CORS + * {@code allowCredentials}를 켜 둔다. 인증 실패는 {@link RestAuthenticationEntryPoint}가 JSON 401로, 권한 부족은 + * {@link RestAccessDeniedHandler}가 JSON 403으로 응답하도록 미리 연결해 둔다. * - *

현재 상태: 개발용(모든 요청 허용)

+ *

인가 규칙: API 명세서(docs)의 권한 컬럼 기준

* - * 인증 기능(로그인)이 아직 없으므로, 팀원들이 각자 API를 개발·테스트할 수 있도록 {@code anyRequest().permitAll()}로 전부 열어 둔다. 보안 - * 자체는 아래 절차로 언제든 켤 수 있다. - * - *

인증 적용 절차 (팀원의 auth 코드가 준비되면)

- * - *
    - *
  1. {@code com.bop.youthpick.auth} 패키지(JwtAuthenticationFilter 등)를 추가한다. JWT/Redis 관련 의존성(jjwt - * 등)과 설정(application.yml의 youthpick.auth.*)도 함께 채운다. - *
  2. 아래 "AUTH ON" 주석 블록을 활성화하고, 현재 "DEV: 전부 허용" 블록을 제거한다. (JwtAuthenticationFilter 주입 필드 + - * addFilterBefore + 공개경로/authenticated) - *
- * - * 이렇게 두면 EntryPoint·CORS·STATELESS 골격은 그대로 재사용되고, 바뀌는 건 인가 규칙과 필터뿐이다. + * 관리자 전용은 {@code hasRole("ADMIN")}, 회원 전용은 {@code authenticated()}, 비회원/공통은 {@code permitAll()}로 + * 매핑한다. 아직 컨트롤러가 없는 경로도 명세에 있으면 미리 규칙을 걸어 둔다(나중에 컨트롤러가 추가돼도 기본값이 열려 있지 않도록). 명세에 없는 나머지 경로는 여전히 개발 + * 편의상 {@code anyRequest().permitAll()}로 열어 둔다. */ @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; + /** 프론트엔드 배포 주소. refresh token 쿠키 Origin 검증({@code AuthController})과 CORS 설정이 이 목록을 공유한다. */ + public static final List ALLOWED_ORIGINS = + List.of("http://localhost:3000", "http://localhost:5173"); - // [AUTH ON] 1단계: 팀원 auth 코드 추가 후 아래 주입을 활성화한다. - // private final com.bop.youthpick.auth.token.JwtAuthenticationFilter jwtAuthenticationFilter; + private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; + private final RestAccessDeniedHandler restAccessDeniedHandler; + private final JwtTokenProvider jwtTokenProvider; @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -50,30 +50,43 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling( exception -> - exception.authenticationEntryPoint(restAuthenticationEntryPoint)); + exception + .authenticationEntryPoint(restAuthenticationEntryPoint) + .accessDeniedHandler(restAccessDeniedHandler)); - // ===== DEV: 전부 허용 (로그인 기능 생기기 전까지 개발 편의용) ===== - http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + http.authorizeHttpRequests( + auth -> + auth.requestMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() + // 관리자(ADMIN) 전용 — 정책 수집 실행/이력 조회 + .requestMatchers("/api/v1/admin/**") + .hasRole("ADMIN") + // 비회원(공개) — 로그인 자체, 정책 탐색/비교/검색, 메타 조회, 헬스체크 + .requestMatchers( + "/api/v1/auth/oauth/**", + "/api/v1/auth/token/refresh", + "/api/v1/policy-chat/queries", + "/api/v1/policy-comparisons/**", + "/api/v1/meta/profile-options", + "/api/v1/policies/**", + "/api/v1/health") + .permitAll() + // 회원 전용 — 로그인 상태 조회/로그아웃, 회원 탈퇴(컨트롤러 미구현, 경로만 선점), + // 마이페이지(관심정책/추천/읽음/프로필), 챗봇 프로필 동의 + .requestMatchers( + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/users", + "/api/v1/me/**", + "/api/v1/policy-chat/profile-consent") + .authenticated() + // 명세에 없는 나머지 경로(구현 중인 다른 도메인 등)는 개발 편의상 열어 둔다. + .anyRequest() + .permitAll()); - // ===== [AUTH ON] 2단계: 위 DEV 블록을 지우고 아래를 활성화한다 ===== - // http.authorizeHttpRequests(auth -> auth - // .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - // .requestMatchers( - // "/actuator/health", - // "/error", - // // 인증 없이 접근 가능한 공개 엔드포인트 (팀원 auth/정책 API 기준) - // "/api/v1/auth/oauth/*/authorization-url", - // "/api/v1/auth/oauth/*/callback", - // "/api/v1/auth/token/refresh", - // "/api/v1/auth/logout", - // "/api/v1/policies", - // "/api/v1/policies/**" - // ).permitAll() - // .anyRequest().authenticated() - // ); - // http.addFilterBefore(jwtAuthenticationFilter, - // - // org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore( + new JwtAuthenticationFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -81,13 +94,14 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - // 프론트엔드 개발 서버 주소. 실제 배포 도메인에 맞게 조정한다. - configuration.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:5173")); + configuration.setAllowedOrigins(ALLOWED_ORIGINS); configuration.setAllowedMethods( List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept")); configuration.setExposedHeaders(List.of("Location")); - configuration.setAllowCredentials(false); + // refresh token을 HttpOnly 쿠키로 내려주므로 브라우저가 쿠키를 담아 보내려면 credentials 허용이 필수. + // allowedOrigins가 명시적 목록(와일드카드 아님)이라 credentials=true와 함께 써도 안전하다. + configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/src/main/java/com/bop/youthpick/global/config/WebMvcConfig.java b/src/main/java/com/bop/youthpick/global/config/WebMvcConfig.java new file mode 100644 index 0000000..d2417ae --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.global.config; + +import com.bop.youthpick.auth.service.CurrentUserArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final CurrentUserArgumentResolver currentUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } +} diff --git a/src/main/java/com/bop/youthpick/user/entity/User.java b/src/main/java/com/bop/youthpick/user/entity/User.java index d8f7946..c66928c 100644 --- a/src/main/java/com/bop/youthpick/user/entity/User.java +++ b/src/main/java/com/bop/youthpick/user/entity/User.java @@ -49,4 +49,14 @@ public class User extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + public static User createSocialUser( + String provider, String providerId, String email, String nickname) { + User user = new User(); + user.provider = provider; + user.providerId = providerId; + user.email = email; + user.nickname = nickname; + return user; + } } diff --git a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java index 3589234..3a2fbad 100644 --- a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java +++ b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java @@ -1,6 +1,10 @@ package com.bop.youthpick.user.repository; import com.bop.youthpick.user.entity.User; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface UserRepository extends JpaRepository {} +public interface UserRepository extends JpaRepository { + + Optional findByProviderAndProviderId(String provider, String providerId); +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5de5321..d1fdb5e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -20,7 +20,21 @@ spring: port: ${REDIS_PORT:6379} youthpick: + jwt: + # 서명 secret은 DB 계정과 달리 토큰 위조 가능성과 직결되므로 fallback을 두지 않는다. + # 로컬에서도 .env(또는 셸 export)로 JWT_SECRET을 반드시 설정한다. + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:30m} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:14d} + auth: + refresh-cookie: + # 로컬은 http(비TLS)라 secure=false. localhost:5173↔localhost:8080은 포트만 다른 동일 사이트라 + # SameSite=Lax로도 쿠키가 정상 전송된다(운영은 application.yml의 SameSite=None+Secure 기본값을 쓴다). + secure: false + same-site: Lax oauth: + # provider 콘솔에 등록하는 redirect_uri와 동일해야 하는 프론트엔드 콜백 라우트 + frontend-callback-uri: ${OAUTH_FRONTEND_CALLBACK_URI:http://localhost:3000/oauth/callback} providers: google: client-id: ${GOOGLE_OAUTH_CLIENT_ID:} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 224faea..ae2462f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,4 +4,19 @@ spring: flyway: # 기본 비활성(안전 기본값) — MySQL을 쓰는 프로필(local, 추후 prod)이 각자 true로 켠다. # test는 H2 + ddl-auto(create-drop)라 꺼진 기본값을 그대로 쓴다. - enabled: false \ No newline at end of file + enabled: false + http: + client: + # OAuthClient 등 자동구성된 RestClient.Builder에 전역 적용. provider가 응답이 없을 때 + # 스레드/DB 커넥션을 오래 붙잡지 않도록 한다. + connect-timeout: 3s + read-timeout: 5s + +youthpick: + auth: + refresh-cookie: + # 운영 안전 기본값: 프론트/백엔드가 서로 다른 도메인(크로스사이트)이라 SameSite=None + Secure가 필수. + # 로컬(application-local.yml)은 http라 secure=false, localhost 동일 사이트라 SameSite=Lax로 완화한다. + path: /api/v1/auth + secure: true + same-site: None \ No newline at end of file diff --git a/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java b/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java index e79a7a7..72fe78a 100644 --- a/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java +++ b/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java @@ -2,10 +2,8 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; @SpringBootTest -@ActiveProfiles("test") class YouthpickApplicationTests { @Test diff --git a/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java b/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java new file mode 100644 index 0000000..d7a9f03 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java @@ -0,0 +1,210 @@ +package com.bop.youthpick.auth.controller; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.dto.TokenResponse; +import com.bop.youthpick.auth.service.AuthService; +import com.bop.youthpick.auth.service.RefreshTokenCookieSupport; +import com.bop.youthpick.user.entity.Role; +import com.bop.youthpick.user.entity.User; +import jakarta.servlet.http.Cookie; +import java.time.Duration; +import java.util.List; +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.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +class AuthControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AuthService authService; + + @MockitoBean private RefreshTokenCookieSupport refreshTokenCookieSupport; + + @Test + void 인가_url_요청시_ApiResponse로_url을_반환한다() throws Exception { + when(authService.buildAuthorizationUrl("google")) + .thenReturn("https://accounts.google.com/o/oauth2/v2/auth?state=abc"); + + mockMvc.perform(get("/api/v1/auth/oauth/{provider}/authorization-url", "google")) + .andExpect(status().isOk()) + .andExpect( + jsonPath("$.data.authorizationUrl") + .value("https://accounts.google.com/o/oauth2/v2/auth?state=abc")); + } + + @Test + void 콜백_성공시_access_token은_JSON으로_refresh_token은_쿠키로_내려간다() throws Exception { + when(authService.login("google", "auth-code", "state-value")) + .thenReturn(TokenResponse.of("access-token", "refresh-token", 1800)); + stubIssuedCookie(); + + mockMvc.perform( + post("/api/v1/auth/oauth/{provider}/callback", "google") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"code": "auth-code", "state": "state-value"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("access-token")) + .andExpect(jsonPath("$.data.tokenType").value("Bearer")) + .andExpect(jsonPath("$.data.refreshToken").doesNotExist()) + .andExpect(cookie().value(RefreshTokenCookieSupport.COOKIE_NAME, "refresh-token")); + } + + @Test + void 콜백_code가_없으면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + post("/api/v1/auth/oauth/{provider}/callback", "google") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"state": "state-value"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void refresh_쿠키가_있으면_재발급하고_새_refresh_token을_쿠키로_내려간다() throws Exception { + when(authService.refresh("old-refresh-token")) + .thenReturn(TokenResponse.of("new-access", "new-refresh", 1800)); + stubIssuedCookie(); + + mockMvc.perform( + post("/api/v1/auth/token/refresh") + .cookie( + new Cookie( + RefreshTokenCookieSupport.COOKIE_NAME, + "old-refresh-token"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").value("new-access")) + .andExpect(jsonPath("$.data.refreshToken").doesNotExist()) + .andExpect(cookie().value(RefreshTokenCookieSupport.COOKIE_NAME, "new-refresh")); + } + + @Test + void refresh_쿠키가_없으면_401과_A007을_반환한다() throws Exception { + mockMvc.perform(post("/api/v1/auth/token/refresh")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A007")); + } + + @Test + void refresh_허용되지_않은_Origin이면_403과_A008을_반환하고_서비스를_호출하지_않는다() throws Exception { + mockMvc.perform( + post("/api/v1/auth/token/refresh") + .header("Origin", "https://evil.example.com") + .cookie( + new Cookie( + RefreshTokenCookieSupport.COOKIE_NAME, + "old-refresh-token"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("A008")); + + verify(authService, never()).refresh(anyString()); + } + + @Test + void 인증된_사용자가_로그아웃하면_204와_함께_refresh_쿠키를_지운다() throws Exception { + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + when(authService.getCurrentUser(1L)).thenReturn(user); + when(refreshTokenCookieSupport.clear()) + .thenReturn( + ResponseCookie.from(RefreshTokenCookieSupport.COOKIE_NAME, "") + .path("/api/v1/auth") + .maxAge(Duration.ZERO) + .build()); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isNoContent()) + .andExpect( + cookie().maxAge(RefreshTokenCookieSupport.COOKIE_NAME, 0))); + } + + @Test + void 인증되지_않은_me_요청은_401과_A001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/auth/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + + @Test + void 인증된_사용자면_me_요청시_사용자_정보를_반환한다() throws Exception { + User user = mock(User.class); + when(user.getId()).thenReturn(1L); + when(user.getEmail()).thenReturn("a@a.com"); + when(user.getNickname()).thenReturn("닉네임"); + when(user.getRole()).thenReturn(Role.USER); + when(authService.getCurrentUser(1L)).thenReturn(user); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform(get("/api/v1/auth/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.email").value("a@a.com")) + .andExpect(jsonPath("$.data.role").value("USER"))); + } + + private void stubIssuedCookie() { + when(refreshTokenCookieSupport.issue(anyString())) + .thenAnswer( + invocation -> + ResponseCookie.from( + RefreshTokenCookieSupport.COOKIE_NAME, + invocation.getArgument(0, String.class)) + .httpOnly(true) + .path("/api/v1/auth") + .maxAge(Duration.ofDays(14)) + .build()); + } + + /** + * addFilters=false로 시큐리티 필터 체인(JwtAuthenticationFilter 포함)을 건너뛰므로, SecurityContextHolder를 직접 + * 채워 @AuthenticationPrincipal을 해석시킨다. + */ + private void withAuthenticatedPrincipal(ThrowingRunnable runnable) throws Exception { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(1L, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + try { + runnable.run(); + } finally { + SecurityContextHolder.clearContext(); + } + } + + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..c867617 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java @@ -0,0 +1,236 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.auth.dto.OAuthUserInfo; +import com.bop.youthpick.auth.dto.TokenResponse; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock private OAuthClient oAuthClient; + @Mock private OAuthStateStore oAuthStateStore; + @Mock private UserRepository userRepository; + @Mock private JwtTokenProvider jwtTokenProvider; + @Mock private RefreshTokenStore refreshTokenStore; + + private AuthService authService; + + @BeforeEach + void setUp() { + OAuthProperties oAuthProperties = + new OAuthProperties( + "http://localhost:3000/oauth/callback", + Map.of( + "google", + new OAuthProperties.Registration("client-id", "client-secret"))); + authService = + new AuthService( + oAuthProperties, + oAuthClient, + oAuthStateStore, + userRepository, + jwtTokenProvider, + refreshTokenStore); + } + + @Test + void 등록된_provider면_state를_저장하고_인가_url을_생성한다() throws Exception { + String url = authService.buildAuthorizationUrl("google"); + + Map query = parseQuery(url); + assertThat(url).startsWith("https://accounts.google.com/o/oauth2/v2/auth"); + assertThat(query.get("client_id")).isEqualTo("client-id"); + assertThat(query.get("redirect_uri")).isEqualTo("http://localhost:3000/oauth/callback"); + assertThat(query.get("state")).isNotBlank(); + verify(oAuthStateStore).save(eq(query.get("state")), eq("GOOGLE")); + } + + @Test + void 등록되지_않은_provider면_인가_url_생성시_예외() { + assertThatThrownBy(() -> authService.buildAuthorizationUrl("facebook")) + .isInstanceOf(AuthException.class); + } + + @Test + void state_검증에_실패하면_로그인이_거부된다() { + when(oAuthStateStore.consume("bad-state", "GOOGLE")).thenReturn(false); + + assertThatThrownBy(() -> authService.login("google", "code", "bad-state")) + .isInstanceOf(AuthException.class); + + verify(oAuthClient, never()).exchangeCodeForAccessToken(any(), any(), any(), any(), any()); + } + + @Test + void 신규_사용자면_생성하고_토큰을_발급한다() { + when(oAuthStateStore.consume("state-value", "GOOGLE")).thenReturn(true); + when(oAuthClient.exchangeCodeForAccessToken( + eq(OAuthProvider.GOOGLE), + eq("client-id"), + eq("client-secret"), + anyString(), + eq("code"))) + .thenReturn("provider-access-token"); + when(oAuthClient.fetchUserInfo(OAuthProvider.GOOGLE, "provider-access-token")) + .thenReturn(new OAuthUserInfo("GOOGLE", "provider-id-1", "a@a.com", "닉네임")); + when(userRepository.findByProviderAndProviderId("GOOGLE", "provider-id-1")) + .thenReturn(Optional.empty()); + User savedUser = User.createSocialUser("GOOGLE", "provider-id-1", "a@a.com", "닉네임"); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + when(jwtTokenProvider.createAccessToken(any(), any())).thenReturn("jwt-access"); + when(jwtTokenProvider.createRefreshToken(any())).thenReturn("jwt-refresh"); + when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); + when(jwtTokenProvider.accessTokenExpirationSeconds()).thenReturn(1800L); + + TokenResponse tokens = authService.login("google", "code", "state-value"); + + verify(userRepository).save(any(User.class)); + verify(refreshTokenStore).save(savedUser.getId(), "jwt-refresh", Duration.ofDays(14)); + assertThat(tokens.accessToken()).isEqualTo("jwt-access"); + assertThat(tokens.refreshToken()).isEqualTo("jwt-refresh"); + assertThat(tokens.expiresIn()).isEqualTo(1800L); + } + + @Test + void 기존_사용자면_새로_생성하지_않는다() { + when(oAuthStateStore.consume("state-value", "GOOGLE")).thenReturn(true); + when(oAuthClient.exchangeCodeForAccessToken( + eq(OAuthProvider.GOOGLE), + eq("client-id"), + eq("client-secret"), + anyString(), + eq("code"))) + .thenReturn("provider-access-token"); + when(oAuthClient.fetchUserInfo(OAuthProvider.GOOGLE, "provider-access-token")) + .thenReturn(new OAuthUserInfo("GOOGLE", "provider-id-1", "a@a.com", "닉네임")); + when(userRepository.findByProviderAndProviderId("GOOGLE", "provider-id-1")) + .thenReturn( + Optional.of( + User.createSocialUser( + "GOOGLE", "provider-id-1", "a@a.com", "닉네임"))); + when(jwtTokenProvider.createAccessToken(any(), any())).thenReturn("jwt-access"); + when(jwtTokenProvider.createRefreshToken(any())).thenReturn("jwt-refresh"); + when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); + + authService.login("google", "code", "state-value"); + + verify(userRepository, never()).save(any()); + } + + @Test + void refresh_token_rotate가_실패하면_거부된다() { + User user = mock(User.class); + io.jsonwebtoken.Claims claims = mock(io.jsonwebtoken.Claims.class); + when(user.getId()).thenReturn(1L); + when(user.getRole()).thenReturn(com.bop.youthpick.user.entity.Role.USER); + when(jwtTokenProvider.validateRefreshToken("refresh-token")).thenReturn(claims); + when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(jwtTokenProvider.createAccessToken(1L, "USER")).thenReturn("new-access"); + when(jwtTokenProvider.createRefreshToken(1L)).thenReturn("new-refresh"); + when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); + when(refreshTokenStore.rotate(1L, "refresh-token", "new-refresh", Duration.ofDays(14))) + .thenReturn(false); + + assertThatThrownBy(() -> authService.refresh("refresh-token")) + .isInstanceOf(AuthException.class); + } + + @Test + void refresh_token이_유효하면_원자적으로_rotate하고_토큰을_재발급한다() { + User user = mock(User.class); + io.jsonwebtoken.Claims claims = mock(io.jsonwebtoken.Claims.class); + when(user.getId()).thenReturn(1L); + when(user.getRole()).thenReturn(com.bop.youthpick.user.entity.Role.USER); + when(jwtTokenProvider.validateRefreshToken("refresh-token")).thenReturn(claims); + when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(jwtTokenProvider.createAccessToken(1L, "USER")).thenReturn("new-access"); + when(jwtTokenProvider.createRefreshToken(1L)).thenReturn("new-refresh"); + when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); + when(jwtTokenProvider.accessTokenExpirationSeconds()).thenReturn(1800L); + when(refreshTokenStore.rotate(1L, "refresh-token", "new-refresh", Duration.ofDays(14))) + .thenReturn(true); + + TokenResponse tokens = authService.refresh("refresh-token"); + + assertThat(tokens.accessToken()).isEqualTo("new-access"); + assertThat(tokens.refreshToken()).isEqualTo("new-refresh"); + verify(refreshTokenStore).rotate(1L, "refresh-token", "new-refresh", Duration.ofDays(14)); + } + + @Test + void 동시_콜백으로_유니크_제약이_위반되면_재조회로_복구한다() { + when(oAuthStateStore.consume("state-value", "GOOGLE")).thenReturn(true); + when(oAuthClient.exchangeCodeForAccessToken( + eq(OAuthProvider.GOOGLE), + eq("client-id"), + eq("client-secret"), + anyString(), + eq("code"))) + .thenReturn("provider-access-token"); + when(oAuthClient.fetchUserInfo(OAuthProvider.GOOGLE, "provider-access-token")) + .thenReturn(new OAuthUserInfo("GOOGLE", "provider-id-1", "a@a.com", "닉네임")); + User existingUser = User.createSocialUser("GOOGLE", "provider-id-1", "a@a.com", "닉네임"); + when(userRepository.findByProviderAndProviderId("GOOGLE", "provider-id-1")) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(existingUser)); + when(userRepository.save(any(User.class))) + .thenThrow( + new org.springframework.dao.DataIntegrityViolationException( + "uk_users_provider")); + when(jwtTokenProvider.createAccessToken(any(), any())).thenReturn("jwt-access"); + when(jwtTokenProvider.createRefreshToken(any())).thenReturn("jwt-refresh"); + when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); + when(jwtTokenProvider.accessTokenExpirationSeconds()).thenReturn(1800L); + + TokenResponse tokens = authService.login("google", "code", "state-value"); + + assertThat(tokens.accessToken()).isEqualTo("jwt-access"); + verify(userRepository, org.mockito.Mockito.times(2)) + .findByProviderAndProviderId("GOOGLE", "provider-id-1"); + } + + @Test + void 로그아웃하면_refresh_token을_삭제한다() { + authService.logout(1L); + + verify(refreshTokenStore).delete(1L); + } + + private Map parseQuery(String url) { + String query = URI.create(url).getRawQuery(); + Map params = new HashMap<>(); + for (String pair : query.split("&")) { + String[] parts = pair.split("=", 2); + params.put( + URLDecoder.decode(parts[0], StandardCharsets.UTF_8), + URLDecoder.decode(parts[1], StandardCharsets.UTF_8)); + } + return params; + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java b/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java new file mode 100644 index 0000000..91fe59e --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java @@ -0,0 +1,79 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.exception.AuthException; +import java.lang.reflect.Method; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +class CurrentUserArgumentResolverTest { + + private final CurrentUserArgumentResolver resolver = new CurrentUserArgumentResolver(); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void CurrentUser가_붙은_Long_파라미터를_지원한다() throws Exception { + MethodParameter parameter = annotatedUserIdParameter(); + + assertThat(resolver.supportsParameter(parameter)).isTrue(); + } + + @Test + void CurrentUser가_없으면_지원하지_않는다() throws Exception { + MethodParameter parameter = plainUserIdParameter(); + + assertThat(resolver.supportsParameter(parameter)).isFalse(); + } + + @Test + void 인증된_요청이면_principal의_userId를_그대로_반환한다() throws Exception { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(1L, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + + Object resolved = resolver.resolveArgument(annotatedUserIdParameter(), null, null, null); + + assertThat(resolved).isEqualTo(1L); + } + + @Test + void 인증되지_않았으면_예외() throws Exception { + assertThatThrownBy( + () -> + resolver.resolveArgument( + annotatedUserIdParameter(), null, null, null)) + .isInstanceOf(AuthException.class); + } + + private MethodParameter annotatedUserIdParameter() throws Exception { + Method method = TestController.class.getMethod("withCurrentUser", Long.class); + return new MethodParameter(method, 0); + } + + private MethodParameter plainUserIdParameter() throws Exception { + Method method = TestController.class.getMethod("withoutCurrentUser", Long.class); + return new MethodParameter(method, 0); + } + + private static class TestController { + public void withCurrentUser(@CurrentUser Long userId) {} + + public void withoutCurrentUser(Long userId) {} + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java b/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..7bf7366 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java @@ -0,0 +1,96 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; + +class JwtAuthenticationFilterTest { + + private final JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + private final JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void 유효한_토큰이면_인증정보를_채운다() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + Claims claims = mock(Claims.class); + when(request.getHeader("Authorization")).thenReturn("Bearer valid-token"); + when(jwtTokenProvider.validateAccessToken("valid-token")).thenReturn(claims); + when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); + when(jwtTokenProvider.getRole(claims)).thenReturn("USER"); + + filter.doFilter(request, response, filterChain); + + AuthPrincipal principal = + (AuthPrincipal) + SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + assertThat(principal.userId()).isEqualTo(1L); + assertThat(principal.role()).isEqualTo("USER"); + verify(filterChain).doFilter(request, response); + } + + @Test + void role이_없으면_인증정보를_채우지_않고_통과시킨다() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + Claims claims = mock(Claims.class); + when(request.getHeader("Authorization")).thenReturn("Bearer valid-token"); + when(jwtTokenProvider.validateAccessToken("valid-token")).thenReturn(claims); + when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); + when(jwtTokenProvider.getRole(claims)).thenReturn(null); + + filter.doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + void 토큰이_없으면_인증정보를_채우지_않는다() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + when(request.getHeader("Authorization")).thenReturn(null); + + filter.doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + void 토큰이_유효하지_않으면_인증정보를_비우고_통과시킨다() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + when(request.getHeader("Authorization")).thenReturn("Bearer invalid-token"); + doThrow(new AuthException(AuthErrorCode.INVALID_TOKEN)) + .when(jwtTokenProvider) + .validateAccessToken("invalid-token"); + + filter.doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java b/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java new file mode 100644 index 0000000..e5ab58d --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java @@ -0,0 +1,93 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bop.youthpick.auth.exception.AuthException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class JwtTokenProviderTest { + + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + JwtProperties jwtProperties = + new JwtProperties( + "test-secret-key-please-be-long-enough-for-hs256", + Duration.ofMinutes(30), + Duration.ofDays(14)); + jwtTokenProvider = new JwtTokenProvider(jwtProperties); + } + + @Test + void access_token을_생성하고_검증하면_userId와_role을_읽을_수_있다() { + String token = jwtTokenProvider.createAccessToken(1L, "USER"); + + Claims claims = jwtTokenProvider.validateAccessToken(token); + + assertThat(jwtTokenProvider.getUserId(claims)).isEqualTo(1L); + assertThat(jwtTokenProvider.getRole(claims)).isEqualTo("USER"); + } + + @Test + void subject가_숫자가_아니면_예외() { + // createToken은 항상 숫자 subject를 넣지만, 파싱된 Claims가 오염됐을 가능성을 방어적으로 검증한다. + Claims claims = Jwts.claims().subject("not-a-number").add("type", "access").build(); + + assertThatThrownBy(() -> jwtTokenProvider.getUserId(claims)) + .isInstanceOf(AuthException.class); + } + + @Test + void refresh_token을_access_token으로_검증하면_예외() { + String refreshToken = jwtTokenProvider.createRefreshToken(1L); + + assertThatThrownBy(() -> jwtTokenProvider.validateAccessToken(refreshToken)) + .isInstanceOf(AuthException.class); + } + + @Test + void access_token을_refresh_token으로_검증하면_예외() { + String accessToken = jwtTokenProvider.createAccessToken(1L, "USER"); + + assertThatThrownBy(() -> jwtTokenProvider.validateRefreshToken(accessToken)) + .isInstanceOf(AuthException.class); + } + + @Test + void 위조된_토큰은_검증에_실패한다() { + String token = jwtTokenProvider.createAccessToken(1L, "USER") + "tampered"; + + assertThatThrownBy(() -> jwtTokenProvider.validateAccessToken(token)) + .isInstanceOf(AuthException.class); + } + + @Test + void 만료된_토큰은_검증에_실패한다() { + JwtProperties expiredProperties = + new JwtProperties( + "test-secret-key-please-be-long-enough-for-hs256", + Duration.ofMillis(1), + Duration.ofDays(14)); + JwtTokenProvider expiredTokenProvider = new JwtTokenProvider(expiredProperties); + String token = expiredTokenProvider.createAccessToken(1L, "USER"); + + await(); + + assertThatThrownBy(() -> expiredTokenProvider.validateAccessToken(token)) + .isInstanceOf(AuthException.class); + } + + private void await() { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/OAuthClientTest.java b/src/test/java/com/bop/youthpick/auth/service/OAuthClientTest.java new file mode 100644 index 0000000..cc0c871 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/OAuthClientTest.java @@ -0,0 +1,158 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import com.bop.youthpick.auth.dto.OAuthUserInfo; +import com.bop.youthpick.auth.exception.AuthException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class OAuthClientTest { + + @Test + void 토큰_교환_성공시_access_token을_반환한다() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo(OAuthProvider.GOOGLE.getTokenUri())) + .andExpect(method(HttpMethod.POST)) + .andRespond( + withSuccess("{\"access_token\":\"token-1\"}", MediaType.APPLICATION_JSON)); + OAuthClient client = new OAuthClient(builder); + + String accessToken = + client.exchangeCodeForAccessToken( + OAuthProvider.GOOGLE, "id", "secret", "http://localhost/callback", "code"); + + assertThat(accessToken).isEqualTo("token-1"); + } + + @Test + void 토큰_응답에_access_token이_없으면_예외() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo(OAuthProvider.GOOGLE.getTokenUri())) + .andRespond( + withSuccess("{\"error\":\"invalid_grant\"}", MediaType.APPLICATION_JSON)); + OAuthClient client = new OAuthClient(builder); + + assertThatThrownBy( + () -> + client.exchangeCodeForAccessToken( + OAuthProvider.GOOGLE, + "id", + "secret", + "http://localhost/callback", + "code")) + .isInstanceOf(AuthException.class); + } + + @Test + void provider_통신_실패시_예외로_변환한다() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + server.expect(requestTo(OAuthProvider.KAKAO.getUserInfoUri())) + .andRespond(withServerError()); + OAuthClient client = new OAuthClient(builder); + + assertThatThrownBy(() -> client.fetchUserInfo(OAuthProvider.KAKAO, "token")) + .isInstanceOf(AuthException.class); + } + + @Test + void 구글_userinfo_응답을_정규화한다() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + String body = + """ + { + "sub": "google-1", + "email": "a@a.com", + "name": "이름" + } + """; + server.expect(requestTo(OAuthProvider.GOOGLE.getUserInfoUri())) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + OAuthClient client = new OAuthClient(builder); + + OAuthUserInfo userInfo = client.fetchUserInfo(OAuthProvider.GOOGLE, "token"); + + assertThat(userInfo.provider()).isEqualTo("GOOGLE"); + assertThat(userInfo.providerId()).isEqualTo("google-1"); + assertThat(userInfo.email()).isEqualTo("a@a.com"); + assertThat(userInfo.nickname()).isEqualTo("이름"); + } + + @Test + void 카카오_userinfo_응답을_정규화한다() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + String body = + """ + { + "id": 12345, + "kakao_account": { + "email": "a@a.com", + "profile": { "nickname": "닉네임" } + } + } + """; + server.expect(requestTo(OAuthProvider.KAKAO.getUserInfoUri())) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + OAuthClient client = new OAuthClient(builder); + + OAuthUserInfo userInfo = client.fetchUserInfo(OAuthProvider.KAKAO, "token"); + + assertThat(userInfo.provider()).isEqualTo("KAKAO"); + assertThat(userInfo.providerId()).isEqualTo("12345"); + assertThat(userInfo.email()).isEqualTo("a@a.com"); + assertThat(userInfo.nickname()).isEqualTo("닉네임"); + } + + @Test + void userinfo_응답에_providerId가_없으면_예외() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + String body = + """ + { + "kakao_account": { "email": "a@a.com" } + } + """; + server.expect(requestTo(OAuthProvider.KAKAO.getUserInfoUri())) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + OAuthClient client = new OAuthClient(builder); + + assertThatThrownBy(() -> client.fetchUserInfo(OAuthProvider.KAKAO, "token")) + .isInstanceOf(AuthException.class); + } + + @Test + void 네이버_userinfo_응답을_정규화한다() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + String body = + """ + { + "response": { "id": "naver-1", "email": "b@b.com", "name": "이름" } + } + """; + server.expect(requestTo(OAuthProvider.NAVER.getUserInfoUri())) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + OAuthClient client = new OAuthClient(builder); + + OAuthUserInfo userInfo = client.fetchUserInfo(OAuthProvider.NAVER, "token"); + + assertThat(userInfo.provider()).isEqualTo("NAVER"); + assertThat(userInfo.providerId()).isEqualTo("naver-1"); + assertThat(userInfo.email()).isEqualTo("b@b.com"); + assertThat(userInfo.nickname()).isEqualTo("이름"); + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/OAuthPropertiesTest.java b/src/test/java/com/bop/youthpick/auth/service/OAuthPropertiesTest.java new file mode 100644 index 0000000..10d21f6 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/OAuthPropertiesTest.java @@ -0,0 +1,19 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class OAuthPropertiesTest { + + @Test + void Registration_toString은_clientSecret을_마스킹한다() { + OAuthProperties.Registration registration = + new OAuthProperties.Registration("client-id", "super-secret-value"); + + String result = registration.toString(); + + assertThat(result).contains("client-id"); + assertThat(result).doesNotContain("super-secret-value"); + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/OAuthProviderTest.java b/src/test/java/com/bop/youthpick/auth/service/OAuthProviderTest.java new file mode 100644 index 0000000..94fe5c4 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/OAuthProviderTest.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bop.youthpick.auth.exception.AuthException; +import org.junit.jupiter.api.Test; + +class OAuthProviderTest { + + @Test + void 대소문자와_무관하게_provider를_찾는다() { + assertThat(OAuthProvider.from("google")).isEqualTo(OAuthProvider.GOOGLE); + assertThat(OAuthProvider.from("KAKAO")).isEqualTo(OAuthProvider.KAKAO); + } + + @Test + void 지원하지_않는_provider면_예외() { + assertThatThrownBy(() -> OAuthProvider.from("facebook")).isInstanceOf(AuthException.class); + assertThatThrownBy(() -> OAuthProvider.from(null)).isInstanceOf(AuthException.class); + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java b/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java new file mode 100644 index 0000000..f550879 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java @@ -0,0 +1,61 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class OAuthStateStoreTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + + private OAuthStateStore oAuthStateStore; + + @BeforeEach + void setUp() { + oAuthStateStore = new OAuthStateStore(redisTemplate); + } + + @Test + void 저장된_provider와_일치하면_소비하고_true를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:oauth-state:state-1")).thenReturn("GOOGLE"); + + boolean consumed = oAuthStateStore.consume("state-1", "GOOGLE"); + + assertThat(consumed).isTrue(); + verify(redisTemplate).delete("auth:oauth-state:state-1"); + } + + @Test + void provider가_다르면_소비하지_않고_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:oauth-state:state-1")).thenReturn("KAKAO"); + + boolean consumed = oAuthStateStore.consume("state-1", "GOOGLE"); + + assertThat(consumed).isFalse(); + verify(redisTemplate, never()).delete(any(String.class)); + } + + @Test + void state가_없으면_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:oauth-state:unknown")).thenReturn(null); + + boolean consumed = oAuthStateStore.consume("unknown", "GOOGLE"); + + assertThat(consumed).isFalse(); + } +} diff --git a/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java b/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java new file mode 100644 index 0000000..56499f9 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java @@ -0,0 +1,83 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.RedisScript; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenStoreTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + + private RefreshTokenStore refreshTokenStore; + + @BeforeEach + void setUp() { + refreshTokenStore = new RefreshTokenStore(redisTemplate); + } + + @Test + void 저장하면_원문이_아니라_해시값을_TTL과_함께_저장한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + + refreshTokenStore.save(1L, "refresh-token", Duration.ofDays(14)); + + verify(valueOperations) + .set(eq("auth:refresh-token:1"), valueCaptor.capture(), eq(Duration.ofDays(14))); + assertThat(valueCaptor.getValue()).isNotEqualTo("refresh-token"); + assertThat(valueCaptor.getValue()).hasSize(64); // SHA-256 hex 문자열 길이 + } + + @Test + void rotate가_성공하면_true를_반환한다() { + when(redisTemplate.execute(any(RedisScript.class), anyList(), any(), any(), any())) + .thenReturn(1L); + + boolean rotated = + refreshTokenStore.rotate(1L, "old-token", "new-token", Duration.ofDays(14)); + + assertThat(rotated).isTrue(); + verify(redisTemplate) + .execute( + any(RedisScript.class), + eq(List.of("auth:refresh-token:1")), + any(), + any(), + any()); + } + + @Test + void 저장된_값과_불일치하면_rotate가_false를_반환한다() { + when(redisTemplate.execute(any(RedisScript.class), anyList(), any(), any(), any())) + .thenReturn(0L); + + boolean rotated = + refreshTokenStore.rotate(1L, "old-token", "new-token", Duration.ofDays(14)); + + assertThat(rotated).isFalse(); + } + + @Test + void 삭제하면_userId_키를_지운다() { + refreshTokenStore.delete(1L); + + verify(redisTemplate).delete("auth:refresh-token:1"); + } +} diff --git a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java new file mode 100644 index 0000000..bfee10f --- /dev/null +++ b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java @@ -0,0 +1,106 @@ +package com.bop.youthpick.global.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.service.JwtTokenProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +/** API 명세서 권한 컬럼(관리자/회원/비회원)이 SecurityConfig에 올바르게 반영됐는지 확인한다. */ +@SpringBootTest +@AutoConfigureMockMvc +class SecurityConfigTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + + @Test + void 관리자_경로에_인증없이_접근하면_401_A001() throws Exception { + mockMvc.perform(get("/api/v1/admin/policy-sync-jobs")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + + @Test + void 일반_회원이_관리자_경로에_접근하면_403_A008() throws Exception { + String token = jwtTokenProvider.createAccessToken(1L, "USER"); + + mockMvc.perform( + get("/api/v1/admin/policy-sync-jobs") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("A008")); + } + + @Test + void 관리자가_관리자_경로에_접근하면_인가는_통과한다() throws Exception { + String token = jwtTokenProvider.createAccessToken(1L, "ADMIN"); + + int status = + mockMvc.perform( + get("/api/v1/admin/policy-sync-jobs") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andReturn() + .getResponse() + .getStatus(); + + // 컨트롤러가 아직 없어 401/403이 아닌 다른 상태로 응답한다 — 인가는 통과했다는 뜻이다. + assertThat(status).isNotIn(401, 403); + } + + @Test + void 회원_전용_경로에_인증없이_접근하면_401() throws Exception { + mockMvc.perform(get("/api/v1/me/favorites")).andExpect(status().isUnauthorized()); + } + + @Test + void 회원이_회원_전용_경로에_접근하면_인가는_통과한다() throws Exception { + String token = jwtTokenProvider.createAccessToken(1L, "USER"); + + int status = + mockMvc.perform( + get("/api/v1/me/favorites") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andReturn() + .getResponse() + .getStatus(); + + assertThat(status).isNotIn(401, 403); + } + + @Test + void 공개_경로는_인증없이_접근해도_401_403이_아니다() throws Exception { + int status = mockMvc.perform(get("/api/v1/policies")).andReturn().getResponse().getStatus(); + + assertThat(status).isNotIn(401, 403); + } + + @Test + void 회원_탈퇴_경로에_인증없이_접근하면_401() throws Exception { + mockMvc.perform(delete("/api/v1/users")).andExpect(status().isUnauthorized()); + } + + @Test + void 회원이_탈퇴_경로에_접근하면_인가는_통과한다() throws Exception { + String token = jwtTokenProvider.createAccessToken(1L, "USER"); + + int status = + mockMvc.perform( + delete("/api/v1/users") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andReturn() + .getResponse() + .getStatus(); + + // 컨트롤러가 아직 없어 401이 아닌 다른 상태로 응답한다 — 인가는 통과했다는 뜻이다. + assertThat(status).isNotEqualTo(401); + } +} diff --git a/src/test/java/com/bop/youthpick/user/repository/UserRepositoryTest.java b/src/test/java/com/bop/youthpick/user/repository/UserRepositoryTest.java new file mode 100644 index 0000000..382e794 --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/repository/UserRepositoryTest.java @@ -0,0 +1,37 @@ +package com.bop.youthpick.user.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.user.entity.User; +import java.util.Optional; +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.context.annotation.Import; + +// @DataJpaTest는 슬라이스 스캔 대상에서 일반 @Configuration을 제외하므로, BaseEntity의 +// createdAt/updatedAt을 채우는 JpaAuditingConfig(@EnableJpaAuditing)를 직접 import한다. +@DataJpaTest +@Import(JpaAuditingConfig.class) +class UserRepositoryTest { + + @Autowired private UserRepository userRepository; + + @Test + void provider와_providerId가_일치하는_사용자를_찾는다() { + userRepository.save(User.createSocialUser("GOOGLE", "google-1", "a@a.com", "닉네임")); + + Optional found = userRepository.findByProviderAndProviderId("GOOGLE", "google-1"); + + assertThat(found).isPresent(); + assertThat(found.get().getEmail()).isEqualTo("a@a.com"); + } + + @Test + void 일치하는_사용자가_없으면_빈_값을_반환한다() { + Optional found = userRepository.findByProviderAndProviderId("GOOGLE", "unknown"); + + assertThat(found).isEmpty(); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml deleted file mode 100644 index 85a6d81..0000000 --- a/src/test/resources/application-test.yml +++ /dev/null @@ -1,14 +0,0 @@ -spring: - datasource: - url: jdbc:h2:mem:youthpick-test;MODE=MySQL;DATABASE_TO_LOWER=TRUE - username: sa - password: - driver-class-name: org.h2.Driver - jpa: - hibernate: - ddl-auto: create-drop - show-sql: false - data: - redis: - host: localhost - port: 6379 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 14be887..0ac4a29 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -17,7 +17,17 @@ spring: port: ${REDIS_PORT:6379} youthpick: + jwt: + secret: test-secret-key-please-be-long-enough-for-hs256 + access-token-expiration: 30m + refresh-token-expiration: 14d + auth: + refresh-cookie: + path: /api/v1/auth + secure: false + same-site: Lax oauth: + frontend-callback-uri: http://localhost:3000/oauth/callback providers: google: client-id: ${GOOGLE_OAUTH_CLIENT_ID:} From b1f45059bd53d82425505fa9a8fb315b4d91f3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Mon, 13 Jul 2026 15:13:20 +0900 Subject: [PATCH 13/93] =?UTF-8?q?docs:=20Claude=20Code=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7(Google=20AOSP=20Style)=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=AC=B8=EC=84=9C=ED=99=94=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: Claude Code 코드 포맷(Google AOSP Style) 규칙 문서화 Closes #33 * docs: spotlessCheck를 test/build와 분리된 필수 단계로 명시 CodeRabbit 리뷰 반영: CI가 spotlessCheck를 별도 실행하고 build에서는 제외하므로, test/build로 대체 가능하다는 뉘앙스를 제거한다. --- .claude/rules/code-style.md | 11 +++++++++++ .claude/rules/workflow.md | 1 + AGENTS.md | 1 + 3 files changed, 13 insertions(+) create mode 100644 .claude/rules/code-style.md diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md new file mode 100644 index 0000000..7c23dfd --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,11 @@ +--- +paths: + - "src/**" +--- + +# 코드 포맷 (Google Java Style, AOSP) + +- 포맷 기준(source of truth)은 `build.gradle`의 Spotless(`googleJavaFormat().aosp()`)다. 4-space 들여쓰기, 8-space continuation indent. +- 코드를 작성/수정한 뒤 `./gradlew spotlessApply`로 정리하고, 완료를 주장하기 전 `./gradlew spotlessCheck`가 통과하는지 확인한다(CI가 이를 강제한다). +- import는 와일드카드 없이 개별 import, 미사용 import는 제거한다(Spotless `removeUnusedImports()`가 자동 처리하지만 직접 작성 시에도 지킨다). +- IntelliJ에서 사람이 직접 편집할 때는 [`docs/ide/intellij-java-google-aosp-style.xml`](../../docs/ide/intellij-java-google-aosp-style.xml) 스킴(`docs/ide/README.md` 참고)을 import해 저장 시 자동 포맷을 맞춘다. 에이전트는 이 XML을 직접 파싱할 필요 없이 위 Gradle 명령으로 충분하다. diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index 9c45454..d34299c 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -33,4 +33,5 @@ refac: 정책 검색 서비스 책임 분리 - 기능 변경 시 관련 작은 테스트를 먼저 실행하고, 마지막에 전체 테스트를 실행한다. - `./gradlew test`(Windows: `.\gradlew.bat test`)를 통과시킨 뒤에만 완료를 주장한다. +- Java 코드를 작성/수정했다면 `./gradlew spotlessApply`로 포맷을 맞춘 뒤, `./gradlew spotlessCheck`를 별도로 실행해 통과를 확인한다(`test`/`build`는 이 검사를 포함하지 않는다. 코드 포맷 기준은 [`code-style.md`](./code-style.md)). - Docker/Compose 관련 변경 시 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. diff --git a/AGENTS.md b/AGENTS.md index 6a855c9..2afa3c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ | 문서 | 내용 | 적용 범위 | |------|------|-----------| | [`.claude/rules/workflow.md`](./.claude/rules/workflow.md) | 작업 흐름, Git 브랜치/커밋/PR/리뷰, 완료 전 검증 | 항상 | +| [`.claude/rules/code-style.md`](./.claude/rules/code-style.md) | 코드 포맷(Google Java Style, AOSP), Spotless | `src` | | [`.claude/rules/architecture.md`](./.claude/rules/architecture.md) | 패키지 구조, 계층 책임, 금지 구조 | `src/main/java` | | [`.claude/rules/api-design.md`](./.claude/rules/api-design.md) | Controller, DTO, Validation, `ApiResponse`, Pageable | `src/main/java` | | [`.claude/rules/error-handling.md`](./.claude/rules/error-handling.md) | `ErrorCode` 인터페이스, 도메인 에러코드, `ErrorResponse` | `src/main/java` | From b17cdd194319eaf7661a9539e31468cd4b1c9fc6 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 14:39:09 +0900 Subject: [PATCH 14/93] =?UTF-8?q?feat:=20=EC=95=B1=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20Log4j2=20+=20JDBC=EB=A1=9C=20app=5Flogs=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20=EC=A0=81=EC=9E=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 앱 로그를 Log4j2 + JDBC로 app_logs 테이블에 적재 Spring Boot 기본 Logback을 Log4j2로 교체하고, app_logs 전용 커넥션 풀과 커스텀 Appender로 WARN 이상 로그를 DB에 적재한다. 요청 컨텍스트(traceId/method/uri/ip/ userId)는 MDC 필터가 채우고, GlobalExceptionHandler의 미처리 예외를 로깅해 실제 에러가 쌓이도록 연결했다. 테스트는 log4j2-test.xml로 JDBC Appender 없이 콘솔만 사용해 Docker 없이 돈다. Closes #41 Co-Authored-By: Claude Sonnet 5 * refac: app_logs 테이블/엔티티를 ERD 물리명(application_logs)에 맞춰 개명 Flyway V3로 app_logs → application_logs 테이블/인덱스명을 변경하고, AppLog 엔티티와 log4j2 커스텀 Appender/DataSource를 ApplicationLog*로 함께 개명해 SQL·클래스명·설정을 일치시켰다. Refs #41 Co-Authored-By: Claude Sonnet 5 * fix: 로그 테이블 개명 마이그레이션을 V3에서 V4로 변경 V3은 #38(regions 시드) PR에서 이미 사용 중이라 번호가 겹친다. Refs #41 Co-Authored-By: Claude Sonnet 5 * fix: 리뷰 반영 — userId 파싱 실패/프록시 뒤 IP 로깅 문제 수정 - ApplicationLogAppender: user_id MDC 값이 숫자가 아니면 NumberFormatException이 Log4j2까지 전파되어 ignoreExceptions=true로 로그 행 전체가 조용히 유실됐다. NumberFormatException을 잡아 NULL로 적재하도록 수정. - RequestLogContextFilter: request.getRemoteAddr()는 리버스 프록시/로드밸런서 뒤에서 프록시 IP만 반환한다. X-Forwarded-For 헤더의 첫 값을 우선 사용하도록 수정. Refs #41 Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .env.example | 1 + build.gradle | 9 ++ docs/schema.sql | 7 +- .../global/config/SecurityConfig.java | 2 + .../global/error/GlobalExceptionHandler.java | 7 +- .../log/config/ApplicationLogAppender.java | 84 +++++++++++++++++++ .../log/config/ApplicationLogDataSource.java | 49 +++++++++++ .../{AppLog.java => ApplicationLog.java} | 6 +- .../log/service/RequestLogContextFilter.java | 61 ++++++++++++++ ...4__rename_app_logs_to_application_logs.sql | 3 + src/main/resources/log4j2.xml | 21 +++++ src/test/resources/log4j2-test.xml | 16 ++++ 12 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/log/config/ApplicationLogAppender.java create mode 100644 src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java rename src/main/java/com/bop/youthpick/log/entity/{AppLog.java => ApplicationLog.java} (92%) create mode 100644 src/main/java/com/bop/youthpick/log/service/RequestLogContextFilter.java create mode 100644 src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql create mode 100644 src/main/resources/log4j2.xml create mode 100644 src/test/resources/log4j2-test.xml diff --git a/.env.example b/.env.example index d8c281d..8acbb72 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,7 @@ DB_PASSWORD=youthpick DB_PORT=3306 # ===== Database (앱이 도커 MySQL 에 붙을 때 사용. H2 로 개발하면 비워둬도 됨) ===== +# 아래 3개(DB_URL/DB_USERNAME/DB_PASSWORD)는 log4j2.xml의 application_logs JDBC Appender 전용 커넥션 풀도 그대로 재사용한다. DB_URL=jdbc:mysql://localhost:3306/youthpick?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul DB_DRIVER=com.mysql.cj.jdbc.Driver JPA_DDL_AUTO=validate diff --git a/build.gradle b/build.gradle index 16a8425..c1c0e6d 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,12 @@ repositories { mavenCentral() } +configurations { + all { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -26,6 +32,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' @@ -35,6 +42,8 @@ dependencies { runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + // 커스텀 log4j2 Appender(@Plugin) 메타데이터(Log4j2Plugins.dat) 생성용. + annotationProcessor 'org.apache.logging.log4j:log4j-core' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' diff --git a/docs/schema.sql b/docs/schema.sql index ab0c529..473a03b 100644 --- a/docs/schema.sql +++ b/docs/schema.sql @@ -280,11 +280,12 @@ CREATE TABLE policy_batch_history ( ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '정책 수집 배치 이력 (FK 없음 — 독립 이력)'; -- ------------------------------------------------------------ --- 12. app_logs — 앱 에러/요청 로그 (ERDCloud `로그`) +-- 12. application_logs — 앱 에러/요청 로그 (ERDCloud `로그`) -- VARCHAR 길이 누락 정정, user_id NULL 허용(비로그인/배치), -- FK 없음 — 로그는 유저 삭제와 무관하게 보존 +-- V4 마이그레이션에서 app_logs → application_logs로 개명(ERD 물리명 기준) -- ------------------------------------------------------------ -CREATE TABLE app_logs ( +CREATE TABLE application_logs ( id BIGINT NOT NULL AUTO_INCREMENT, level VARCHAR(10) NOT NULL COMMENT 'INFO | WARN | ERROR', message TEXT NOT NULL, @@ -298,7 +299,7 @@ CREATE TABLE app_logs ( user_id BIGINT NULL COMMENT '비로그인/배치 = NULL. FK 안 걺', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), - KEY idx_app_logs_created (created_at) + KEY idx_application_logs_created (created_at) ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '앱 로그 (기한 지나면 삭제)'; -- ------------------------------------------------------------ diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index f5534c3..8ba88ae 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -2,6 +2,7 @@ import com.bop.youthpick.auth.service.JwtAuthenticationFilter; import com.bop.youthpick.auth.service.JwtTokenProvider; +import com.bop.youthpick.log.service.RequestLogContextFilter; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -87,6 +88,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.addFilterBefore( new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + http.addFilterAfter(new RequestLogContextFilter(), JwtAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index 58a218e..c5d8fc2 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -5,6 +5,8 @@ import java.util.List; import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; @@ -19,6 +21,8 @@ @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException exception) { @@ -67,9 +71,10 @@ public ResponseEntity handleCustomException(CustomException excep return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } - // 개발자가 예상치 못한 에러 + // 개발자가 예상치 못한 에러 — application_logs 테이블에 남도록 ERROR로 로깅한다. @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { + log.error("처리되지 않은 예외가 발생했습니다.", exception); ErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } diff --git a/src/main/java/com/bop/youthpick/log/config/ApplicationLogAppender.java b/src/main/java/com/bop/youthpick/log/config/ApplicationLogAppender.java new file mode 100644 index 0000000..542596a --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/config/ApplicationLogAppender.java @@ -0,0 +1,84 @@ +package com.bop.youthpick.log.config; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import javax.sql.DataSource; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginElement; +import org.apache.logging.log4j.core.config.plugins.PluginFactory; +import org.apache.logging.log4j.util.ReadOnlyStringMap; + +/** + * application_logs 테이블에 로그를 적재하는 전용 Appender. Log4j2 내장 JDBC Appender는 컬럼 값을 항상 문자열로 바인딩해 MDC 값이 없을 + * 때 빈 문자열("")을 BIGINT user_id 컬럼에 넣으려다 실패하므로, 직접 PreparedStatement로 타입을 맞춰 적재한다. + */ +@Plugin(name = "ApplicationLog", category = "Core", elementType = "appender", printObject = true) +public final class ApplicationLogAppender extends AbstractAppender { + + private static final String INSERT_SQL = + "INSERT INTO application_logs " + + "(level, message, trace_id, method, uri, ip, exception_class, exception_message, stack_trace, user_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + private ApplicationLogAppender(String name, Filter filter) { + super(name, filter, null, true, Property.EMPTY_ARRAY); + } + + @PluginFactory + public static ApplicationLogAppender createAppender( + @PluginAttribute("name") String name, @PluginElement("Filter") Filter filter) { + return new ApplicationLogAppender(name, filter); + } + + @Override + public void append(LogEvent event) { + DataSource dataSource = ApplicationLogDataSource.getDataSource(); + Throwable thrown = event.getThrown(); + ReadOnlyStringMap context = event.getContextData(); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(INSERT_SQL)) { + statement.setString(1, event.getLevel().toString()); + statement.setString(2, event.getMessage().getFormattedMessage()); + statement.setString(3, context.getValue("traceId")); + statement.setString(4, context.getValue("method")); + statement.setString(5, context.getValue("uri")); + statement.setString(6, context.getValue("ip")); + statement.setString(7, thrown == null ? null : thrown.getClass().getName()); + statement.setString(8, thrown == null ? null : thrown.getMessage()); + statement.setString(9, thrown == null ? null : stackTraceOf(thrown)); + setNullableLong(statement, 10, context.getValue("userId")); + statement.executeUpdate(); + } catch (SQLException e) { + LOGGER.error("application_logs 적재에 실패했습니다.", e); + } + } + + private void setNullableLong(PreparedStatement statement, int index, String value) + throws SQLException { + if (value == null || value.isBlank()) { + statement.setNull(index, Types.BIGINT); + return; + } + try { + statement.setLong(index, Long.parseLong(value)); + } catch (NumberFormatException e) { + LOGGER.warn("user_id MDC 값이 숫자가 아니라 NULL로 적재합니다: {}", value); + statement.setNull(index, Types.BIGINT); + } + } + + private static String stackTraceOf(Throwable thrown) { + StringWriter writer = new StringWriter(); + thrown.printStackTrace(new PrintWriter(writer)); + return writer.toString(); + } +} diff --git a/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java b/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java new file mode 100644 index 0000000..e3f5323 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java @@ -0,0 +1,49 @@ +package com.bop.youthpick.log.config; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import javax.sql.DataSource; + +/** + * log4j2.xml의 JDBC Appender가 참조하는 application_logs 전용 소형 커넥션 풀. Spring 메인 데이터소스와 분리해 Spring 초기화 전에도 + * 동작한다. + */ +public final class ApplicationLogDataSource { + + private static volatile HikariDataSource dataSource; + + private ApplicationLogDataSource() {} + + public static DataSource getDataSource() { + HikariDataSource local = dataSource; + if (local == null) { + synchronized (ApplicationLogDataSource.class) { + local = dataSource; + if (local == null) { + local = build(); + dataSource = local; + } + } + } + return local; + } + + private static HikariDataSource build() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl( + env( + "DB_URL", + "jdbc:mysql://localhost:3306/youthpick?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul")); + config.setUsername(env("DB_USERNAME", "youthpick")); + config.setPassword(env("DB_PASSWORD", "youthpick")); + config.setPoolName("application-log-jdbc-appender"); + config.setMaximumPoolSize(2); + config.setMinimumIdle(0); + return new HikariDataSource(config); + } + + private static String env(String key, String fallback) { + String value = System.getenv(key); + return (value == null || value.isBlank()) ? fallback : value; + } +} diff --git a/src/main/java/com/bop/youthpick/log/entity/AppLog.java b/src/main/java/com/bop/youthpick/log/entity/ApplicationLog.java similarity index 92% rename from src/main/java/com/bop/youthpick/log/entity/AppLog.java rename to src/main/java/com/bop/youthpick/log/entity/ApplicationLog.java index 7ffcd58..e62a112 100644 --- a/src/main/java/com/bop/youthpick/log/entity/AppLog.java +++ b/src/main/java/com/bop/youthpick/log/entity/ApplicationLog.java @@ -18,12 +18,12 @@ /** 앱 에러/요청 로그. userId는 연관관계 없이 값만 저장 — 로그는 유저 삭제와 무관하게 보존하고, 비로그인/배치는 NULL. */ @Entity @Table( - name = "app_logs", - indexes = @Index(name = "idx_app_logs_created", columnList = "created_at")) + name = "application_logs", + indexes = @Index(name = "idx_application_logs_created", columnList = "created_at")) @EntityListeners(AuditingEntityListener.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class AppLog { +public class ApplicationLog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/bop/youthpick/log/service/RequestLogContextFilter.java b/src/main/java/com/bop/youthpick/log/service/RequestLogContextFilter.java new file mode 100644 index 0000000..c8dc6fb --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/service/RequestLogContextFilter.java @@ -0,0 +1,61 @@ +package com.bop.youthpick.log.service; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.UUID; +import org.apache.logging.log4j.ThreadContext; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * 요청 단위 traceId/method/uri/ip/userId를 MDC(ThreadContext)에 채운다. application_logs JDBC Appender가 이 값을 + * 읽어 로그 행을 채우므로, 인증된 userId를 읽을 수 있도록 JwtAuthenticationFilter 이후에 실행해야 한다. + */ +public class RequestLogContextFilter extends OncePerRequestFilter { + + private static final String FORWARDED_FOR_HEADER = "X-Forwarded-For"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + ThreadContext.put("traceId", UUID.randomUUID().toString()); + ThreadContext.put("method", request.getMethod()); + ThreadContext.put("uri", request.getRequestURI()); + ThreadContext.put("ip", clientIp(request)); + Long userId = currentUserId(); + if (userId != null) { + ThreadContext.put("userId", String.valueOf(userId)); + } + try { + filterChain.doFilter(request, response); + } finally { + ThreadContext.clearAll(); + } + } + + private Long currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return null; + } + return authentication.getPrincipal() instanceof AuthPrincipal principal + ? principal.userId() + : null; + } + + /** 리버스 프록시/로드밸런서 뒤에서는 소켓 IP 대신 X-Forwarded-For의 첫 값(원 클라이언트 IP)을 우선한다. */ + private String clientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR_HEADER); + if (StringUtils.hasText(forwardedFor)) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql b/src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql new file mode 100644 index 0000000..99edd36 --- /dev/null +++ b/src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql @@ -0,0 +1,3 @@ +-- ERD의 테이블명(application_logs)과 맞춘다. +ALTER TABLE app_logs RENAME TO application_logs; +ALTER TABLE application_logs RENAME INDEX idx_app_logs_created TO idx_application_logs_created; diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..188f2c6 --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/log4j2-test.xml b/src/test/resources/log4j2-test.xml new file mode 100644 index 0000000..4e79dca --- /dev/null +++ b/src/test/resources/log4j2-test.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + From ca4fbe7a886e2aa0a4f448f7fca6b9ff1654ae74 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 14:39:29 +0900 Subject: [PATCH 15/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B4=80=EB=A6=AC=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 조회(role/accountStatus/provider 필터 + Pageable), 온보딩 프로필 상세, role 변경, 회원 탈퇴(soft delete) 엔드포인트를 /api/v1/admin/users 아래 추가한다. SecurityConfig에 이미 예약된 /api/v1/admin/** -> hasRole(ADMIN) 규칙을 그대로 사용한다. Closes #43 --- .../user/controller/AdminUserController.java | 65 ++++++++ .../user/dto/AdminUserProfileResponse.java | 34 ++++ .../youthpick/user/dto/AdminUserResponse.java | 23 +++ .../user/dto/AdminUserRoleUpdateRequest.java | 9 + .../com/bop/youthpick/user/entity/User.java | 8 + .../repository/AdminUserSpecifications.java | 40 +++++ .../repository/UserProfileRepository.java | 3 + .../user/repository/UserRepository.java | 3 +- .../user/service/AdminUserService.java | 61 +++++++ .../controller/AdminUserControllerTest.java | 157 ++++++++++++++++++ .../AdminUserSpecificationsTest.java | 81 +++++++++ .../user/service/AdminUserServiceTest.java | 144 ++++++++++++++++ 12 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/bop/youthpick/user/controller/AdminUserController.java create mode 100644 src/main/java/com/bop/youthpick/user/dto/AdminUserProfileResponse.java create mode 100644 src/main/java/com/bop/youthpick/user/dto/AdminUserResponse.java create mode 100644 src/main/java/com/bop/youthpick/user/dto/AdminUserRoleUpdateRequest.java create mode 100644 src/main/java/com/bop/youthpick/user/repository/AdminUserSpecifications.java create mode 100644 src/main/java/com/bop/youthpick/user/service/AdminUserService.java create mode 100644 src/test/java/com/bop/youthpick/user/controller/AdminUserControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/user/repository/AdminUserSpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/user/service/AdminUserServiceTest.java diff --git a/src/main/java/com/bop/youthpick/user/controller/AdminUserController.java b/src/main/java/com/bop/youthpick/user/controller/AdminUserController.java new file mode 100644 index 0000000..b57d822 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/controller/AdminUserController.java @@ -0,0 +1,65 @@ +package com.bop.youthpick.user.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.user.dto.AdminUserProfileResponse; +import com.bop.youthpick.user.dto.AdminUserResponse; +import com.bop.youthpick.user.dto.AdminUserRoleUpdateRequest; +import com.bop.youthpick.user.service.AdminUserService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@Validated +public class AdminUserController { + + private final AdminUserService adminUserService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) + @Pattern(regexp = "USER|ADMIN", message = "role은 USER 또는 ADMIN이어야 합니다.") + String role, + @RequestParam(required = false) + @Pattern( + regexp = "ACTIVE|DELETED", + message = "accountStatus는 ACTIVE 또는 DELETED여야 합니다.") + String accountStatus, + @RequestParam(required = false) String provider, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminUserService.search(role, accountStatus, provider, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + @GetMapping("/{userId}/profile") + public ApiResponse getProfile(@PathVariable Long userId) { + return ApiResponse.ok(adminUserService.getProfile(userId)); + } + + @PatchMapping("/{userId}/role") + public ApiResponse updateRole( + @PathVariable Long userId, @Valid @RequestBody AdminUserRoleUpdateRequest request) { + return ApiResponse.ok(adminUserService.updateRole(userId, request.role())); + } + + @DeleteMapping("/{userId}") + public ApiResponse delete(@PathVariable Long userId) { + return ApiResponse.ok(adminUserService.softDelete(userId)); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/AdminUserProfileResponse.java b/src/main/java/com/bop/youthpick/user/dto/AdminUserProfileResponse.java new file mode 100644 index 0000000..84ae190 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/AdminUserProfileResponse.java @@ -0,0 +1,34 @@ +package com.bop.youthpick.user.dto; + +import com.bop.youthpick.user.entity.UserProfile; +import java.util.Arrays; +import java.util.List; + +public record AdminUserProfileResponse( + Long userId, + Integer birthYear, + String employmentStatus, + String educationLevel, + List categories, + List keywords, + String status, + String regionLabel) { + public static AdminUserProfileResponse from(UserProfile profile) { + return new AdminUserProfileResponse( + profile.getUser().getId(), + profile.getBirthYear(), + profile.getEmploymentStatus(), + profile.getEducationLevel(), + splitToList(profile.getCategories()), + splitToList(profile.getKeywords()), + profile.getStatus(), + profile.getRegion().getSidoName() + " " + profile.getRegion().getName()); + } + + private static List splitToList(String commaSeparated) { + if (commaSeparated == null || commaSeparated.isBlank()) { + return List.of(); + } + return Arrays.asList(commaSeparated.split(",")); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/AdminUserResponse.java b/src/main/java/com/bop/youthpick/user/dto/AdminUserResponse.java new file mode 100644 index 0000000..10180c7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/AdminUserResponse.java @@ -0,0 +1,23 @@ +package com.bop.youthpick.user.dto; + +import com.bop.youthpick.user.entity.Role; +import com.bop.youthpick.user.entity.User; +import java.time.LocalDateTime; + +public record AdminUserResponse( + Long id, + String provider, + String providerId, + Role role, + LocalDateTime createdAt, + LocalDateTime deletedAt) { + public static AdminUserResponse from(User user) { + return new AdminUserResponse( + user.getId(), + user.getProvider(), + user.getProviderId(), + user.getRole(), + user.getCreatedAt(), + user.getDeletedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/AdminUserRoleUpdateRequest.java b/src/main/java/com/bop/youthpick/user/dto/AdminUserRoleUpdateRequest.java new file mode 100644 index 0000000..00e8eaa --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/AdminUserRoleUpdateRequest.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.user.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record AdminUserRoleUpdateRequest( + @NotBlank(message = "role은 필수입니다.") + @Pattern(regexp = "USER|ADMIN", message = "role은 USER 또는 ADMIN이어야 합니다.") + String role) {} diff --git a/src/main/java/com/bop/youthpick/user/entity/User.java b/src/main/java/com/bop/youthpick/user/entity/User.java index c66928c..ac9106c 100644 --- a/src/main/java/com/bop/youthpick/user/entity/User.java +++ b/src/main/java/com/bop/youthpick/user/entity/User.java @@ -59,4 +59,12 @@ public static User createSocialUser( user.nickname = nickname; return user; } + + public void changeRole(Role role) { + this.role = role; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/bop/youthpick/user/repository/AdminUserSpecifications.java b/src/main/java/com/bop/youthpick/user/repository/AdminUserSpecifications.java new file mode 100644 index 0000000..a273394 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/repository/AdminUserSpecifications.java @@ -0,0 +1,40 @@ +package com.bop.youthpick.user.repository; + +import com.bop.youthpick.user.entity.Role; +import com.bop.youthpick.user.entity.User; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 사용자 목록 조회의 선택적 필터(role/accountStatus/provider)를 조합한다. */ +public final class AdminUserSpecifications { + + private AdminUserSpecifications() {} + + public static Specification filter(String role, String accountStatus, String provider) { + return Specification.allOf( + roleEquals(role), accountStatusEquals(accountStatus), providerEquals(provider)); + } + + private static Specification roleEquals(String role) { + if (role == null || role.isBlank()) { + return null; + } + Role roleValue = Role.valueOf(role); + return (root, query, cb) -> cb.equal(root.get("role"), roleValue); + } + + private static Specification accountStatusEquals(String accountStatus) { + if (accountStatus == null || accountStatus.isBlank()) { + return null; + } + boolean deleted = "DELETED".equals(accountStatus); + return (root, query, cb) -> + deleted ? cb.isNotNull(root.get("deletedAt")) : cb.isNull(root.get("deletedAt")); + } + + private static Specification providerEquals(String provider) { + if (provider == null || provider.isBlank()) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("provider"), provider); + } +} diff --git a/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java index 097cb9d..8ac14c9 100644 --- a/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java +++ b/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java @@ -1,9 +1,12 @@ package com.bop.youthpick.user.repository; import com.bop.youthpick.user.entity.UserProfile; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface UserProfileRepository extends JpaRepository { boolean existsByUserId(Long userId); + + Optional findByUserId(Long userId); } diff --git a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java index 3a2fbad..d5b14d1 100644 --- a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java +++ b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java @@ -3,8 +3,9 @@ import com.bop.youthpick.user.entity.User; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByProviderAndProviderId(String provider, String providerId); } diff --git a/src/main/java/com/bop/youthpick/user/service/AdminUserService.java b/src/main/java/com/bop/youthpick/user/service/AdminUserService.java new file mode 100644 index 0000000..fee8cdb --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/service/AdminUserService.java @@ -0,0 +1,61 @@ +package com.bop.youthpick.user.service; + +import com.bop.youthpick.user.dto.AdminUserProfileResponse; +import com.bop.youthpick.user.dto.AdminUserResponse; +import com.bop.youthpick.user.entity.Role; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.AdminUserSpecifications; +import com.bop.youthpick.user.repository.UserProfileRepository; +import com.bop.youthpick.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private final UserRepository userRepository; + private final UserProfileRepository userProfileRepository; + + @Transactional(readOnly = true) + public Page search( + String role, String accountStatus, String provider, Pageable pageable) { + return userRepository + .findAll(AdminUserSpecifications.filter(role, accountStatus, provider), pageable) + .map(AdminUserResponse::from); + } + + @Transactional(readOnly = true) + public AdminUserProfileResponse getProfile(Long userId) { + findUser(userId); + return userProfileRepository + .findByUserId(userId) + .map(AdminUserProfileResponse::from) + .orElse(null); + } + + @Transactional + public AdminUserResponse updateRole(Long userId, String role) { + User user = findUser(userId); + user.changeRole(Role.valueOf(role)); + return AdminUserResponse.from(user); + } + + @Transactional + public AdminUserResponse softDelete(Long userId) { + User user = findUser(userId); + user.softDelete(); + return AdminUserResponse.from(user); + } + + private User findUser(Long userId) { + return userRepository + .findById(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + } +} diff --git a/src/test/java/com/bop/youthpick/user/controller/AdminUserControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/AdminUserControllerTest.java new file mode 100644 index 0000000..13729de --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/controller/AdminUserControllerTest.java @@ -0,0 +1,157 @@ +package com.bop.youthpick.user.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.user.dto.AdminUserProfileResponse; +import com.bop.youthpick.user.dto.AdminUserResponse; +import com.bop.youthpick.user.entity.Role; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.service.AdminUserService; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminUserController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminUserControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminUserService adminUserService; + + private static final AdminUserResponse USER_RESPONSE = + new AdminUserResponse(1L, "kakao", "kakao-1", Role.USER, LocalDateTime.now(), null); + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = new PageImpl<>(List.of(USER_RESPONSE)); + when(adminUserService.search(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].provider").value("kakao")) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void role_필터가_허용값이_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/admin/users").param("role", "SUPERADMIN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 프로필이_있으면_200과_프로필을_반환한다() throws Exception { + AdminUserProfileResponse profile = + new AdminUserProfileResponse( + 1L, + 2000, + "EMPLOYED", + "UNIVERSITY", + List.of("취업"), + List.of("청년"), + "COMPLETED", + "서울특별시 강남구"); + when(adminUserService.getProfile(1L)).thenReturn(profile); + + mockMvc.perform(get("/api/v1/admin/users/{userId}/profile", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(1)) + .andExpect(jsonPath("$.data.regionLabel").value("서울특별시 강남구")); + } + + @Test + void 프로필이_없으면_200과_null_데이터를_반환한다() throws Exception { + when(adminUserService.getProfile(1L)).thenReturn(null); + + mockMvc.perform(get("/api/v1/admin/users/{userId}/profile", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void role_변경이_유효하면_200과_변경된_사용자를_반환한다() throws Exception { + AdminUserResponse updated = + new AdminUserResponse( + 1L, "kakao", "kakao-1", Role.ADMIN, LocalDateTime.now(), null); + when(adminUserService.updateRole(eq(1L), eq("ADMIN"))).thenReturn(updated); + + mockMvc.perform( + patch("/api/v1/admin/users/{userId}/role", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + } + + @Test + void role_변경_대상이_없으면_404와_U001을_반환한다() throws Exception { + when(adminUserService.updateRole(eq(1L), anyString())) + .thenThrow(new UserException(UserError.USER_NOT_FOUND)); + + mockMvc.perform( + patch("/api/v1/admin/users/{userId}/role", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"ADMIN\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("U001")); + } + + @Test + void role_값이_허용범위가_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + patch("/api/v1/admin/users/{userId}/role", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role\":\"SUPERADMIN\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 탈퇴_처리하면_200과_deletedAt이_채워진_사용자를_반환한다() throws Exception { + AdminUserResponse deleted = + new AdminUserResponse( + 1L, + "kakao", + "kakao-1", + Role.USER, + LocalDateTime.now(), + LocalDateTime.now()); + when(adminUserService.softDelete(1L)).thenReturn(deleted); + + mockMvc.perform(delete("/api/v1/admin/users/{userId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedAt").exists()); + } + + @Test + void 탈퇴_대상이_없으면_404와_U001을_반환한다() throws Exception { + when(adminUserService.softDelete(1L)) + .thenThrow(new UserException(UserError.USER_NOT_FOUND)); + + mockMvc.perform(delete("/api/v1/admin/users/{userId}", 1L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("U001")); + } +} diff --git a/src/test/java/com/bop/youthpick/user/repository/AdminUserSpecificationsTest.java b/src/test/java/com/bop/youthpick/user/repository/AdminUserSpecificationsTest.java new file mode 100644 index 0000000..168c6d4 --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/repository/AdminUserSpecificationsTest.java @@ -0,0 +1,81 @@ +package com.bop.youthpick.user.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.user.entity.Role; +import com.bop.youthpick.user.entity.User; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class AdminUserSpecificationsTest { + + @Autowired private UserRepository userRepository; + + @BeforeEach + void setUp() { + User admin = User.createSocialUser("kakao", "kakao-1", "admin@a.com", "관리자"); + admin.changeRole(Role.ADMIN); + userRepository.save(admin); + + User activeUser = User.createSocialUser("naver", "naver-1", "user@a.com", "회원"); + userRepository.save(activeUser); + + User deletedUser = User.createSocialUser("kakao", "kakao-2", "deleted@a.com", "탈퇴회원"); + deletedUser.softDelete(); + userRepository.save(deletedUser); + } + + @Test + void role로_필터링한다() { + List result = + userRepository.findAll(AdminUserSpecifications.filter("ADMIN", null, null)); + + assertThat(result).extracting(User::getProviderId).containsExactly("kakao-1"); + } + + @Test + void accountStatus로_필터링한다() { + List deleted = + userRepository.findAll(AdminUserSpecifications.filter(null, "DELETED", null)); + List active = + userRepository.findAll(AdminUserSpecifications.filter(null, "ACTIVE", null)); + + assertThat(deleted).extracting(User::getProviderId).containsExactly("kakao-2"); + assertThat(active) + .extracting(User::getProviderId) + .containsExactlyInAnyOrder("kakao-1", "naver-1"); + } + + @Test + void provider로_필터링한다() { + List result = + userRepository.findAll(AdminUserSpecifications.filter(null, null, "kakao")); + + assertThat(result) + .extracting(User::getProviderId) + .containsExactlyInAnyOrder("kakao-1", "kakao-2"); + } + + @Test + void 필터를_조합할_수_있다() { + List result = + userRepository.findAll(AdminUserSpecifications.filter(null, "ACTIVE", "kakao")); + + assertThat(result).extracting(User::getProviderId).containsExactly("kakao-1"); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + userRepository.findAll(AdminUserSpecifications.filter(null, null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/user/service/AdminUserServiceTest.java b/src/test/java/com/bop/youthpick/user/service/AdminUserServiceTest.java new file mode 100644 index 0000000..d400ff8 --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/service/AdminUserServiceTest.java @@ -0,0 +1,144 @@ +package com.bop.youthpick.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.user.entity.Role; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserProfileRepository; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminUserServiceTest { + + @Mock private UserRepository userRepository; + + @Mock private UserProfileRepository userProfileRepository; + + private AdminUserService adminUserService; + + private static final Long USER_ID = 1L; + + @BeforeEach + void setUp() { + adminUserService = new AdminUserService(userRepository, userProfileRepository); + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { + User user = mock(User.class); + when(user.getId()).thenReturn(USER_ID); + when(user.getProvider()).thenReturn("kakao"); + when(user.getProviderId()).thenReturn("kakao-1"); + when(user.getRole()).thenReturn(Role.USER); + Page page = new PageImpl<>(java.util.List.of(user)); + when(userRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + + Page result = adminUserService.search("USER", "ACTIVE", "kakao", Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + verify(userRepository).findAll(any(Specification.class), any(Pageable.class)); + } + + @Test + void 프로필이_있으면_프로필을_반환한다() { + User user = mock(User.class); + UserProfile profile = mock(UserProfile.class); + Region region = mock(Region.class); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.of(profile)); + when(profile.getUser()).thenReturn(user); + when(user.getId()).thenReturn(USER_ID); + when(profile.getRegion()).thenReturn(region); + when(region.getSidoName()).thenReturn("서울특별시"); + when(region.getName()).thenReturn("강남구"); + + var result = adminUserService.getProfile(USER_ID); + + assertThat(result.userId()).isEqualTo(USER_ID); + assertThat(result.regionLabel()).isEqualTo("서울특별시 강남구"); + } + + @Test + void 프로필이_없으면_null을_반환한다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + var result = adminUserService.getProfile(USER_ID); + + assertThat(result).isNull(); + } + + @Test + void 프로필_조회_대상_사용자가_없으면_USER_NOT_FOUND_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminUserService.getProfile(USER_ID)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.USER_NOT_FOUND); + } + + @Test + void role을_변경한다() { + User user = mock(User.class); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(user.getId()).thenReturn(USER_ID); + when(user.getRole()).thenReturn(Role.ADMIN); + + var result = adminUserService.updateRole(USER_ID, "ADMIN"); + + verify(user).changeRole(Role.ADMIN); + assertThat(result.role()).isEqualTo(Role.ADMIN); + } + + @Test + void role_변경_대상_사용자가_없으면_USER_NOT_FOUND_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminUserService.updateRole(USER_ID, "ADMIN")) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.USER_NOT_FOUND); + } + + @Test + void 회원을_soft_delete_한다() { + User user = mock(User.class); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(user.getId()).thenReturn(USER_ID); + + adminUserService.softDelete(USER_ID); + + verify(user).softDelete(); + } + + @Test + void 탈퇴_대상_사용자가_없으면_USER_NOT_FOUND_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminUserService.softDelete(USER_ID)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.USER_NOT_FOUND); + } +} From dad55196e01c7f40a494d88a1a95cf852eee1863 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 14:39:58 +0900 Subject: [PATCH 16/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85/=EC=A7=80=EC=97=AD=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 조회(category/visibilityStatus/신청기간 겹침 필터 + Pageable), 전체 수정, 노출상태 변경, soft delete 엔드포인트를 /api/v1/admin/policies 아래 추가하고 /api/v1/admin/regions에 지역 전체 목록 조회를 추가한다. Policy에는 관리자 soft delete용 deletedAt을 신규 추가한다(Flyway V4) — 배치가 쓰는 visibility(원본 소실 정책 재노출용 상태 전환)와는 독립적인 필드다. PolicyRepository/PolicyRegionRepository도 이번 작업에서 함께 만든다. Closes #45 --- .../controller/AdminPolicyController.java | 72 +++++++ .../controller/AdminRegionController.java | 23 ++ .../policy/dto/AdminPolicyResponse.java | 45 ++++ .../policy/dto/AdminPolicyUpdateRequest.java | 20 ++ .../AdminPolicyVisibilityUpdateRequest.java | 11 + .../youthpick/policy/dto/RegionResponse.java | 9 + .../bop/youthpick/policy/entity/Policy.java | 31 +++ .../youthpick/policy/entity/PolicyRegion.java | 7 + .../repository/AdminPolicySpecifications.java | 58 +++++ .../repository/PolicyRegionRepository.java | 17 ++ .../policy/repository/PolicyRepository.java | 8 + .../policy/service/AdminPolicyService.java | 120 +++++++++++ .../policy/service/AdminRegionService.java | 20 ++ .../migration/V4__add_policies_deleted_at.sql | 9 + .../controller/AdminPolicyControllerTest.java | 198 ++++++++++++++++++ .../controller/AdminRegionControllerTest.java | 38 ++++ .../AdminPolicySpecificationsTest.java | 109 ++++++++++ .../PolicyRegionRepositoryTest.java | 69 ++++++ .../service/AdminPolicyServiceTest.java | 171 +++++++++++++++ 19 files changed, 1035 insertions(+) create mode 100644 src/main/java/com/bop/youthpick/policy/controller/AdminPolicyController.java create mode 100644 src/main/java/com/bop/youthpick/policy/controller/AdminRegionController.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/AdminPolicyResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/AdminPolicyUpdateRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/AdminPolicyVisibilityUpdateRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/RegionResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/AdminPolicySpecifications.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/AdminPolicyService.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/AdminRegionService.java create mode 100644 src/main/resources/db/migration/V4__add_policies_deleted_at.sql create mode 100644 src/test/java/com/bop/youthpick/policy/controller/AdminPolicyControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/AdminRegionControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/repository/AdminPolicySpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/repository/PolicyRegionRepositoryTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/AdminPolicyServiceTest.java diff --git a/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyController.java new file mode 100644 index 0000000..b3fdb00 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyController.java @@ -0,0 +1,72 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.AdminPolicyResponse; +import com.bop.youthpick.policy.dto.AdminPolicyUpdateRequest; +import com.bop.youthpick.policy.dto.AdminPolicyVisibilityUpdateRequest; +import com.bop.youthpick.policy.service.AdminPolicyService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/policies") +@RequiredArgsConstructor +@Validated +public class AdminPolicyController { + + private final AdminPolicyService adminPolicyService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) String category, + @RequestParam(required = false) + @Pattern( + regexp = "VISIBLE|HIDDEN", + message = "visibilityStatus는 VISIBLE 또는 HIDDEN이어야 합니다.") + String visibilityStatus, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminPolicyService.search(category, visibilityStatus, startDate, endDate, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + @PutMapping("/{policyId}") + public ApiResponse update( + @PathVariable Long policyId, @Valid @RequestBody AdminPolicyUpdateRequest request) { + return ApiResponse.ok(adminPolicyService.update(policyId, request)); + } + + @PatchMapping("/{policyId}/visibility") + public ApiResponse updateVisibility( + @PathVariable Long policyId, + @Valid @RequestBody AdminPolicyVisibilityUpdateRequest request) { + return ApiResponse.ok( + adminPolicyService.updateVisibility(policyId, request.visibilityStatus())); + } + + @DeleteMapping("/{policyId}") + public ApiResponse delete(@PathVariable Long policyId) { + return ApiResponse.ok(adminPolicyService.softDelete(policyId)); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/controller/AdminRegionController.java b/src/main/java/com/bop/youthpick/policy/controller/AdminRegionController.java new file mode 100644 index 0000000..b2be286 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/AdminRegionController.java @@ -0,0 +1,23 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.service.AdminRegionService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/regions") +@RequiredArgsConstructor +public class AdminRegionController { + + private final AdminRegionService adminRegionService; + + @GetMapping + public ApiResponse> list() { + return ApiResponse.ok(adminRegionService.findAll()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyResponse.java b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyResponse.java new file mode 100644 index 0000000..4ffdd9c --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyResponse.java @@ -0,0 +1,45 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record AdminPolicyResponse( + Long id, + String policyNo, + String policyName, + String organizationName, + String description, + String largeCategory, + String middleCategory, + LocalDate applicationStartDate, + LocalDate applicationEndDate, + String applicationUrl, + int viewCount, + PolicyVisibility visibilityStatus, + List regionCodes, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt) { + public static AdminPolicyResponse from(Policy policy, List regionCodes) { + return new AdminPolicyResponse( + policy.getId(), + policy.getPolicyNo(), + policy.getTitle(), + policy.getOrganizationName(), + policy.getDescription(), + policy.getCategory(), + policy.getMiddleCategory(), + policy.getApplicationStartDate(), + policy.getApplicationEndDate(), + policy.getApplicationUrl(), + policy.getViewCount(), + policy.getVisibility(), + regionCodes, + policy.getCreatedAt(), + policy.getUpdatedAt(), + policy.getDeletedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyUpdateRequest.java b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyUpdateRequest.java new file mode 100644 index 0000000..524f4f1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyUpdateRequest.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.util.List; + +public record AdminPolicyUpdateRequest( + @NotBlank(message = "정책명은 필수입니다.") @Size(max = 300, message = "정책명은 300자 이하여야 합니다.") + String policyName, + @Size(max = 255, message = "주관기관명은 255자 이하여야 합니다.") String organizationName, + @Size(max = 2000, message = "설명은 2000자 이하여야 합니다.") String description, + @NotBlank(message = "대분류는 필수입니다.") @Size(max = 64, message = "대분류는 64자 이하여야 합니다.") + String largeCategory, + @Size(max = 64, message = "중분류는 64자 이하여야 합니다.") String middleCategory, + @NotNull(message = "신청 시작일은 필수입니다.") LocalDate applicationStartDate, + @NotNull(message = "신청 마감일은 필수입니다.") LocalDate applicationEndDate, + @Size(max = 500, message = "신청 URL은 500자 이하여야 합니다.") String applicationUrl, + @NotNull(message = "지역 코드 목록은 필수입니다.") List regionCodes) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyVisibilityUpdateRequest.java b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyVisibilityUpdateRequest.java new file mode 100644 index 0000000..e2d69d6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyVisibilityUpdateRequest.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record AdminPolicyVisibilityUpdateRequest( + @NotBlank(message = "visibilityStatus는 필수입니다.") + @Pattern( + regexp = "VISIBLE|HIDDEN", + message = "visibilityStatus는 VISIBLE 또는 HIDDEN이어야 합니다.") + String visibilityStatus) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/RegionResponse.java b/src/main/java/com/bop/youthpick/policy/dto/RegionResponse.java new file mode 100644 index 0000000..9c9a7cc --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/RegionResponse.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Region; + +public record RegionResponse(String regionCode, String provinceName, String districtName) { + public static RegionResponse from(Region region) { + return new RegionResponse(region.getCode(), region.getSidoName(), region.getName()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java index e81b0ab..c64d0ad 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Policy.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -238,4 +238,35 @@ public class Policy extends BaseEntity { /** API 응답 원문 JSON — 스키마 진화 시 백필용 보험 */ @Column(name = "raw_payload", columnDefinition = "LONGTEXT") private String rawPayload; + + /** 관리자 soft delete 시각. {@link #visibility}(배치의 재노출 가능한 상태 전환)와는 독립적이다. */ + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public void changeVisibility(PolicyVisibility visibility) { + this.visibility = visibility; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public void updateDetails( + String title, + String organizationName, + String description, + String category, + String middleCategory, + LocalDate applicationStartDate, + LocalDate applicationEndDate, + String applicationUrl) { + this.title = title; + this.organizationName = organizationName; + this.description = description; + this.category = category; + this.middleCategory = middleCategory; + this.applicationStartDate = applicationStartDate; + this.applicationEndDate = applicationEndDate; + this.applicationUrl = applicationUrl; + } } diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java index fbad73a..bc293a3 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java @@ -38,4 +38,11 @@ public class PolicyRegion { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "region_code", nullable = false) private Region region; + + public static PolicyRegion create(Policy policy, Region region) { + PolicyRegion policyRegion = new PolicyRegion(); + policyRegion.policy = policy; + policyRegion.region = region; + return policyRegion; + } } diff --git a/src/main/java/com/bop/youthpick/policy/repository/AdminPolicySpecifications.java b/src/main/java/com/bop/youthpick/policy/repository/AdminPolicySpecifications.java new file mode 100644 index 0000000..ec009f5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/AdminPolicySpecifications.java @@ -0,0 +1,58 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import jakarta.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 정책 목록 조회의 선택적 필터(category/visibilityStatus/신청기간 겹침)를 조합한다. */ +public final class AdminPolicySpecifications { + + private AdminPolicySpecifications() {} + + public static Specification filter( + String category, String visibilityStatus, LocalDate startDate, LocalDate endDate) { + return Specification.allOf( + categoryEquals(category), + visibilityEquals(visibilityStatus), + applicationPeriodOverlaps(startDate, endDate)); + } + + private static Specification categoryEquals(String category) { + if (category == null || category.isBlank()) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("category"), category); + } + + private static Specification visibilityEquals(String visibilityStatus) { + if (visibilityStatus == null || visibilityStatus.isBlank()) { + return null; + } + PolicyVisibility visibility = PolicyVisibility.valueOf(visibilityStatus); + return (root, query, cb) -> cb.equal(root.get("visibility"), visibility); + } + + /** 정책의 [applicationStartDate, applicationEndDate] 구간이 [startDate, endDate]와 겹치는 정책만 남긴다. */ + private static Specification applicationPeriodOverlaps( + LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.isNotNull(root.get("applicationStartDate"))); + predicates.add(cb.isNotNull(root.get("applicationEndDate"))); + if (endDate != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("applicationStartDate"), endDate)); + } + if (startDate != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("applicationEndDate"), startDate)); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java new file mode 100644 index 0000000..49fc868 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.PolicyRegion; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PolicyRegionRepository extends JpaRepository { + + List findByPolicyIdIn(List policyIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from PolicyRegion pr where pr.policy.id = :policyId") + void deleteByPolicyId(@Param("policyId") Long policyId); +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java new file mode 100644 index 0000000..177ade6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -0,0 +1,8 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.Policy; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface PolicyRepository + extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/policy/service/AdminPolicyService.java b/src/main/java/com/bop/youthpick/policy/service/AdminPolicyService.java new file mode 100644 index 0000000..e161d55 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/AdminPolicyService.java @@ -0,0 +1,120 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.AdminPolicyResponse; +import com.bop.youthpick.policy.dto.AdminPolicyUpdateRequest; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.AdminPolicySpecifications; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminPolicyService { + + private final PolicyRepository policyRepository; + private final PolicyRegionRepository policyRegionRepository; + private final RegionRepository regionRepository; + + @Transactional(readOnly = true) + public Page search( + String category, + String visibilityStatus, + LocalDate startDate, + LocalDate endDate, + Pageable pageable) { + Page page = + policyRepository.findAll( + AdminPolicySpecifications.filter( + category, visibilityStatus, startDate, endDate), + pageable); + Map> regionCodesByPolicyId = + regionCodesByPolicyId(page.getContent().stream().map(Policy::getId).toList()); + return page.map( + policy -> + AdminPolicyResponse.from( + policy, + regionCodesByPolicyId.getOrDefault(policy.getId(), List.of()))); + } + + @Transactional + public AdminPolicyResponse update(Long policyId, AdminPolicyUpdateRequest request) { + Policy policy = findPolicy(policyId); + List regions = resolveRegions(request.regionCodes()); + + policy.updateDetails( + request.policyName(), + request.organizationName(), + request.description(), + request.largeCategory(), + request.middleCategory(), + request.applicationStartDate(), + request.applicationEndDate(), + request.applicationUrl()); + + policyRegionRepository.deleteByPolicyId(policyId); + regions.forEach(region -> policyRegionRepository.save(PolicyRegion.create(policy, region))); + + return AdminPolicyResponse.from(policy, request.regionCodes()); + } + + @Transactional + public AdminPolicyResponse updateVisibility(Long policyId, String visibilityStatus) { + Policy policy = findPolicy(policyId); + policy.changeVisibility(PolicyVisibility.valueOf(visibilityStatus)); + return AdminPolicyResponse.from(policy, regionCodesOf(policyId)); + } + + @Transactional + public AdminPolicyResponse softDelete(Long policyId) { + Policy policy = findPolicy(policyId); + policy.softDelete(); + return AdminPolicyResponse.from(policy, regionCodesOf(policyId)); + } + + private Policy findPolicy(Long policyId) { + return policyRepository + .findById(policyId) + .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + } + + private List resolveRegions(List regionCodes) { + return regionCodes.stream() + .map( + code -> + regionRepository + .findById(code) + .orElseThrow( + () -> + new CustomException( + PolicyErrorCode.REGION_NOT_FOUND))) + .toList(); + } + + private List regionCodesOf(Long policyId) { + return regionCodesByPolicyId(List.of(policyId)).getOrDefault(policyId, List.of()); + } + + private Map> regionCodesByPolicyId(List policyIds) { + return policyRegionRepository.findByPolicyIdIn(policyIds).stream() + .collect( + Collectors.groupingBy( + pr -> pr.getPolicy().getId(), + Collectors.mapping( + pr -> pr.getRegion().getCode(), Collectors.toList()))); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/AdminRegionService.java b/src/main/java/com/bop/youthpick/policy/service/AdminRegionService.java new file mode 100644 index 0000000..e7dd266 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/AdminRegionService.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminRegionService { + + private final RegionRepository regionRepository; + + @Transactional(readOnly = true) + public List findAll() { + return regionRepository.findAll().stream().map(RegionResponse::from).toList(); + } +} diff --git a/src/main/resources/db/migration/V4__add_policies_deleted_at.sql b/src/main/resources/db/migration/V4__add_policies_deleted_at.sql new file mode 100644 index 0000000..d3862f8 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_policies_deleted_at.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- V4 — policies.deleted_at 추가 (관리자 정책 삭제) +-- visibility(VISIBLE/HIDDEN)는 배치가 원본 소실 정책을 껐다 켜는 +-- 용도로 계속 쓰고, deleted_at은 관리자가 확정적으로 삭제 처리한 +-- 것을 별도로 표시하는 soft delete 컬럼이다. 두 상태는 독립적이다. +-- ============================================================ + +ALTER TABLE policies + ADD COLUMN deleted_at DATETIME NULL COMMENT '관리자 soft delete 시각'; diff --git a/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyControllerTest.java new file mode 100644 index 0000000..f462412 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyControllerTest.java @@ -0,0 +1,198 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.AdminPolicyResponse; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.service.AdminPolicyService; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminPolicyController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminPolicyControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminPolicyService adminPolicyService; + + private static final AdminPolicyResponse POLICY_RESPONSE = + new AdminPolicyResponse( + 1L, + "P001", + "정책명", + "주관기관", + "설명", + "일자리", + "중분류", + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31), + "https://example.com", + 0, + PolicyVisibility.VISIBLE, + List.of("11110"), + LocalDateTime.now(), + LocalDateTime.now(), + null); + + private static final String VALID_UPDATE_BODY = + """ + { + "policyName": "정책명", + "organizationName": "주관기관", + "description": "설명", + "largeCategory": "일자리", + "middleCategory": "중분류", + "applicationStartDate": "2026-01-01", + "applicationEndDate": "2026-12-31", + "applicationUrl": "https://example.com", + "regionCodes": ["11110"] + } + """; + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = new PageImpl<>(List.of(POLICY_RESPONSE)); + when(adminPolicyService.search(isNull(), isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/policies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].regionCodes[0]").value("11110")) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void visibilityStatus_필터가_허용값이_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/admin/policies").param("visibilityStatus", "GONE")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 정책_수정이_유효하면_200과_수정된_정책을_반환한다() throws Exception { + when(adminPolicyService.update(eq(1L), any())).thenReturn(POLICY_RESPONSE); + + mockMvc.perform( + put("/api/v1/admin/policies/{policyId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_UPDATE_BODY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.policyName").value("정책명")); + } + + @Test + void 정책명이_없으면_400과_C001을_반환한다() throws Exception { + String invalidBody = + """ + { + "organizationName": "주관기관", + "largeCategory": "일자리", + "applicationStartDate": "2026-01-01", + "applicationEndDate": "2026-12-31", + "regionCodes": [] + } + """; + + mockMvc.perform( + put("/api/v1/admin/policies/{policyId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidBody)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 수정_대상_정책이_없으면_404와_P001을_반환한다() throws Exception { + when(adminPolicyService.update(eq(1L), any())) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + + mockMvc.perform( + put("/api/v1/admin/policies/{policyId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_UPDATE_BODY)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("P001")); + } + + @Test + void 노출상태_변경이_유효하면_200을_반환한다() throws Exception { + when(adminPolicyService.updateVisibility(eq(1L), eq("HIDDEN"))).thenReturn(POLICY_RESPONSE); + + mockMvc.perform( + patch("/api/v1/admin/policies/{policyId}/visibility", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"visibilityStatus\":\"HIDDEN\"}")) + .andExpect(status().isOk()); + } + + @Test + void 노출상태_값이_허용범위가_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + patch("/api/v1/admin/policies/{policyId}/visibility", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"visibilityStatus\":\"GONE\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 삭제하면_200과_deletedAt이_채워진_정책을_반환한다() throws Exception { + AdminPolicyResponse deleted = + new AdminPolicyResponse( + 1L, + "P001", + "정책명", + "주관기관", + "설명", + "일자리", + "중분류", + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31), + "https://example.com", + 0, + PolicyVisibility.VISIBLE, + List.of("11110"), + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now()); + when(adminPolicyService.softDelete(1L)).thenReturn(deleted); + + mockMvc.perform(delete("/api/v1/admin/policies/{policyId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedAt").exists()); + } + + @Test + void 삭제_대상이_없으면_404와_P001을_반환한다() throws Exception { + when(adminPolicyService.softDelete(1L)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + + mockMvc.perform(delete("/api/v1/admin/policies/{policyId}", 1L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("P001")); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/AdminRegionControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/AdminRegionControllerTest.java new file mode 100644 index 0000000..1c22e5d --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/AdminRegionControllerTest.java @@ -0,0 +1,38 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.service.AdminRegionService; +import java.util.List; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminRegionController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminRegionControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminRegionService adminRegionService; + + @Test + void 지역_목록은_200과_전체_목록을_반환한다() throws Exception { + when(adminRegionService.findAll()) + .thenReturn(List.of(new RegionResponse("11110", "서울특별시", "강남구"))); + + mockMvc.perform(get("/api/v1/admin/regions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].regionCode").value("11110")) + .andExpect(jsonPath("$.data[0].provinceName").value("서울특별시")) + .andExpect(jsonPath("$.data[0].districtName").value("강남구")) + .andExpect(jsonPath("$.meta").doesNotExist()); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/AdminPolicySpecificationsTest.java b/src/test/java/com/bop/youthpick/policy/repository/AdminPolicySpecificationsTest.java new file mode 100644 index 0000000..e0c2a66 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/AdminPolicySpecificationsTest.java @@ -0,0 +1,109 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class AdminPolicySpecificationsTest { + + @Autowired private PolicyRepository policyRepository; + + @BeforeEach + void setUp() { + policyRepository.save( + newPolicy( + "P001", + "일자리", + PolicyVisibility.VISIBLE, + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 3, 31))); + policyRepository.save( + newPolicy( + "P002", + "주거", + PolicyVisibility.HIDDEN, + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 8, 31))); + policyRepository.save(newPolicy("P003", "일자리", PolicyVisibility.VISIBLE, null, null)); + } + + // 배치 수집(온통청년 API) 경로가 아직 없어 Policy 인스턴스를 만들 공개 팩토리가 없다. + // 테스트 전용으로 ReflectionTestUtils를 사용해 필드를 채운다. + private Policy newPolicy( + String policyNo, + String category, + PolicyVisibility visibility, + LocalDate applicationStartDate, + LocalDate applicationEndDate) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "category", category); + ReflectionTestUtils.setField(policy, "visibility", visibility); + ReflectionTestUtils.setField(policy, "applicationStartDate", applicationStartDate); + ReflectionTestUtils.setField(policy, "applicationEndDate", applicationEndDate); + return policy; + } + + @Test + void category로_필터링한다() { + List result = + policyRepository.findAll(AdminPolicySpecifications.filter("일자리", null, null, null)); + + assertThat(result) + .extracting(Policy::getPolicyNo) + .containsExactlyInAnyOrder("P001", "P003"); + } + + @Test + void visibilityStatus로_필터링한다() { + List result = + policyRepository.findAll( + AdminPolicySpecifications.filter(null, "HIDDEN", null, null)); + + assertThat(result).extracting(Policy::getPolicyNo).containsExactly("P002"); + } + + @Test + void 신청기간이_겹치는_정책만_반환한다() { + List result = + policyRepository.findAll( + AdminPolicySpecifications.filter( + null, null, LocalDate.of(2026, 2, 1), LocalDate.of(2026, 2, 28))); + + assertThat(result).extracting(Policy::getPolicyNo).containsExactly("P001"); + } + + @Test + void 신청기간이_없는_정책은_기간_필터에서_제외된다() { + List result = + policyRepository.findAll( + AdminPolicySpecifications.filter( + null, null, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31))); + + assertThat(result) + .extracting(Policy::getPolicyNo) + .containsExactlyInAnyOrder("P001", "P002"); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + policyRepository.findAll(AdminPolicySpecifications.filter(null, null, null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRegionRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRegionRepositoryTest.java new file mode 100644 index 0000000..6bb1d14 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRegionRepositoryTest.java @@ -0,0 +1,69 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PolicyRegionRepositoryTest { + + @Autowired private PolicyRepository policyRepository; + + @Autowired private RegionRepository regionRepository; + + @Autowired private PolicyRegionRepository policyRegionRepository; + + @Test + void 정책ID로_지역_매핑을_모두_삭제한다() { + Region region = regionRepository.save(newRegion("11110", "서울특별시", "강남구")); + Policy policy = policyRepository.save(newPolicy("P001")); + policyRegionRepository.save(PolicyRegion.create(policy, region)); + + policyRegionRepository.deleteByPolicyId(policy.getId()); + + assertThat(policyRegionRepository.findByPolicyIdIn(List.of(policy.getId()))).isEmpty(); + } + + @Test + void 정책ID_목록으로_지역코드를_한번에_조회한다() { + Region seoul = regionRepository.save(newRegion("11110", "서울특별시", "강남구")); + Region busan = regionRepository.save(newRegion("26110", "부산광역시", "중구")); + Policy policy1 = policyRepository.save(newPolicy("P001")); + Policy policy2 = policyRepository.save(newPolicy("P002")); + policyRegionRepository.save(PolicyRegion.create(policy1, seoul)); + policyRegionRepository.save(PolicyRegion.create(policy2, busan)); + + List result = + policyRegionRepository.findByPolicyIdIn(List.of(policy1.getId(), policy2.getId())); + + assertThat(result).hasSize(2); + } + + private Region newRegion(String code, String sidoName, String name) { + Region region = BeanUtils.instantiateClass(Region.class); + ReflectionTestUtils.setField(region, "code", code); + ReflectionTestUtils.setField(region, "sidoName", sidoName); + ReflectionTestUtils.setField(region, "name", name); + return region; + } + + private Policy newPolicy(String policyNo) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + return policy; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/AdminPolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/AdminPolicyServiceTest.java new file mode 100644 index 0000000..2dd62e0 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/AdminPolicyServiceTest.java @@ -0,0 +1,171 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.AdminPolicyUpdateRequest; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminPolicyServiceTest { + + @Mock private PolicyRepository policyRepository; + + @Mock private PolicyRegionRepository policyRegionRepository; + + @Mock private RegionRepository regionRepository; + + private AdminPolicyService adminPolicyService; + + private static final Long POLICY_ID = 1L; + + private static final AdminPolicyUpdateRequest UPDATE_REQUEST = + new AdminPolicyUpdateRequest( + "정책명", + "주관기관", + "설명", + "일자리", + "중분류", + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31), + "https://example.com", + List.of("11110")); + + @BeforeEach + void setUp() { + adminPolicyService = + new AdminPolicyService(policyRepository, policyRegionRepository, regionRepository); + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_지역코드가_채워진_페이지를_반환한다() { + Policy policy = mock(Policy.class); + when(policy.getId()).thenReturn(POLICY_ID); + Page page = new PageImpl<>(List.of(policy)); + when(policyRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + PolicyRegion policyRegion = mock(PolicyRegion.class); + Region region = mock(Region.class); + when(policyRegion.getPolicy()).thenReturn(policy); + when(policyRegion.getRegion()).thenReturn(region); + when(region.getCode()).thenReturn("11110"); + when(policyRegionRepository.findByPolicyIdIn(List.of(POLICY_ID))) + .thenReturn(List.of(policyRegion)); + + Page result = + adminPolicyService.search("일자리", "VISIBLE", null, null, Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + } + + @Test + void 정책_수정_시_지역을_전량_교체한다() { + Policy policy = mock(Policy.class); + Region region = mock(Region.class); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(regionRepository.findById("11110")).thenReturn(Optional.of(region)); + + adminPolicyService.update(POLICY_ID, UPDATE_REQUEST); + + verify(policy) + .updateDetails( + "정책명", + "주관기관", + "설명", + "일자리", + "중분류", + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31), + "https://example.com"); + verify(policyRegionRepository).deleteByPolicyId(POLICY_ID); + verify(policyRegionRepository).save(any(PolicyRegion.class)); + } + + @Test + void 수정_대상_정책이_없으면_POLICY_NOT_FOUND_예외를_던진다() { + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminPolicyService.update(POLICY_ID, UPDATE_REQUEST)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_NOT_FOUND); + } + + @Test + void 존재하지_않는_지역코드로_수정하면_REGION_NOT_FOUND_예외를_던진다() { + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); + when(regionRepository.findById("11110")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminPolicyService.update(POLICY_ID, UPDATE_REQUEST)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.REGION_NOT_FOUND); + } + + @Test + void 노출상태를_변경한다() { + Policy policy = mock(Policy.class); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyRegionRepository.findByPolicyIdIn(eq(List.of(POLICY_ID)))).thenReturn(List.of()); + + adminPolicyService.updateVisibility(POLICY_ID, "HIDDEN"); + + verify(policy).changeVisibility(PolicyVisibility.HIDDEN); + } + + @Test + void 노출상태_변경_대상이_없으면_POLICY_NOT_FOUND_예외를_던진다() { + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminPolicyService.updateVisibility(POLICY_ID, "HIDDEN")) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_NOT_FOUND); + } + + @Test + void 정책을_soft_delete_한다() { + Policy policy = mock(Policy.class); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyRegionRepository.findByPolicyIdIn(eq(List.of(POLICY_ID)))).thenReturn(List.of()); + + adminPolicyService.softDelete(POLICY_ID); + + verify(policy).softDelete(); + } + + @Test + void 삭제_대상이_없으면_POLICY_NOT_FOUND_예외를_던진다() { + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminPolicyService.softDelete(POLICY_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_NOT_FOUND); + } +} From 9c698810cf0e45acac7a7c6eaa6f82c1f705df9b Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 16:16:20 +0900 Subject: [PATCH 17/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B4=EB=A0=A5=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoginHistory 엔티티(BaseEntity 상속)와 login_histories 테이블(Flyway V5)을 신규 추가하고, AuthService.login() 성공 시 로그인 이력을 기록하도록 훅을 건다. userId/기간(createdAt) 필터를 지원하는 읽기 전용 목록 조회를 /api/v1/admin/login-histories에 추가한다. 기존 AuthServiceTest를 새 LoginHistoryRepository 의존성에 맞춰 갱신하고, 로그인 성공 시 이력이 기록되는지(및 state 검증 실패 시 기록되지 않는지) 검증을 추가한다. Closes #58 --- .../youthpick/auth/service/AuthService.java | 4 + .../AdminLoginHistoryController.java | 37 +++++++++ .../user/dto/LoginHistoryResponse.java | 15 ++++ .../youthpick/user/entity/LoginHistory.java | 42 ++++++++++ .../AdminLoginHistorySpecifications.java | 46 ++++++++++ .../repository/LoginHistoryRepository.java | 8 ++ .../service/AdminLoginHistoryService.java | 28 +++++++ .../migration/V5__create_login_histories.sql | 17 ++++ .../auth/service/AuthServiceTest.java | 10 ++- .../AdminLoginHistoryControllerTest.java | 48 +++++++++++ .../AdminLoginHistorySpecificationsTest.java | 83 +++++++++++++++++++ .../service/AdminLoginHistoryServiceTest.java | 49 +++++++++++ 12 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/bop/youthpick/user/controller/AdminLoginHistoryController.java create mode 100644 src/main/java/com/bop/youthpick/user/dto/LoginHistoryResponse.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/LoginHistory.java create mode 100644 src/main/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecifications.java create mode 100644 src/main/java/com/bop/youthpick/user/repository/LoginHistoryRepository.java create mode 100644 src/main/java/com/bop/youthpick/user/service/AdminLoginHistoryService.java create mode 100644 src/main/resources/db/migration/V5__create_login_histories.sql create mode 100644 src/test/java/com/bop/youthpick/user/controller/AdminLoginHistoryControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/user/service/AdminLoginHistoryServiceTest.java diff --git a/src/main/java/com/bop/youthpick/auth/service/AuthService.java b/src/main/java/com/bop/youthpick/auth/service/AuthService.java index 9b639bf..700b080 100644 --- a/src/main/java/com/bop/youthpick/auth/service/AuthService.java +++ b/src/main/java/com/bop/youthpick/auth/service/AuthService.java @@ -4,7 +4,9 @@ import com.bop.youthpick.auth.dto.TokenResponse; import com.bop.youthpick.auth.exception.AuthErrorCode; import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.user.entity.LoginHistory; import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.LoginHistoryRepository; import com.bop.youthpick.user.repository.UserRepository; import io.jsonwebtoken.Claims; import java.util.UUID; @@ -29,6 +31,7 @@ public class AuthService { private final UserRepository userRepository; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenStore refreshTokenStore; + private final LoginHistoryRepository loginHistoryRepository; public String buildAuthorizationUrl(String registrationId) { OAuthProvider provider = OAuthProvider.from(registrationId); @@ -70,6 +73,7 @@ public TokenResponse login(String registrationId, String code, String state) { OAuthUserInfo userInfo = oAuthClient.fetchUserInfo(provider, accessToken); User user = findOrCreateUser(provider, userInfo); + loginHistoryRepository.save(LoginHistory.create(user)); return issueTokens(user); } diff --git a/src/main/java/com/bop/youthpick/user/controller/AdminLoginHistoryController.java b/src/main/java/com/bop/youthpick/user/controller/AdminLoginHistoryController.java new file mode 100644 index 0000000..2f920ad --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/controller/AdminLoginHistoryController.java @@ -0,0 +1,37 @@ +package com.bop.youthpick.user.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.user.dto.LoginHistoryResponse; +import com.bop.youthpick.user.service.AdminLoginHistoryService; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/login-histories") +@RequiredArgsConstructor +public class AdminLoginHistoryController { + + private final AdminLoginHistoryService adminLoginHistoryService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) Long userId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminLoginHistoryService.search(userId, startDate, endDate, pageable); + return ApiResponse.ok(page.getContent(), page); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/LoginHistoryResponse.java b/src/main/java/com/bop/youthpick/user/dto/LoginHistoryResponse.java new file mode 100644 index 0000000..0250c69 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/LoginHistoryResponse.java @@ -0,0 +1,15 @@ +package com.bop.youthpick.user.dto; + +import com.bop.youthpick.user.entity.LoginHistory; +import java.time.LocalDateTime; + +public record LoginHistoryResponse( + Long id, Long userId, LocalDateTime createdAt, LocalDateTime updatedAt) { + public static LoginHistoryResponse from(LoginHistory history) { + return new LoginHistoryResponse( + history.getId(), + history.getUser().getId(), + history.getCreatedAt(), + history.getUpdatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/user/entity/LoginHistory.java b/src/main/java/com/bop/youthpick/user/entity/LoginHistory.java new file mode 100644 index 0000000..ba3809d --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/LoginHistory.java @@ -0,0 +1,42 @@ +package com.bop.youthpick.user.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** OAuth 로그인 성공 이력. 관리자 조회 전용 — 실무적으로 불변 로그이며 갱신되지 않는다. */ +@Entity +@Table( + name = "login_histories", + indexes = { + @Index(name = "idx_login_histories_user", columnList = "user_id"), + @Index(name = "idx_login_histories_created", columnList = "created_at") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + public static LoginHistory create(User user) { + LoginHistory history = new LoginHistory(); + history.user = user; + return history; + } +} diff --git a/src/main/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecifications.java b/src/main/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecifications.java new file mode 100644 index 0000000..127cd9f --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecifications.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.user.repository; + +import com.bop.youthpick.user.entity.LoginHistory; +import jakarta.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 로그인 이력 목록 조회의 선택적 필터(userId/기간)를 조합한다. */ +public final class AdminLoginHistorySpecifications { + + private AdminLoginHistorySpecifications() {} + + public static Specification filter( + Long userId, LocalDate startDate, LocalDate endDate) { + return Specification.allOf(userIdEquals(userId), createdBetween(startDate, endDate)); + } + + private static Specification userIdEquals(Long userId) { + if (userId == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("user").get("id"), userId); + } + + /** createdAt이 [startDate, endDate] 날짜 범위 안에 있는 이력만 남긴다(KST 벽시계 시각 기준 직접 비교). */ + private static Specification createdBetween( + LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + if (startDate != null) { + predicates.add( + cb.greaterThanOrEqualTo(root.get("createdAt"), startDate.atStartOfDay())); + } + if (endDate != null) { + predicates.add( + cb.lessThan(root.get("createdAt"), endDate.plusDays(1).atStartOfDay())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/bop/youthpick/user/repository/LoginHistoryRepository.java b/src/main/java/com/bop/youthpick/user/repository/LoginHistoryRepository.java new file mode 100644 index 0000000..f313a5e --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/repository/LoginHistoryRepository.java @@ -0,0 +1,8 @@ +package com.bop.youthpick.user.repository; + +import com.bop.youthpick.user.entity.LoginHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface LoginHistoryRepository + extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/user/service/AdminLoginHistoryService.java b/src/main/java/com/bop/youthpick/user/service/AdminLoginHistoryService.java new file mode 100644 index 0000000..c297ad9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/service/AdminLoginHistoryService.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.user.service; + +import com.bop.youthpick.user.dto.LoginHistoryResponse; +import com.bop.youthpick.user.repository.AdminLoginHistorySpecifications; +import com.bop.youthpick.user.repository.LoginHistoryRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminLoginHistoryService { + + private final LoginHistoryRepository loginHistoryRepository; + + @Transactional(readOnly = true) + public Page search( + Long userId, LocalDate startDate, LocalDate endDate, Pageable pageable) { + return loginHistoryRepository + .findAll( + AdminLoginHistorySpecifications.filter(userId, startDate, endDate), + pageable) + .map(LoginHistoryResponse::from); + } +} diff --git a/src/main/resources/db/migration/V5__create_login_histories.sql b/src/main/resources/db/migration/V5__create_login_histories.sql new file mode 100644 index 0000000..ff0696c --- /dev/null +++ b/src/main/resources/db/migration/V5__create_login_histories.sql @@ -0,0 +1,17 @@ +-- ============================================================ +-- V5 — login_histories 신규 (관리자 로그인 이력 조회) +-- OAuth 로그인 성공 시 1건씩 기록한다. 실무적으로는 불변 로그지만 +-- 관리자 API 응답에 updated_at도 필요해 다른 엔티티와 동일하게 +-- created_at/updated_at 둘 다 둔다. +-- ============================================================ + +CREATE TABLE login_histories ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT fk_login_histories_user FOREIGN KEY (user_id) REFERENCES users (id), + KEY idx_login_histories_user (user_id), + KEY idx_login_histories_created (created_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '로그인 이력 (관리자 조회용)'; diff --git a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java index c867617..5372ca4 100644 --- a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java @@ -13,7 +13,9 @@ import com.bop.youthpick.auth.dto.OAuthUserInfo; import com.bop.youthpick.auth.dto.TokenResponse; import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.user.entity.LoginHistory; import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.LoginHistoryRepository; import com.bop.youthpick.user.repository.UserRepository; import java.net.URI; import java.net.URLDecoder; @@ -36,6 +38,7 @@ class AuthServiceTest { @Mock private UserRepository userRepository; @Mock private JwtTokenProvider jwtTokenProvider; @Mock private RefreshTokenStore refreshTokenStore; + @Mock private LoginHistoryRepository loginHistoryRepository; private AuthService authService; @@ -54,7 +57,8 @@ void setUp() { oAuthStateStore, userRepository, jwtTokenProvider, - refreshTokenStore); + refreshTokenStore, + loginHistoryRepository); } @Test @@ -83,6 +87,7 @@ void setUp() { .isInstanceOf(AuthException.class); verify(oAuthClient, never()).exchangeCodeForAccessToken(any(), any(), any(), any(), any()); + verify(loginHistoryRepository, never()).save(any()); } @Test @@ -110,6 +115,7 @@ void setUp() { verify(userRepository).save(any(User.class)); verify(refreshTokenStore).save(savedUser.getId(), "jwt-refresh", Duration.ofDays(14)); + verify(loginHistoryRepository).save(any(LoginHistory.class)); assertThat(tokens.accessToken()).isEqualTo("jwt-access"); assertThat(tokens.refreshToken()).isEqualTo("jwt-refresh"); assertThat(tokens.expiresIn()).isEqualTo(1800L); @@ -139,6 +145,7 @@ void setUp() { authService.login("google", "code", "state-value"); verify(userRepository, never()).save(any()); + verify(loginHistoryRepository).save(any(LoginHistory.class)); } @Test @@ -213,6 +220,7 @@ void setUp() { assertThat(tokens.accessToken()).isEqualTo("jwt-access"); verify(userRepository, org.mockito.Mockito.times(2)) .findByProviderAndProviderId("GOOGLE", "provider-id-1"); + verify(loginHistoryRepository).save(any(LoginHistory.class)); } @Test diff --git a/src/test/java/com/bop/youthpick/user/controller/AdminLoginHistoryControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/AdminLoginHistoryControllerTest.java new file mode 100644 index 0000000..a48c5f4 --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/controller/AdminLoginHistoryControllerTest.java @@ -0,0 +1,48 @@ +package com.bop.youthpick.user.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.user.dto.LoginHistoryResponse; +import com.bop.youthpick.user.service.AdminLoginHistoryService; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminLoginHistoryController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminLoginHistoryControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminLoginHistoryService adminLoginHistoryService; + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = + new PageImpl<>( + List.of( + new LoginHistoryResponse( + 1L, 10L, LocalDateTime.now(), LocalDateTime.now()))); + when(adminLoginHistoryService.search(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/login-histories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].userId").value(10)) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } +} diff --git a/src/test/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecificationsTest.java b/src/test/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecificationsTest.java new file mode 100644 index 0000000..d5bace8 --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecificationsTest.java @@ -0,0 +1,83 @@ +package com.bop.youthpick.user.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.user.entity.LoginHistory; +import com.bop.youthpick.user.entity.User; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class AdminLoginHistorySpecificationsTest { + + @Autowired private LoginHistoryRepository loginHistoryRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private TestEntityManager entityManager; + + private Long userId; + + @BeforeEach + void setUp() { + User user = userRepository.save(User.createSocialUser("kakao", "kakao-1", null, "닉네임")); + User otherUser = + userRepository.save(User.createSocialUser("naver", "naver-1", null, "다른닉네임")); + userId = user.getId(); + + saveLoginHistory(user, LocalDateTime.of(2026, 3, 15, 9, 0)); + saveLoginHistory(user, LocalDateTime.of(2026, 6, 1, 9, 0)); + saveLoginHistory(otherUser, LocalDateTime.of(2026, 6, 10, 9, 0)); + } + + // createdAt은 @CreatedDate + updatable=false라 auditing이 채운 뒤에는 JPA로 값을 바꿀 수 없다. + // 날짜 필터 테스트를 위해 네이티브 쿼리로 직접 덮어쓴다. + private void saveLoginHistory(User user, LocalDateTime createdAt) { + LoginHistory saved = loginHistoryRepository.saveAndFlush(LoginHistory.create(user)); + + entityManager + .getEntityManager() + .createNativeQuery("UPDATE login_histories SET created_at = ?1 WHERE id = ?2") + .setParameter(1, createdAt) + .setParameter(2, saved.getId()) + .executeUpdate(); + entityManager.clear(); + } + + @Test + void userId로_필터링한다() { + List result = + loginHistoryRepository.findAll( + AdminLoginHistorySpecifications.filter(userId, null, null)); + + assertThat(result).hasSize(2); + } + + @Test + void 날짜범위로_필터링한다() { + List result = + loginHistoryRepository.findAll( + AdminLoginHistorySpecifications.filter( + null, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 31))); + + assertThat(result).hasSize(1); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + loginHistoryRepository.findAll( + AdminLoginHistorySpecifications.filter(null, null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/user/service/AdminLoginHistoryServiceTest.java b/src/test/java/com/bop/youthpick/user/service/AdminLoginHistoryServiceTest.java new file mode 100644 index 0000000..8e3908e --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/service/AdminLoginHistoryServiceTest.java @@ -0,0 +1,49 @@ +package com.bop.youthpick.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.user.entity.LoginHistory; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.LoginHistoryRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminLoginHistoryServiceTest { + + @Mock private LoginHistoryRepository loginHistoryRepository; + + private AdminLoginHistoryService adminLoginHistoryService; + + @BeforeEach + void setUp() { + adminLoginHistoryService = new AdminLoginHistoryService(loginHistoryRepository); + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { + LoginHistory history = mock(LoginHistory.class); + User user = mock(User.class); + when(history.getId()).thenReturn(1L); + when(history.getUser()).thenReturn(user); + when(user.getId()).thenReturn(10L); + Page page = new PageImpl<>(List.of(history)); + when(loginHistoryRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + + Page result = adminLoginHistoryService.search(10L, null, null, Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + } +} From a166aeec85c7f3544dd5b4ed5b9fbb743438eba9 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 16:16:41 +0900 Subject: [PATCH 18/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EB=A1=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keyword(query/normalized 부분일치)와 기간(createdAt) 필터를 지원하는 읽기 전용 목록 조회를 /api/v1/admin/search-logs에 추가한다. SearchLogRepository도 이번 작업에서 함께 만든다. Closes #52 --- .../controller/AdminSearchLogController.java | 37 +++++++++ .../youthpick/log/dto/SearchLogResponse.java | 22 ++++++ .../AdminSearchLogSpecifications.java | 53 +++++++++++++ .../log/repository/SearchLogRepository.java | 8 ++ .../log/service/AdminSearchLogService.java | 26 +++++++ .../AdminSearchLogControllerTest.java | 48 ++++++++++++ .../AdminSearchLogSpecificationsTest.java | 77 +++++++++++++++++++ .../service/AdminSearchLogServiceTest.java | 45 +++++++++++ 8 files changed, 316 insertions(+) create mode 100644 src/main/java/com/bop/youthpick/log/controller/AdminSearchLogController.java create mode 100644 src/main/java/com/bop/youthpick/log/dto/SearchLogResponse.java create mode 100644 src/main/java/com/bop/youthpick/log/repository/AdminSearchLogSpecifications.java create mode 100644 src/main/java/com/bop/youthpick/log/repository/SearchLogRepository.java create mode 100644 src/main/java/com/bop/youthpick/log/service/AdminSearchLogService.java create mode 100644 src/test/java/com/bop/youthpick/log/controller/AdminSearchLogControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/log/repository/AdminSearchLogSpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/log/service/AdminSearchLogServiceTest.java diff --git a/src/main/java/com/bop/youthpick/log/controller/AdminSearchLogController.java b/src/main/java/com/bop/youthpick/log/controller/AdminSearchLogController.java new file mode 100644 index 0000000..f1b5504 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/controller/AdminSearchLogController.java @@ -0,0 +1,37 @@ +package com.bop.youthpick.log.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.log.dto.SearchLogResponse; +import com.bop.youthpick.log.service.AdminSearchLogService; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/search-logs") +@RequiredArgsConstructor +public class AdminSearchLogController { + + private final AdminSearchLogService adminSearchLogService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminSearchLogService.search(keyword, startDate, endDate, pageable); + return ApiResponse.ok(page.getContent(), page); + } +} diff --git a/src/main/java/com/bop/youthpick/log/dto/SearchLogResponse.java b/src/main/java/com/bop/youthpick/log/dto/SearchLogResponse.java new file mode 100644 index 0000000..bb0982a --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/dto/SearchLogResponse.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.log.dto; + +import com.bop.youthpick.log.entity.SearchLog; +import java.time.LocalDateTime; + +public record SearchLogResponse( + Long id, + Long userId, + String originalQuery, + String normalizedQuery, + int resultCount, + LocalDateTime searchedAt) { + public static SearchLogResponse from(SearchLog searchLog) { + return new SearchLogResponse( + searchLog.getId(), + searchLog.getUserId(), + searchLog.getQuery(), + searchLog.getNormalized(), + searchLog.getResultCount(), + searchLog.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/log/repository/AdminSearchLogSpecifications.java b/src/main/java/com/bop/youthpick/log/repository/AdminSearchLogSpecifications.java new file mode 100644 index 0000000..05dc4f9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/repository/AdminSearchLogSpecifications.java @@ -0,0 +1,53 @@ +package com.bop.youthpick.log.repository; + +import com.bop.youthpick.log.entity.SearchLog; +import jakarta.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 검색 로그 목록 조회의 선택적 필터(keyword/기간)를 조합한다. */ +public final class AdminSearchLogSpecifications { + + private AdminSearchLogSpecifications() {} + + public static Specification filter( + String keyword, LocalDate startDate, LocalDate endDate) { + return Specification.allOf(keywordContains(keyword), createdBetween(startDate, endDate)); + } + + private static Specification keywordContains(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + String pattern = "%" + keyword.toLowerCase() + "%"; + return (root, query, cb) -> + cb.or( + cb.like(cb.lower(root.get("query")), pattern), + cb.like(cb.lower(root.get("normalized")), pattern)); + } + + /** + * createdAt이 [startDate, endDate] 날짜 범위 안에 있는 로그만 남긴다. 이 코드베이스는 타임스탬프를 타임존 없는 {@code + * LocalDateTime}으로만 저장하므로(UTC 저장 후 변환하는 구조가 아님) "KST 기준 기간 필터"는 저장값을 이미 KST 벽시계 시각으로 간주해 직접 + * 비교한다. + */ + private static Specification createdBetween(LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + if (startDate != null) { + predicates.add( + cb.greaterThanOrEqualTo(root.get("createdAt"), startDate.atStartOfDay())); + } + if (endDate != null) { + predicates.add( + cb.lessThan(root.get("createdAt"), endDate.plusDays(1).atStartOfDay())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/bop/youthpick/log/repository/SearchLogRepository.java b/src/main/java/com/bop/youthpick/log/repository/SearchLogRepository.java new file mode 100644 index 0000000..ea803f3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/repository/SearchLogRepository.java @@ -0,0 +1,8 @@ +package com.bop.youthpick.log.repository; + +import com.bop.youthpick.log.entity.SearchLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface SearchLogRepository + extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/log/service/AdminSearchLogService.java b/src/main/java/com/bop/youthpick/log/service/AdminSearchLogService.java new file mode 100644 index 0000000..be9ddef --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/service/AdminSearchLogService.java @@ -0,0 +1,26 @@ +package com.bop.youthpick.log.service; + +import com.bop.youthpick.log.dto.SearchLogResponse; +import com.bop.youthpick.log.repository.AdminSearchLogSpecifications; +import com.bop.youthpick.log.repository.SearchLogRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminSearchLogService { + + private final SearchLogRepository searchLogRepository; + + @Transactional(readOnly = true) + public Page search( + String keyword, LocalDate startDate, LocalDate endDate, Pageable pageable) { + return searchLogRepository + .findAll(AdminSearchLogSpecifications.filter(keyword, startDate, endDate), pageable) + .map(SearchLogResponse::from); + } +} diff --git a/src/test/java/com/bop/youthpick/log/controller/AdminSearchLogControllerTest.java b/src/test/java/com/bop/youthpick/log/controller/AdminSearchLogControllerTest.java new file mode 100644 index 0000000..d94b774 --- /dev/null +++ b/src/test/java/com/bop/youthpick/log/controller/AdminSearchLogControllerTest.java @@ -0,0 +1,48 @@ +package com.bop.youthpick.log.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.log.dto.SearchLogResponse; +import com.bop.youthpick.log.service.AdminSearchLogService; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminSearchLogController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminSearchLogControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminSearchLogService adminSearchLogService; + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = + new PageImpl<>( + List.of( + new SearchLogResponse( + 1L, 10L, "청년 정책", "청년정책", 5, LocalDateTime.now()))); + when(adminSearchLogService.search(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/search-logs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].originalQuery").value("청년 정책")) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } +} diff --git a/src/test/java/com/bop/youthpick/log/repository/AdminSearchLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/log/repository/AdminSearchLogSpecificationsTest.java new file mode 100644 index 0000000..6c66baf --- /dev/null +++ b/src/test/java/com/bop/youthpick/log/repository/AdminSearchLogSpecificationsTest.java @@ -0,0 +1,77 @@ +package com.bop.youthpick.log.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.log.entity.SearchLog; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class AdminSearchLogSpecificationsTest { + + @Autowired private SearchLogRepository searchLogRepository; + + @Autowired private TestEntityManager entityManager; + + @BeforeEach + void setUp() { + saveSearchLog("청년 정책", "청년정책", LocalDateTime.of(2026, 3, 15, 9, 0)); + saveSearchLog("주거 지원", "주거지원", LocalDateTime.of(2026, 6, 1, 9, 0)); + saveSearchLog("일자리", "일자리", LocalDateTime.of(2026, 6, 10, 9, 0)); + } + + // createdAt은 @CreatedDate + updatable=false라 auditing이 채운 뒤에는 JPA로 값을 바꿀 수 없다. + // 날짜 필터 테스트를 위해 네이티브 쿼리로 직접 덮어쓴다. + private void saveSearchLog(String query, String normalized, LocalDateTime createdAt) { + SearchLog searchLog = BeanUtils.instantiateClass(SearchLog.class); + ReflectionTestUtils.setField(searchLog, "query", query); + ReflectionTestUtils.setField(searchLog, "normalized", normalized); + ReflectionTestUtils.setField(searchLog, "resultCount", 0); + SearchLog saved = searchLogRepository.saveAndFlush(searchLog); + + entityManager + .getEntityManager() + .createNativeQuery("UPDATE search_histories SET created_at = ?1 WHERE id = ?2") + .setParameter(1, createdAt) + .setParameter(2, saved.getId()) + .executeUpdate(); + entityManager.clear(); + } + + @Test + void keyword로_query_또는_normalized를_부분일치_검색한다() { + List result = + searchLogRepository.findAll(AdminSearchLogSpecifications.filter("주거", null, null)); + + assertThat(result).extracting(SearchLog::getQuery).containsExactly("주거 지원"); + } + + @Test + void 날짜범위로_필터링한다() { + List result = + searchLogRepository.findAll( + AdminSearchLogSpecifications.filter( + null, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 31))); + + assertThat(result).extracting(SearchLog::getQuery).containsExactly("청년 정책"); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + searchLogRepository.findAll(AdminSearchLogSpecifications.filter(null, null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/log/service/AdminSearchLogServiceTest.java b/src/test/java/com/bop/youthpick/log/service/AdminSearchLogServiceTest.java new file mode 100644 index 0000000..942a24f --- /dev/null +++ b/src/test/java/com/bop/youthpick/log/service/AdminSearchLogServiceTest.java @@ -0,0 +1,45 @@ +package com.bop.youthpick.log.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.log.entity.SearchLog; +import com.bop.youthpick.log.repository.SearchLogRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminSearchLogServiceTest { + + @Mock private SearchLogRepository searchLogRepository; + + private AdminSearchLogService adminSearchLogService; + + @BeforeEach + void setUp() { + adminSearchLogService = new AdminSearchLogService(searchLogRepository); + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { + SearchLog searchLog = mock(SearchLog.class); + when(searchLog.getId()).thenReturn(1L); + Page page = new PageImpl<>(List.of(searchLog)); + when(searchLogRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + + Page result = adminSearchLogService.search("청년", null, null, Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + } +} From 977a64f6875d10b54647eb29145ffd4e2facbfa9 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 16:29:16 +0900 Subject: [PATCH 19/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EC=8B=A0=EC=B2=AD=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 관리자 - 정책 신청 관리 API 추가 목록 조회(userId/policyName/status/deadline 범위 필터 + Pageable), 체크리스트 읽기 전용 조회, 상태 변경 엔드포인트를 /api/v1/admin/policy-applications 아래 추가한다. PolicyApplicationRepository/ApplicationChecklistRepository도 이번 작업에서 함께 만든다. 프론트 스펙은 상태를 INTERESTED/PREPARING/SUBMITTED/CLOSED 4단계로 보지만 실제 ApplicationStatus enum은 INTERESTED/APPLIED/COMPLETED 3단계다. 기존 enum을 그대로 쓰고 PR에 차이를 명시한다. Closes #48 * fix: 신청 관리 API 상태값/성능/일관성 보완 - 상태값을 프론트 계약(INTERESTED/PREPARING/SUBMITTED/CLOSED)에 맞춰 변경 (기존 APPLIED/COMPLETED, V3 마이그레이션으로 기존 데이터 재매핑) - id/userId/policyId를 프론트가 기대하는 문자열로 JSON 직렬화 - 목록 조회에서 policy/user를 fetch join하여 N+1 쿼리 제거 - 관리 해제(soft delete)된 신청을 목록/상세/체크리스트 조회에서 제외 - 체크리스트 조회 순서를 id 오름차순으로 명시 * Revert "fix: 신청 관리 API 상태값/성능/일관성 보완" This reverts commit c83a32e481119cdc3ac75cfe79a41cc3a08d31e0. * fix: 신청 관리 API 리뷰 코멘트(N+1, soft-delete, 상태값 방어) 반영 - PolicyApplication/ApplicationChecklist에 @SQLRestriction(deleted_at IS NULL)을 적용해 관리 해제(soft delete)된 행이 모든 조회에서 누락 없이 제외되도록 함 - 목록 조회에서 policy/user를 LEFT fetch join(count 쿼리는 제외)해 N+1 제거, policyName 필터의 join과 fetch join이 같은 policy 경로를 재사용하도록 정리 - ApplicationStatus.valueOf 실패 시 500 대신 P005(INVALID_APPLICATION_STATUS)로 매핑 - 체크리스트 조회 정렬을 id 오름차순으로 명시(findByApplicationIdOrderByIdAsc) - deadline 비교의 타임존 가정(JVM 기본 타임존=KST 고정 운영)을 주석으로 명시 * fix: policyName 필터 없이도 policy를 fetch join하도록 수정 policyJoin()이 기존 join/fetch가 없을 때 항상 일반 join(root.join)만 만들어, policyName 필터가 없는 목록 조회에서는 policy가 실제로 fetch되지 않고 N+1이 남아있었다(kilo-code-bot 재리뷰 지적). count 쿼리에서는 fetch가 허용되지 않으므로 일반 join, content 쿼리에서는 fetch join을 생성하도록 분기하고, fetch 여부를 Hibernate.isInitialized로 검증하는 테스트를 추가. --------- Co-authored-by: canfactor --- .../AdminPolicyApplicationController.java | 67 ++++++ .../dto/AdminPolicyApplicationResponse.java | 28 +++ ...nPolicyApplicationStatusUpdateRequest.java | 11 + .../dto/ApplicationChecklistItemResponse.java | 20 ++ .../policy/entity/ApplicationChecklist.java | 2 + .../policy/entity/PolicyApplication.java | 6 + .../policy/exception/PolicyErrorCode.java | 4 +- .../AdminPolicyApplicationSpecifications.java | 133 ++++++++++++ .../ApplicationChecklistRepository.java | 10 + .../PolicyApplicationRepository.java | 9 + .../AdminPolicyApplicationService.java | 74 +++++++ .../AdminPolicyApplicationControllerTest.java | 130 ++++++++++++ ...inPolicyApplicationSpecificationsTest.java | 200 ++++++++++++++++++ .../ApplicationChecklistRepositoryTest.java | 93 ++++++++ .../AdminPolicyApplicationServiceTest.java | 141 ++++++++++++ 15 files changed, 927 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationController.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/ApplicationChecklistItemResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecifications.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/AdminPolicyApplicationService.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepositoryTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/AdminPolicyApplicationServiceTest.java diff --git a/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationController.java b/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationController.java new file mode 100644 index 0000000..c0ef8d7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationController.java @@ -0,0 +1,67 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.AdminPolicyApplicationResponse; +import com.bop.youthpick.policy.dto.AdminPolicyApplicationStatusUpdateRequest; +import com.bop.youthpick.policy.dto.ApplicationChecklistItemResponse; +import com.bop.youthpick.policy.service.AdminPolicyApplicationService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/policy-applications") +@RequiredArgsConstructor +@Validated +public class AdminPolicyApplicationController { + + private final AdminPolicyApplicationService adminPolicyApplicationService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) Long userId, + @RequestParam(required = false) String policyName, + @RequestParam(required = false) + @Pattern( + regexp = "INTERESTED|APPLIED|COMPLETED", + message = "status는 INTERESTED, APPLIED, COMPLETED 중 하나여야 합니다.") + String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate deadlineStart, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate deadlineEnd, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminPolicyApplicationService.search( + userId, policyName, status, deadlineStart, deadlineEnd, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + @GetMapping("/{applicationId}/checklist") + public ApiResponse> getChecklist( + @PathVariable Long applicationId) { + return ApiResponse.ok(adminPolicyApplicationService.getChecklist(applicationId)); + } + + @PatchMapping("/{applicationId}/status") + public ApiResponse updateStatus( + @PathVariable Long applicationId, + @Valid @RequestBody AdminPolicyApplicationStatusUpdateRequest request) { + return ApiResponse.ok( + adminPolicyApplicationService.updateStatus(applicationId, request.status())); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationResponse.java b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationResponse.java new file mode 100644 index 0000000..aa29bca --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationResponse.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.PolicyApplication; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record AdminPolicyApplicationResponse( + Long id, + Long userId, + Long policyId, + String policyName, + ApplicationStatus status, + String memo, + LocalDate deadline, + LocalDateTime createdAt) { + public static AdminPolicyApplicationResponse from(PolicyApplication application) { + return new AdminPolicyApplicationResponse( + application.getId(), + application.getUser().getId(), + application.getPolicy().getId(), + application.getPolicy().getTitle(), + application.getStatus(), + application.getMemo(), + application.getEndAt() == null ? null : application.getEndAt().toLocalDate(), + application.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java new file mode 100644 index 0000000..684420e --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record AdminPolicyApplicationStatusUpdateRequest( + @NotBlank(message = "status는 필수입니다.") + @Pattern( + regexp = "INTERESTED|APPLIED|COMPLETED", + message = "status는 INTERESTED, APPLIED, COMPLETED 중 하나여야 합니다.") + String status) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/ApplicationChecklistItemResponse.java b/src/main/java/com/bop/youthpick/policy/dto/ApplicationChecklistItemResponse.java new file mode 100644 index 0000000..18ad772 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/ApplicationChecklistItemResponse.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.ApplicationChecklist; +import java.time.LocalDateTime; + +public record ApplicationChecklistItemResponse( + Long id, + Long policyApplicationId, + boolean checked, + String description, + LocalDateTime createdAt) { + public static ApplicationChecklistItemResponse from(ApplicationChecklist item) { + return new ApplicationChecklistItemResponse( + item.getId(), + item.getApplication().getId(), + item.isChecked(), + item.getContent(), + item.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java index 21f0823..fab1249 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java @@ -15,6 +15,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; /** 신청관리별 준비 체크리스트 (제출서류 등). */ @Entity @@ -24,6 +25,7 @@ @Index( name = "idx_policy_application_checklists_app", columnList = "application_id")) +@SQLRestriction("deleted_at IS NULL") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ApplicationChecklist extends BaseEntity { diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java index 2ff3993..56cca27 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java @@ -18,6 +18,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; /** * 사용자별 정책 신청 진행관리 (관심 → 신청 → 완료). 기존 favorite_policies를 흡수 — status=INTERESTED가 즐겨찾기. UNIQUE(user, @@ -30,6 +31,7 @@ @UniqueConstraint( name = "uk_policy_applications_user_policy", columnNames = {"user_id", "policy_id"})) +@SQLRestriction("deleted_at IS NULL") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PolicyApplication extends BaseEntity { @@ -60,4 +62,8 @@ public class PolicyApplication extends BaseEntity { /** 관리 해제 = soft delete. 재등록 시 행 재활성화(UNIQUE 충돌 방지) */ @Column(name = "deleted_at") private LocalDateTime deletedAt; + + public void changeStatus(ApplicationStatus status) { + this.status = status; + } } diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java index 8253e09..fc83dcf 100644 --- a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -10,7 +10,9 @@ public enum PolicyErrorCode implements ErrorCode { POLICY_NOT_FOUND("P001", "일치하는 정책이 존재하지 않습니다.", HttpStatus.NOT_FOUND), POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT), - REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND); + REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND), + POLICY_APPLICATION_NOT_FOUND("P004", "일치하는 정책 신청 내역이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + INVALID_APPLICATION_STATUS("P005", "유효하지 않은 신청 상태 값입니다.", HttpStatus.BAD_REQUEST); private final String code; private final String message; diff --git a/src/main/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecifications.java b/src/main/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecifications.java new file mode 100644 index 0000000..0f870eb --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecifications.java @@ -0,0 +1,133 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Fetch; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 정책 신청 목록 조회의 선택적 필터(userId/policyName/status/deadline 범위)를 조합한다. */ +public final class AdminPolicyApplicationSpecifications { + + private AdminPolicyApplicationSpecifications() {} + + public static Specification filter( + Long userId, + String policyName, + String status, + LocalDate deadlineStart, + LocalDate deadlineEnd) { + return Specification.allOf( + userIdEquals(userId), + policyNameContains(policyName), + statusEquals(status), + deadlineBetween(deadlineStart, deadlineEnd), + fetchPolicyAndUser()); + } + + private static Specification userIdEquals(Long userId) { + if (userId == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("user").get("id"), userId); + } + + private static Specification policyNameContains(String policyName) { + if (policyName == null || policyName.isBlank()) { + return null; + } + String pattern = "%" + policyName.toLowerCase() + "%"; + return (root, query, cb) -> + cb.like(cb.lower(policyJoin(root, query).get("title")), pattern); + } + + /** 목록 조회(content 쿼리)에서만 policy/user를 LEFT fetch join해 응답 매핑 시 N+1을 없앤다(count 쿼리는 건너뛴다). */ + private static Specification fetchPolicyAndUser() { + return (root, query, cb) -> { + if (!isCountQuery(query)) { + policyJoin(root, query); + root.fetch("user", JoinType.LEFT); + } + return cb.conjunction(); + }; + } + + private static boolean isCountQuery(CriteriaQuery query) { + return query.getResultType() == Long.class || query.getResultType() == long.class; + } + + /** + * policyNameContains의 필터 join과 fetchPolicyAndUser의 fetch join이 동일한 "policy" 경로를 공유하도록 재사용한다. + * count 쿼리에서는 fetch가 허용되지 않으므로 일반 join으로, content 쿼리에서는 fetch join으로 만든다(Fetch는 Join도 구현하므로 + * 필터링에도 그대로 쓸 수 있다). + */ + @SuppressWarnings("unchecked") + private static Join policyJoin( + Root root, CriteriaQuery query) { + for (Fetch fetch : root.getFetches()) { + if (fetch.getAttribute().getName().equals("policy")) { + return (Join) fetch; + } + } + for (Join join : root.getJoins()) { + if (join.getAttribute().getName().equals("policy")) { + return (Join) join; + } + } + if (isCountQuery(query)) { + return root.join("policy", JoinType.LEFT); + } + return (Join) + root.fetch("policy", JoinType.LEFT); + } + + private static Specification statusEquals(String status) { + if (status == null || status.isBlank()) { + return null; + } + ApplicationStatus value = parseStatus(status); + return (root, query, cb) -> cb.equal(root.get("status"), value); + } + + private static ApplicationStatus parseStatus(String status) { + try { + return ApplicationStatus.valueOf(status); + } catch (IllegalArgumentException e) { + throw new CustomException(PolicyErrorCode.INVALID_APPLICATION_STATUS); + } + } + + /** + * deadline(endAt)이 [deadlineStart, deadlineEnd] 날짜 범위 안에 있는 신청만 남긴다. 이 프로젝트는 서버 전역에서 타임존 변환 없이 + * JVM 기본 타임존(KST로 고정 운영) 기준 LocalDateTime을 그대로 저장·비교하므로, 여기서도 같은 가정(KST 자정 경계)으로 비교한다. + */ + private static Specification deadlineBetween( + LocalDate deadlineStart, LocalDate deadlineEnd) { + if (deadlineStart == null && deadlineEnd == null) { + return null; + } + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.isNotNull(root.get("endAt"))); + if (deadlineStart != null) { + predicates.add( + cb.greaterThanOrEqualTo(root.get("endAt"), deadlineStart.atStartOfDay())); + } + if (deadlineEnd != null) { + predicates.add( + cb.lessThan(root.get("endAt"), deadlineEnd.plusDays(1).atStartOfDay())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java b/src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java new file mode 100644 index 0000000..6199314 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.ApplicationChecklist; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationChecklistRepository extends JpaRepository { + + List findByApplicationIdOrderByIdAsc(Long applicationId); +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java new file mode 100644 index 0000000..203e554 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.PolicyApplication; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface PolicyApplicationRepository + extends JpaRepository, + JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/policy/service/AdminPolicyApplicationService.java b/src/main/java/com/bop/youthpick/policy/service/AdminPolicyApplicationService.java new file mode 100644 index 0000000..9c6b415 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/AdminPolicyApplicationService.java @@ -0,0 +1,74 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.AdminPolicyApplicationResponse; +import com.bop.youthpick.policy.dto.ApplicationChecklistItemResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.AdminPolicyApplicationSpecifications; +import com.bop.youthpick.policy.repository.ApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminPolicyApplicationService { + + private final PolicyApplicationRepository policyApplicationRepository; + private final ApplicationChecklistRepository applicationChecklistRepository; + + @Transactional(readOnly = true) + public Page search( + Long userId, + String policyName, + String status, + LocalDate deadlineStart, + LocalDate deadlineEnd, + Pageable pageable) { + return policyApplicationRepository + .findAll( + AdminPolicyApplicationSpecifications.filter( + userId, policyName, status, deadlineStart, deadlineEnd), + pageable) + .map(AdminPolicyApplicationResponse::from); + } + + @Transactional(readOnly = true) + public List getChecklist(Long applicationId) { + findApplication(applicationId); + return applicationChecklistRepository + .findByApplicationIdOrderByIdAsc(applicationId) + .stream() + .map(ApplicationChecklistItemResponse::from) + .toList(); + } + + @Transactional + public AdminPolicyApplicationResponse updateStatus(Long applicationId, String status) { + PolicyApplication application = findApplication(applicationId); + application.changeStatus(parseStatus(status)); + return AdminPolicyApplicationResponse.from(application); + } + + private PolicyApplication findApplication(Long applicationId) { + return policyApplicationRepository + .findById(applicationId) + .orElseThrow( + () -> new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + } + + private ApplicationStatus parseStatus(String status) { + try { + return ApplicationStatus.valueOf(status); + } catch (IllegalArgumentException e) { + throw new CustomException(PolicyErrorCode.INVALID_APPLICATION_STATUS); + } + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationControllerTest.java new file mode 100644 index 0000000..db0727d --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationControllerTest.java @@ -0,0 +1,130 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.AdminPolicyApplicationResponse; +import com.bop.youthpick.policy.dto.ApplicationChecklistItemResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.service.AdminPolicyApplicationService; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminPolicyApplicationController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminPolicyApplicationControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminPolicyApplicationService adminPolicyApplicationService; + + private static final AdminPolicyApplicationResponse APPLICATION_RESPONSE = + new AdminPolicyApplicationResponse( + 1L, + 10L, + 20L, + "정책명", + ApplicationStatus.INTERESTED, + "메모", + LocalDate.of(2026, 12, 31), + LocalDateTime.now()); + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = new PageImpl<>(List.of(APPLICATION_RESPONSE)); + when(adminPolicyApplicationService.search( + isNull(), isNull(), isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/policy-applications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].policyName").value("정책명")) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void status_필터가_허용값이_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/admin/policy-applications").param("status", "CLOSED")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 체크리스트_조회는_200과_목록을_반환한다() throws Exception { + when(adminPolicyApplicationService.getChecklist(1L)) + .thenReturn( + List.of( + new ApplicationChecklistItemResponse( + 100L, 1L, true, "주민등록등본 제출", LocalDateTime.now()))); + + mockMvc.perform(get("/api/v1/admin/policy-applications/{applicationId}/checklist", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(100)) + .andExpect(jsonPath("$.data[0].checked").value(true)); + } + + @Test + void 체크리스트_조회_대상이_없으면_404와_P004를_반환한다() throws Exception { + when(adminPolicyApplicationService.getChecklist(1L)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/policy-applications/{applicationId}/checklist", 1L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("P004")); + } + + @Test + void 상태_변경이_유효하면_200을_반환한다() throws Exception { + when(adminPolicyApplicationService.updateStatus(eq(1L), eq("APPLIED"))) + .thenReturn(APPLICATION_RESPONSE); + + mockMvc.perform( + patch("/api/v1/admin/policy-applications/{applicationId}/status", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"APPLIED\"}")) + .andExpect(status().isOk()); + } + + @Test + void 상태_값이_허용범위가_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + patch("/api/v1/admin/policy-applications/{applicationId}/status", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"CLOSED\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 상태_변경_대상이_없으면_404와_P004를_반환한다() throws Exception { + when(adminPolicyApplicationService.updateStatus(eq(1L), eq("APPLIED"))) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + + mockMvc.perform( + patch("/api/v1/admin/policy-applications/{applicationId}/status", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"APPLIED\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("P004")); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecificationsTest.java b/src/test/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecificationsTest.java new file mode 100644 index 0000000..59efda2 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecificationsTest.java @@ -0,0 +1,200 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.hibernate.Hibernate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class AdminPolicyApplicationSpecificationsTest { + + @Autowired private PolicyApplicationRepository policyApplicationRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private TestEntityManager entityManager; + + private Long userId; + + @BeforeEach + void setUp() { + User user = userRepository.save(User.createSocialUser("kakao", "kakao-1", null, "닉네임")); + userId = user.getId(); + + Policy jobPolicy = entityManager.persistAndFlush(newPolicy("P001", "청년 일자리 지원")); + Policy housingPolicy = entityManager.persistAndFlush(newPolicy("P002", "청년 주거 지원")); + Policy educationPolicy = entityManager.persistAndFlush(newPolicy("P003", "청년 교육 지원")); + + policyApplicationRepository.save( + newApplication( + user, + jobPolicy, + ApplicationStatus.INTERESTED, + LocalDateTime.of(2026, 3, 15, 0, 0))); + policyApplicationRepository.save( + newApplication( + user, + housingPolicy, + ApplicationStatus.APPLIED, + LocalDateTime.of(2026, 6, 1, 0, 0))); + policyApplicationRepository.save( + newApplication(user, educationPolicy, ApplicationStatus.COMPLETED, null)); + } + + private Policy newPolicy(String policyNo, String title) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", title); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + return policy; + } + + private PolicyApplication newApplication( + User user, Policy policy, ApplicationStatus status, LocalDateTime endAt) { + PolicyApplication application = BeanUtils.instantiateClass(PolicyApplication.class); + ReflectionTestUtils.setField(application, "user", user); + ReflectionTestUtils.setField(application, "policy", policy); + ReflectionTestUtils.setField(application, "status", status); + ReflectionTestUtils.setField(application, "endAt", endAt); + return application; + } + + @Test + void userId로_필터링한다() { + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter( + userId, null, null, null, null)); + + assertThat(result).hasSize(3); + } + + @Test + void policyName_부분일치로_필터링한다() { + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter(null, "주거", null, null, null)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getPolicy().getTitle()).isEqualTo("청년 주거 지원"); + } + + @Test + void status로_필터링한다() { + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter( + null, null, "COMPLETED", null, null)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getStatus()).isEqualTo(ApplicationStatus.COMPLETED); + } + + @Test + void deadline_범위로_필터링한다() { + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter( + null, + null, + null, + LocalDate.of(2026, 3, 1), + LocalDate.of(2026, 3, 31))); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getStatus()).isEqualTo(ApplicationStatus.INTERESTED); + } + + @Test + void deadline이_없는_신청은_기간_필터에서_제외된다() { + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter( + null, + null, + null, + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31))); + + assertThat(result).hasSize(2); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter(null, null, null, null, null)); + + assertThat(result).hasSize(3); + } + + @Test + void 관리_해제된_신청은_목록에서_제외된다() { + PolicyApplication target = + policyApplicationRepository + .findAll( + AdminPolicyApplicationSpecifications.filter( + null, null, "COMPLETED", null, null)) + .get(0); + ReflectionTestUtils.setField(target, "deletedAt", LocalDateTime.now()); + entityManager.persistAndFlush(target); + entityManager.clear(); + + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter(null, null, null, null, null)); + + assertThat(result).hasSize(2); + assertThat(result) + .noneMatch(application -> application.getStatus() == ApplicationStatus.COMPLETED); + } + + @Test + void policyName_필터가_없어도_policy와_user가_fetch된다() { + entityManager.clear(); + + List result = + policyApplicationRepository.findAll( + AdminPolicyApplicationSpecifications.filter(null, null, null, null, null)); + + assertThat(result).isNotEmpty(); + assertThat(result) + .allSatisfy( + application -> { + assertThat(Hibernate.isInitialized(application.getPolicy())).isTrue(); + assertThat(Hibernate.isInitialized(application.getUser())).isTrue(); + }); + } + + @Test + void 잘못된_상태값이면_INVALID_APPLICATION_STATUS_예외를_던진다() { + assertThatThrownBy( + () -> + AdminPolicyApplicationSpecifications.filter( + null, null, "INVALID_STATUS", null, null)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.INVALID_APPLICATION_STATUS); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepositoryTest.java new file mode 100644 index 0000000..d328e96 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepositoryTest.java @@ -0,0 +1,93 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.ApplicationChecklist; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class ApplicationChecklistRepositoryTest { + + @Autowired private ApplicationChecklistRepository applicationChecklistRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private TestEntityManager entityManager; + + private Long applicationId; + + @BeforeEach + void setUp() { + User user = userRepository.save(User.createSocialUser("kakao", "kakao-1", null, "닉네임")); + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", "P001"); + ReflectionTestUtils.setField(policy, "title", "청년 일자리 지원"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + entityManager.persistAndFlush(policy); + + PolicyApplication application = BeanUtils.instantiateClass(PolicyApplication.class); + ReflectionTestUtils.setField(application, "user", user); + ReflectionTestUtils.setField(application, "policy", policy); + ReflectionTestUtils.setField(application, "status", ApplicationStatus.INTERESTED); + entityManager.persistAndFlush(application); + applicationId = application.getId(); + + saveChecklistItem(application, "서류 A"); + saveChecklistItem(application, "서류 B"); + saveChecklistItem(application, "서류 C"); + } + + private void saveChecklistItem(PolicyApplication application, String content) { + ApplicationChecklist item = BeanUtils.instantiateClass(ApplicationChecklist.class); + ReflectionTestUtils.setField(item, "application", application); + ReflectionTestUtils.setField(item, "content", content); + ReflectionTestUtils.setField(item, "checked", false); + entityManager.persistAndFlush(item); + } + + @Test + void 체크리스트는_id_오름차순으로_조회된다() { + List result = + applicationChecklistRepository.findByApplicationIdOrderByIdAsc(applicationId); + + assertThat(result) + .extracting(ApplicationChecklist::getContent) + .containsExactly("서류 A", "서류 B", "서류 C"); + } + + @Test + void 관리_해제된_체크리스트는_조회에서_제외된다() { + ApplicationChecklist target = + applicationChecklistRepository + .findByApplicationIdOrderByIdAsc(applicationId) + .get(0); + ReflectionTestUtils.setField(target, "deletedAt", LocalDateTime.now()); + entityManager.persistAndFlush(target); + entityManager.clear(); + + List result = + applicationChecklistRepository.findByApplicationIdOrderByIdAsc(applicationId); + + assertThat(result).hasSize(2); + assertThat(result) + .extracting(ApplicationChecklist::getContent) + .containsExactly("서류 B", "서류 C"); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/AdminPolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/AdminPolicyApplicationServiceTest.java new file mode 100644 index 0000000..d9345f0 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/AdminPolicyApplicationServiceTest.java @@ -0,0 +1,141 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.ApplicationChecklist; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.ApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; +import com.bop.youthpick.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminPolicyApplicationServiceTest { + + @Mock private PolicyApplicationRepository policyApplicationRepository; + + @Mock private ApplicationChecklistRepository applicationChecklistRepository; + + private AdminPolicyApplicationService adminPolicyApplicationService; + + private static final Long APPLICATION_ID = 1L; + + @BeforeEach + void setUp() { + adminPolicyApplicationService = + new AdminPolicyApplicationService( + policyApplicationRepository, applicationChecklistRepository); + } + + private PolicyApplication mockApplication() { + PolicyApplication application = mock(PolicyApplication.class); + User user = mock(User.class); + Policy policy = mock(Policy.class); + when(application.getId()).thenReturn(APPLICATION_ID); + when(application.getUser()).thenReturn(user); + when(user.getId()).thenReturn(10L); + when(application.getPolicy()).thenReturn(policy); + when(policy.getId()).thenReturn(20L); + when(policy.getTitle()).thenReturn("정책명"); + return application; + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { + Page page = new PageImpl<>(List.of(mockApplication())); + when(policyApplicationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + + Page result = + adminPolicyApplicationService.search( + 10L, "정책", "INTERESTED", null, null, Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + } + + @Test + void 체크리스트를_조회한다() { + when(policyApplicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(mock(PolicyApplication.class))); + ApplicationChecklist item = mock(ApplicationChecklist.class); + PolicyApplication application = mock(PolicyApplication.class); + when(item.getId()).thenReturn(100L); + when(item.getApplication()).thenReturn(application); + when(application.getId()).thenReturn(APPLICATION_ID); + when(item.isChecked()).thenReturn(true); + when(item.getContent()).thenReturn("주민등록등본 제출"); + when(applicationChecklistRepository.findByApplicationIdOrderByIdAsc(APPLICATION_ID)) + .thenReturn(List.of(item)); + + List result = adminPolicyApplicationService.getChecklist(APPLICATION_ID); + + assertThat(result).hasSize(1); + } + + @Test + void 체크리스트_조회_대상이_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminPolicyApplicationService.getChecklist(APPLICATION_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void 상태를_변경한다() { + PolicyApplication application = mockApplication(); + when(policyApplicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(application)); + when(application.getStatus()).thenReturn(ApplicationStatus.APPLIED); + + var result = adminPolicyApplicationService.updateStatus(APPLICATION_ID, "APPLIED"); + + verify(application).changeStatus(ApplicationStatus.APPLIED); + assertThat(result.status()).isEqualTo(ApplicationStatus.APPLIED); + } + + @Test + void 상태_변경_대상이_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> adminPolicyApplicationService.updateStatus(APPLICATION_ID, "APPLIED")) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void 유효하지_않은_상태값이면_INVALID_APPLICATION_STATUS_예외를_던진다() { + when(policyApplicationRepository.findById(APPLICATION_ID)) + .thenReturn(Optional.of(mock(PolicyApplication.class))); + + assertThatThrownBy( + () -> + adminPolicyApplicationService.updateStatus( + APPLICATION_ID, "INVALID_STATUS")) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.INVALID_APPLICATION_STATUS); + } +} From 28229098ab3413d779f559a8289c7e2f45e3d9a2 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 16:29:36 +0900 Subject: [PATCH 20/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 관리자 - 커뮤니티 관리 API 추가 게시글 목록 조회(category/authorId/createdAt 범위 필터 + Pageable), 댓글/첨부파일 조회, 게시글/댓글 soft delete 엔드포인트를 /api/v1/admin/community 아래 추가한다. board 패키지에 없던 PostRepository/CommentRepository/AttachmentRepository와 BoardErrorCode(신규 접두어 B)도 함께 만든다. 게시글 createdAt은 스펙의 날짜만 있는 mock과 달리 실제 시:분초 타임스탬프를 그대로 내려준다(스펙 문서 자체가 이 방향을 제안). category(QUESTION/REVIEW/FREE)와 첨부파일 필드명(fileKey ← fileUrl)의 프론트 표시값 차이는 PR에 명시한다. Closes #50 * refac: 관리자 커뮤니티 API 리뷰 반영 (N+1 방지, 에러코드 접두어 문서화) - 게시글 목록: user를 fetch join으로 함께 로딩해 페이지당 N+1 쿼리 제거 (count 쿼리에는 fetch를 적용하지 않는다) - 댓글 조회(findByPostId): @EntityGraph(user, parent)로 N+1 제거 - error-handling.md 접두어 표에 board 도메인의 B 접두어 추가 리뷰: kilo-code-bot (PR #51) --- .claude/rules/error-handling.md | 2 +- .../AdminCommunityCommentController.java | 23 +++ .../AdminCommunityPostController.java | 64 +++++++ .../board/dto/AdminAttachmentResponse.java | 16 ++ .../dto/AdminCommunityCommentResponse.java | 24 +++ .../board/dto/AdminCommunityPostResponse.java | 29 ++++ .../bop/youthpick/board/entity/Comment.java | 4 + .../com/bop/youthpick/board/entity/Post.java | 4 + .../board/exception/BoardErrorCode.java | 17 ++ .../board/exception/BoardException.java | 11 ++ .../AdminCommunityPostSpecifications.java | 57 +++++++ .../repository/AttachmentRepository.java | 10 ++ .../board/repository/CommentRepository.java | 12 ++ .../board/repository/PostRepository.java | 7 + .../board/service/AdminCommunityService.java | 97 +++++++++++ .../AdminCommunityCommentControllerTest.java | 49 ++++++ .../AdminCommunityPostControllerTest.java | 142 ++++++++++++++++ .../AdminCommunityPostSpecificationsTest.java | 102 ++++++++++++ .../service/AdminCommunityServiceTest.java | 157 ++++++++++++++++++ 19 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java create mode 100644 src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java create mode 100644 src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java create mode 100644 src/main/java/com/bop/youthpick/board/dto/AdminCommunityCommentResponse.java create mode 100644 src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java create mode 100644 src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java create mode 100644 src/main/java/com/bop/youthpick/board/exception/BoardException.java create mode 100644 src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java create mode 100644 src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java create mode 100644 src/main/java/com/bop/youthpick/board/repository/CommentRepository.java create mode 100644 src/main/java/com/bop/youthpick/board/repository/PostRepository.java create mode 100644 src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java create mode 100644 src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md index 280a02b..5607270 100644 --- a/.claude/rules/error-handling.md +++ b/.claude/rules/error-handling.md @@ -36,7 +36,7 @@ public enum UserErrorCode implements ErrorCode { | `C` | 공통(common) | | `U` | 사용자(user) | | `A` | 인증/인가(auth) | | `P` | 정책(policy) | | `S` | 서버(server) | | `F` | 즐겨찾기(favorite) | -| | | | `D` | 자가진단(diagnosis) | +| `B` | 커뮤니티(board) | | `D` | 자가진단(diagnosis) | ## 규칙 diff --git a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java new file mode 100644 index 0000000..3bb1256 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java @@ -0,0 +1,23 @@ +package com.bop.youthpick.board.controller; + +import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.board.service.AdminCommunityService; +import com.bop.youthpick.global.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/community/comments") +@RequiredArgsConstructor +public class AdminCommunityCommentController { + + private final AdminCommunityService adminCommunityService; + + @DeleteMapping("/{commentId}") + public ApiResponse delete(@PathVariable Long commentId) { + return ApiResponse.ok(adminCommunityService.deleteComment(commentId)); + } +} diff --git a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java new file mode 100644 index 0000000..9a5090b --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java @@ -0,0 +1,64 @@ +package com.bop.youthpick.board.controller; + +import com.bop.youthpick.board.dto.AdminAttachmentResponse; +import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.board.service.AdminCommunityService; +import com.bop.youthpick.global.common.ApiResponse; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@RestController +@RequestMapping("/api/v1/admin/community/posts") +@RequiredArgsConstructor +@Validated +public class AdminCommunityPostController { + + private final AdminCommunityService adminCommunityService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) + @Pattern( + regexp = "QUESTION|REVIEW|FREE", + message = "category는 QUESTION, REVIEW, FREE 중 하나여야 합니다.") + String category, + @RequestParam(required = false) Long authorId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminCommunityService.searchPosts(category, authorId, startDate, endDate, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + @GetMapping("/{postId}/comments") + public ApiResponse> getComments(@PathVariable Long postId) { + return ApiResponse.ok(adminCommunityService.getComments(postId)); + } + + @GetMapping("/{postId}/attachments") + public ApiResponse> getAttachments(@PathVariable Long postId) { + return ApiResponse.ok(adminCommunityService.getAttachments(postId)); + } + + @DeleteMapping("/{postId}") + public ApiResponse delete(@PathVariable Long postId) { + return ApiResponse.ok(adminCommunityService.deletePost(postId)); + } +} diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java b/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java new file mode 100644 index 0000000..36d6473 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java @@ -0,0 +1,16 @@ +package com.bop.youthpick.board.dto; + +import com.bop.youthpick.board.entity.Attachment; +import java.time.LocalDateTime; + +public record AdminAttachmentResponse( + Long id, Long postId, String fileKey, Long fileSize, LocalDateTime createdAt) { + public static AdminAttachmentResponse from(Attachment attachment) { + return new AdminAttachmentResponse( + attachment.getId(), + attachment.getPost().getId(), + attachment.getFileUrl(), + attachment.getFileSize(), + attachment.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityCommentResponse.java b/src/main/java/com/bop/youthpick/board/dto/AdminCommunityCommentResponse.java new file mode 100644 index 0000000..2434930 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/dto/AdminCommunityCommentResponse.java @@ -0,0 +1,24 @@ +package com.bop.youthpick.board.dto; + +import com.bop.youthpick.board.entity.Comment; +import java.time.LocalDateTime; + +public record AdminCommunityCommentResponse( + Long id, + Long postId, + Long parentCommentId, + String authorName, + String content, + LocalDateTime createdAt, + LocalDateTime deletedAt) { + public static AdminCommunityCommentResponse from(Comment comment) { + return new AdminCommunityCommentResponse( + comment.getId(), + comment.getPost().getId(), + comment.getParent() == null ? null : comment.getParent().getId(), + comment.getUser().getNickname(), + comment.getContent(), + comment.getCreatedAt(), + comment.getDeletedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java b/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java new file mode 100644 index 0000000..3d184b6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java @@ -0,0 +1,29 @@ +package com.bop.youthpick.board.dto; + +import com.bop.youthpick.board.entity.Post; +import com.bop.youthpick.board.entity.PostCategory; +import java.time.LocalDateTime; + +public record AdminCommunityPostResponse( + Long id, + String title, + PostCategory category, + String content, + Long authorId, + String authorName, + LocalDateTime createdAt, + int viewCount, + LocalDateTime deletedAt) { + public static AdminCommunityPostResponse from(Post post) { + return new AdminCommunityPostResponse( + post.getId(), + post.getTitle(), + post.getCategory(), + post.getContent(), + post.getUser().getId(), + post.getUser().getNickname(), + post.getCreatedAt(), + post.getViewCount(), + post.getDeletedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/board/entity/Comment.java b/src/main/java/com/bop/youthpick/board/entity/Comment.java index b0bc6e8..6ee113f 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Comment.java +++ b/src/main/java/com/bop/youthpick/board/entity/Comment.java @@ -45,4 +45,8 @@ public class Comment extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/bop/youthpick/board/entity/Post.java b/src/main/java/com/bop/youthpick/board/entity/Post.java index dd55064..7236b04 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Post.java +++ b/src/main/java/com/bop/youthpick/board/entity/Post.java @@ -54,4 +54,8 @@ public class Post extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java b/src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java new file mode 100644 index 0000000..87af69e --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.board.exception; + +import com.bop.youthpick.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BoardErrorCode implements ErrorCode { + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "게시글을 찾을 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "댓글을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/bop/youthpick/board/exception/BoardException.java b/src/main/java/com/bop/youthpick/board/exception/BoardException.java new file mode 100644 index 0000000..5b19228 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/exception/BoardException.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.board.exception; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.global.error.ErrorCode; + +public class BoardException extends CustomException { + + public BoardException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java b/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java new file mode 100644 index 0000000..34b2514 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java @@ -0,0 +1,57 @@ +package com.bop.youthpick.board.repository; + +import com.bop.youthpick.board.entity.Post; +import com.bop.youthpick.board.entity.PostCategory; +import jakarta.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 게시글 목록 조회의 선택적 필터(category/authorId/createdAt 범위)를 조합한다. */ +public final class AdminCommunityPostSpecifications { + + private AdminCommunityPostSpecifications() {} + + public static Specification filter( + String category, Long authorId, LocalDate startDate, LocalDate endDate) { + return Specification.allOf( + categoryEquals(category), + authorIdEquals(authorId), + createdBetween(startDate, endDate)); + } + + private static Specification categoryEquals(String category) { + if (category == null || category.isBlank()) { + return null; + } + PostCategory value = PostCategory.valueOf(category); + return (root, query, cb) -> cb.equal(root.get("category"), value); + } + + private static Specification authorIdEquals(Long authorId) { + if (authorId == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get("user").get("id"), authorId); + } + + /** createdAt이 [startDate, endDate] 날짜 범위 안에 있는 게시글만 남긴다(KST 변환 없이 직접 비교). */ + private static Specification createdBetween(LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + if (startDate != null) { + predicates.add( + cb.greaterThanOrEqualTo(root.get("createdAt"), startDate.atStartOfDay())); + } + if (endDate != null) { + predicates.add( + cb.lessThan(root.get("createdAt"), endDate.plusDays(1).atStartOfDay())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java b/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java new file mode 100644 index 0000000..c217983 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.board.repository; + +import com.bop.youthpick.board.entity.Attachment; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttachmentRepository extends JpaRepository { + + List findByPostId(Long postId); +} diff --git a/src/main/java/com/bop/youthpick/board/repository/CommentRepository.java b/src/main/java/com/bop/youthpick/board/repository/CommentRepository.java new file mode 100644 index 0000000..6e3be81 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/repository/CommentRepository.java @@ -0,0 +1,12 @@ +package com.bop.youthpick.board.repository; + +import com.bop.youthpick.board.entity.Comment; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"user", "parent"}) + List findByPostId(Long postId); +} diff --git a/src/main/java/com/bop/youthpick/board/repository/PostRepository.java b/src/main/java/com/bop/youthpick/board/repository/PostRepository.java new file mode 100644 index 0000000..03213aa --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/repository/PostRepository.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.board.repository; + +import com.bop.youthpick.board.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface PostRepository extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java b/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java new file mode 100644 index 0000000..6efd968 --- /dev/null +++ b/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java @@ -0,0 +1,97 @@ +package com.bop.youthpick.board.service; + +import com.bop.youthpick.board.dto.AdminAttachmentResponse; +import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.board.entity.Comment; +import com.bop.youthpick.board.entity.Post; +import com.bop.youthpick.board.exception.BoardErrorCode; +import com.bop.youthpick.board.exception.BoardException; +import com.bop.youthpick.board.repository.AdminCommunityPostSpecifications; +import com.bop.youthpick.board.repository.AttachmentRepository; +import com.bop.youthpick.board.repository.CommentRepository; +import com.bop.youthpick.board.repository.PostRepository; +import jakarta.persistence.criteria.JoinType; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminCommunityService { + + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final AttachmentRepository attachmentRepository; + + @Transactional(readOnly = true) + public Page searchPosts( + String category, + Long authorId, + LocalDate startDate, + LocalDate endDate, + Pageable pageable) { + Specification spec = + AdminCommunityPostSpecifications.filter(category, authorId, startDate, endDate) + .and(fetchUser()); + return postRepository.findAll(spec, pageable).map(AdminCommunityPostResponse::from); + } + + // 목록 페이지당(기본 20건) User 조회 N+1을 막기 위해 작성자를 함께 fetch한다. + // count 쿼리(select type=Long)에는 fetch를 적용하지 않는다 — to-one 연관관계라 row 중복은 없다. + private Specification fetchUser() { + return (root, query, cb) -> { + if (Long.class != query.getResultType()) { + root.fetch("user", JoinType.LEFT); + } + return cb.conjunction(); + }; + } + + @Transactional(readOnly = true) + public List getComments(Long postId) { + findPost(postId); + return commentRepository.findByPostId(postId).stream() + .map(AdminCommunityCommentResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getAttachments(Long postId) { + findPost(postId); + return attachmentRepository.findByPostId(postId).stream() + .map(AdminAttachmentResponse::from) + .toList(); + } + + @Transactional + public AdminCommunityPostResponse deletePost(Long postId) { + Post post = findPost(postId); + post.softDelete(); + return AdminCommunityPostResponse.from(post); + } + + @Transactional + public AdminCommunityCommentResponse deleteComment(Long commentId) { + Comment comment = findComment(commentId); + comment.softDelete(); + return AdminCommunityCommentResponse.from(comment); + } + + private Post findPost(Long postId) { + return postRepository + .findById(postId) + .orElseThrow(() -> new BoardException(BoardErrorCode.POST_NOT_FOUND)); + } + + private Comment findComment(Long commentId) { + return commentRepository + .findById(commentId) + .orElseThrow(() -> new BoardException(BoardErrorCode.COMMENT_NOT_FOUND)); + } +} diff --git a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java new file mode 100644 index 0000000..e5e183d --- /dev/null +++ b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java @@ -0,0 +1,49 @@ +package com.bop.youthpick.board.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.board.exception.BoardErrorCode; +import com.bop.youthpick.board.exception.BoardException; +import com.bop.youthpick.board.service.AdminCommunityService; +import java.time.LocalDateTime; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminCommunityCommentController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminCommunityCommentControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminCommunityService adminCommunityService; + + @Test + void 댓글_삭제하면_200과_deletedAt이_채워진_댓글을_반환한다() throws Exception { + AdminCommunityCommentResponse deleted = + new AdminCommunityCommentResponse( + 2L, 1L, null, "닉네임", "댓글", LocalDateTime.now(), LocalDateTime.now()); + when(adminCommunityService.deleteComment(2L)).thenReturn(deleted); + + mockMvc.perform(delete("/api/v1/admin/community/comments/{commentId}", 2L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedAt").exists()); + } + + @Test + void 삭제_대상이_없으면_404와_B002를_반환한다() throws Exception { + when(adminCommunityService.deleteComment(2L)) + .thenThrow(new BoardException(BoardErrorCode.COMMENT_NOT_FOUND)); + + mockMvc.perform(delete("/api/v1/admin/community/comments/{commentId}", 2L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("B002")); + } +} diff --git a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java new file mode 100644 index 0000000..ec21d9f --- /dev/null +++ b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java @@ -0,0 +1,142 @@ +package com.bop.youthpick.board.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.board.dto.AdminAttachmentResponse; +import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.board.entity.PostCategory; +import com.bop.youthpick.board.exception.BoardErrorCode; +import com.bop.youthpick.board.exception.BoardException; +import com.bop.youthpick.board.service.AdminCommunityService; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminCommunityPostController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminCommunityPostControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminCommunityService adminCommunityService; + + private static final AdminCommunityPostResponse POST_RESPONSE = + new AdminCommunityPostResponse( + 1L, + "제목", + PostCategory.QUESTION, + "내용", + 10L, + "닉네임", + LocalDateTime.now(), + 0, + null); + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = new PageImpl<>(List.of(POST_RESPONSE)); + when(adminCommunityService.searchPosts( + isNull(), isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/community/posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].authorName").value("닉네임")) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void category_필터가_허용값이_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/admin/community/posts").param("category", "NOTICE")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 댓글_조회는_200과_목록을_반환한다() throws Exception { + when(adminCommunityService.getComments(1L)) + .thenReturn( + List.of( + new AdminCommunityCommentResponse( + 2L, 1L, null, "닉네임", "댓글", LocalDateTime.now(), null))); + + mockMvc.perform(get("/api/v1/admin/community/posts/{postId}/comments", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(2)); + } + + @Test + void 댓글_조회_대상_게시글이_없으면_404와_B001을_반환한다() throws Exception { + when(adminCommunityService.getComments(1L)) + .thenThrow(new BoardException(BoardErrorCode.POST_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/admin/community/posts/{postId}/comments", 1L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("B001")); + } + + @Test + void 첨부파일_조회는_200과_목록을_반환한다() throws Exception { + when(adminCommunityService.getAttachments(1L)) + .thenReturn( + List.of( + new AdminAttachmentResponse( + 3L, + 1L, + "https://example.com/file.pdf", + 1024L, + LocalDateTime.now()))); + + mockMvc.perform(get("/api/v1/admin/community/posts/{postId}/attachments", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(3)) + .andExpect(jsonPath("$.data[0].fileKey").value("https://example.com/file.pdf")); + } + + @Test + void 게시글_삭제하면_200과_deletedAt이_채워진_게시글을_반환한다() throws Exception { + AdminCommunityPostResponse deleted = + new AdminCommunityPostResponse( + 1L, + "제목", + PostCategory.QUESTION, + "내용", + 10L, + "닉네임", + LocalDateTime.now(), + 0, + LocalDateTime.now()); + when(adminCommunityService.deletePost(1L)).thenReturn(deleted); + + mockMvc.perform(delete("/api/v1/admin/community/posts/{postId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deletedAt").exists()); + } + + @Test + void 게시글_삭제_대상이_없으면_404와_B001을_반환한다() throws Exception { + when(adminCommunityService.deletePost(eq(1L))) + .thenThrow(new BoardException(BoardErrorCode.POST_NOT_FOUND)); + + mockMvc.perform(delete("/api/v1/admin/community/posts/{postId}", 1L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("B001")); + } +} diff --git a/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java b/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java new file mode 100644 index 0000000..01cdd14 --- /dev/null +++ b/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java @@ -0,0 +1,102 @@ +package com.bop.youthpick.board.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.board.entity.Post; +import com.bop.youthpick.board.entity.PostCategory; +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class AdminCommunityPostSpecificationsTest { + + @Autowired private PostRepository postRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private TestEntityManager entityManager; + + private Long authorId; + + @BeforeEach + void setUp() { + User author = userRepository.save(User.createSocialUser("kakao", "kakao-1", null, "닉네임")); + User otherAuthor = + userRepository.save(User.createSocialUser("naver", "naver-1", null, "다른닉네임")); + authorId = author.getId(); + + savePost(author, PostCategory.QUESTION, "정책 질문", LocalDateTime.of(2026, 3, 15, 9, 0)); + savePost(author, PostCategory.REVIEW, "정책 후기", LocalDateTime.of(2026, 6, 1, 9, 0)); + savePost(otherAuthor, PostCategory.FREE, "잡담", LocalDateTime.of(2026, 6, 10, 9, 0)); + entityManager.clear(); + } + + // createdAt은 @CreatedDate + updatable=false라 auditing이 현재 시각으로 채운 뒤에는 + // JPA로 값을 바꿀 수 없다. 날짜 필터 테스트를 위해 네이티브 쿼리로 직접 덮어쓴다. + private void savePost( + User author, PostCategory category, String title, LocalDateTime createdAt) { + Post post = BeanUtils.instantiateClass(Post.class); + ReflectionTestUtils.setField(post, "user", author); + ReflectionTestUtils.setField(post, "category", category); + ReflectionTestUtils.setField(post, "title", title); + ReflectionTestUtils.setField(post, "content", "내용"); + postRepository.saveAndFlush(post); + + entityManager + .getEntityManager() + .createNativeQuery("UPDATE posts SET created_at = ?1 WHERE id = ?2") + .setParameter(1, createdAt) + .setParameter(2, post.getId()) + .executeUpdate(); + } + + @Test + void category로_필터링한다() { + List result = + postRepository.findAll( + AdminCommunityPostSpecifications.filter("QUESTION", null, null, null)); + + assertThat(result).extracting(Post::getTitle).containsExactly("정책 질문"); + } + + @Test + void authorId로_필터링한다() { + List result = + postRepository.findAll( + AdminCommunityPostSpecifications.filter(null, authorId, null, null)); + + assertThat(result).hasSize(2); + } + + @Test + void createdAt_날짜범위로_필터링한다() { + List result = + postRepository.findAll( + AdminCommunityPostSpecifications.filter( + null, null, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 31))); + + assertThat(result).extracting(Post::getTitle).containsExactly("정책 질문"); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + postRepository.findAll( + AdminCommunityPostSpecifications.filter(null, null, null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java b/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java new file mode 100644 index 0000000..8fe9205 --- /dev/null +++ b/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java @@ -0,0 +1,157 @@ +package com.bop.youthpick.board.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.board.entity.Comment; +import com.bop.youthpick.board.entity.Post; +import com.bop.youthpick.board.exception.BoardErrorCode; +import com.bop.youthpick.board.exception.BoardException; +import com.bop.youthpick.board.repository.AttachmentRepository; +import com.bop.youthpick.board.repository.CommentRepository; +import com.bop.youthpick.board.repository.PostRepository; +import com.bop.youthpick.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminCommunityServiceTest { + + @Mock private PostRepository postRepository; + + @Mock private CommentRepository commentRepository; + + @Mock private AttachmentRepository attachmentRepository; + + private AdminCommunityService adminCommunityService; + + private static final Long POST_ID = 1L; + private static final Long COMMENT_ID = 2L; + + @BeforeEach + void setUp() { + adminCommunityService = + new AdminCommunityService(postRepository, commentRepository, attachmentRepository); + } + + private Post mockPost() { + Post post = mock(Post.class); + User user = mock(User.class); + when(post.getId()).thenReturn(POST_ID); + when(post.getUser()).thenReturn(user); + when(user.getId()).thenReturn(10L); + when(user.getNickname()).thenReturn("닉네임"); + return post; + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { + Page page = new PageImpl<>(List.of(mockPost())); + when(postRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + + Page result = + adminCommunityService.searchPosts("QUESTION", 10L, null, null, Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + } + + @Test + void 댓글_목록을_조회한다() { + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(mock(Post.class))); + Comment comment = mock(Comment.class); + Post post = mock(Post.class); + User user = mock(User.class); + when(comment.getId()).thenReturn(COMMENT_ID); + when(comment.getPost()).thenReturn(post); + when(post.getId()).thenReturn(POST_ID); + when(comment.getUser()).thenReturn(user); + when(user.getNickname()).thenReturn("닉네임"); + when(comment.getContent()).thenReturn("좋은 정책이네요"); + when(commentRepository.findByPostId(POST_ID)).thenReturn(List.of(comment)); + + List result = adminCommunityService.getComments(POST_ID); + + assertThat(result).hasSize(1); + } + + @Test + void 댓글_조회_대상_게시글이_없으면_POST_NOT_FOUND_예외를_던진다() { + when(postRepository.findById(POST_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCommunityService.getComments(POST_ID)) + .isInstanceOf(BoardException.class) + .extracting(ex -> ((BoardException) ex).getErrorCode()) + .isEqualTo(BoardErrorCode.POST_NOT_FOUND); + } + + @Test + void 첨부파일_조회_대상_게시글이_없으면_POST_NOT_FOUND_예외를_던진다() { + when(postRepository.findById(POST_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCommunityService.getAttachments(POST_ID)) + .isInstanceOf(BoardException.class) + .extracting(ex -> ((BoardException) ex).getErrorCode()) + .isEqualTo(BoardErrorCode.POST_NOT_FOUND); + } + + @Test + void 게시글을_soft_delete_한다() { + Post post = mockPost(); + when(postRepository.findById(POST_ID)).thenReturn(Optional.of(post)); + + adminCommunityService.deletePost(POST_ID); + + verify(post).softDelete(); + } + + @Test + void 게시글_삭제_대상이_없으면_POST_NOT_FOUND_예외를_던진다() { + when(postRepository.findById(POST_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCommunityService.deletePost(POST_ID)) + .isInstanceOf(BoardException.class) + .extracting(ex -> ((BoardException) ex).getErrorCode()) + .isEqualTo(BoardErrorCode.POST_NOT_FOUND); + } + + @Test + void 댓글을_soft_delete_한다() { + Comment comment = mock(Comment.class); + Post post = mock(Post.class); + User user = mock(User.class); + when(commentRepository.findById(COMMENT_ID)).thenReturn(Optional.of(comment)); + when(comment.getId()).thenReturn(COMMENT_ID); + when(comment.getPost()).thenReturn(post); + when(post.getId()).thenReturn(POST_ID); + when(comment.getUser()).thenReturn(user); + when(user.getNickname()).thenReturn("닉네임"); + + adminCommunityService.deleteComment(COMMENT_ID); + + verify(comment).softDelete(); + } + + @Test + void 댓글_삭제_대상이_없으면_COMMENT_NOT_FOUND_예외를_던진다() { + when(commentRepository.findById(COMMENT_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminCommunityService.deleteComment(COMMENT_ID)) + .isInstanceOf(BoardException.class) + .extracting(ex -> ((BoardException) ex).getErrorCode()) + .isEqualTo(BoardErrorCode.COMMENT_NOT_FOUND); + } +} From ec04545d74eaff0790788217fc83c0df1848cddf Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 16:30:04 +0900 Subject: [PATCH 21/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 관리자 - 애플리케이션 로그 조회 API 추가 logLevel, keyword(message/traceId/requestUri 부분일치), 기간(createdAt) 필터를 지원하는 읽기 전용 목록 조회를 /api/v1/admin/application-logs에 추가한다. AppLogRepository도 이번 작업에서 함께 만든다. Log4j2 JDBC Appender(#41, 오픈)가 아직 없어 실제 데이터는 비어 있을 수 있다. 프론트 스펙의 로그 레벨 4단계(ERROR/WARN/INFO/DEBUG)를 그대로 허용하되, DEBUG는 현재 문서화된 실제 적재 레벨(INFO/WARN/ERROR)에 없어 당분간 빈 결과만 나올 수 있음을 PR에 명시한다. Closes #54 * refac: 관리자 애플리케이션 로그 API 리뷰 반영 (타임존 고정, 대소문자, 빈값 일관성) - JVM 기본 타임존을 기동 시점에 Asia/Seoul로 고정한다(YouthpickApplication.main). KST 기간 필터와 @CreatedDate가 공유하는 LocalDateTime.now() 기준이 서버 배포 환경의 기본 타임존에 좌우되지 않게 한다. - level 필터를 대소문자 무시 비교로 변경(자유 문자열 컬럼이라 적재값의 대소문자를 보장할 수 없음) - logLevel 빈 문자열을 keyword와 동일하게 "필터 없음"으로 처리(기존엔 400) 선행 와일드카드 LIKE 검색의 인덱스 미사용 지적은 현재 app_logs가 비어 있고(#41 미구현) 스키마 변경(FULLTEXT 인덱스 등)이 필요해 이번에는 반영하지 않는다 — 실제 로그 볼륨이 생기면 별도로 다룬다. 리뷰: kilo-code-bot (PR #55) --- .../bop/youthpick/YouthpickApplication.java | 5 ++ .../log/controller/AdminAppLogController.java | 45 ++++++++++ .../log/dto/ApplicationLogResponse.java | 34 +++++++ .../repository/AdminAppLogSpecifications.java | 61 +++++++++++++ .../log/repository/AppLogRepository.java | 8 ++ .../log/service/AdminAppLogService.java | 32 +++++++ .../controller/AdminAppLogControllerTest.java | 76 ++++++++++++++++ .../AdminAppLogSpecificationsTest.java | 88 +++++++++++++++++++ .../log/service/AdminAppLogServiceTest.java | 46 ++++++++++ 9 files changed, 395 insertions(+) create mode 100644 src/main/java/com/bop/youthpick/log/controller/AdminAppLogController.java create mode 100644 src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java create mode 100644 src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java create mode 100644 src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java create mode 100644 src/main/java/com/bop/youthpick/log/service/AdminAppLogService.java create mode 100644 src/test/java/com/bop/youthpick/log/controller/AdminAppLogControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java diff --git a/src/main/java/com/bop/youthpick/YouthpickApplication.java b/src/main/java/com/bop/youthpick/YouthpickApplication.java index 8e47c9c..ecb4f66 100644 --- a/src/main/java/com/bop/youthpick/YouthpickApplication.java +++ b/src/main/java/com/bop/youthpick/YouthpickApplication.java @@ -1,5 +1,6 @@ package com.bop.youthpick; +import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @@ -9,6 +10,10 @@ public class YouthpickApplication { public static void main(String[] args) { + // JPA auditing(@CreatedDate 등)과 관리자 API의 KST 기간 필터가 타임존 없는 + // LocalDateTime.now()를 공유 기준으로 삼으므로, JVM 기본 타임존을 기동 시점에 + // 고정한다(빈 초기화보다 먼저 실행되도록 SpringApplication.run 이전에 둔다). + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(YouthpickApplication.class, args); } } diff --git a/src/main/java/com/bop/youthpick/log/controller/AdminAppLogController.java b/src/main/java/com/bop/youthpick/log/controller/AdminAppLogController.java new file mode 100644 index 0000000..c6930e3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/controller/AdminAppLogController.java @@ -0,0 +1,45 @@ +package com.bop.youthpick.log.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.log.dto.ApplicationLogResponse; +import com.bop.youthpick.log.service.AdminAppLogService; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/application-logs") +@RequiredArgsConstructor +@Validated +public class AdminAppLogController { + + private final AdminAppLogService adminAppLogService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) + @Pattern( + regexp = "ERROR|WARN|INFO|DEBUG|^$", + message = "logLevel은 ERROR, WARN, INFO, DEBUG 중 하나여야 합니다.") + String logLevel, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminAppLogService.search(logLevel, keyword, startDate, endDate, pageable); + return ApiResponse.ok(page.getContent(), page); + } +} diff --git a/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java b/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java new file mode 100644 index 0000000..f9778b2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java @@ -0,0 +1,34 @@ +package com.bop.youthpick.log.dto; + +import com.bop.youthpick.log.entity.AppLog; +import java.time.LocalDateTime; + +public record ApplicationLogResponse( + Long id, + Long userId, + String logLevel, + String message, + String traceId, + String requestMethod, + String requestUri, + String userIp, + String exceptionClass, + String exceptionMessage, + String stackTrace, + LocalDateTime createdAt) { + public static ApplicationLogResponse from(AppLog appLog) { + return new ApplicationLogResponse( + appLog.getId(), + appLog.getUserId(), + appLog.getLevel(), + appLog.getMessage(), + appLog.getTraceId(), + appLog.getMethod(), + appLog.getUri(), + appLog.getIp(), + appLog.getExceptionClass(), + appLog.getExceptionMessage(), + appLog.getStackTrace(), + appLog.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java b/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java new file mode 100644 index 0000000..c0d0fca --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java @@ -0,0 +1,61 @@ +package com.bop.youthpick.log.repository; + +import com.bop.youthpick.log.entity.AppLog; +import jakarta.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 애플리케이션 로그 목록 조회의 선택적 필터(logLevel/keyword/기간)를 조합한다. */ +public final class AdminAppLogSpecifications { + + private AdminAppLogSpecifications() {} + + public static Specification filter( + String logLevel, String keyword, LocalDate startDate, LocalDate endDate) { + return Specification.allOf( + levelEquals(logLevel), + keywordContains(keyword), + createdBetween(startDate, endDate)); + } + + private static Specification levelEquals(String logLevel) { + if (logLevel == null || logLevel.isBlank()) { + return null; + } + // level은 enum이 아니라 자유 문자열 컬럼이라 적재 값의 대소문자를 보장할 수 없어 대소문자 무시 비교한다. + return (root, query, cb) -> cb.equal(cb.upper(root.get("level")), logLevel.toUpperCase()); + } + + private static Specification keywordContains(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + String pattern = "%" + keyword.toLowerCase() + "%"; + return (root, query, cb) -> + cb.or( + cb.like(cb.lower(root.get("message")), pattern), + cb.like(cb.lower(root.get("traceId")), pattern), + cb.like(cb.lower(root.get("uri")), pattern)); + } + + /** createdAt이 [startDate, endDate] 날짜 범위 안에 있는 로그만 남긴다(KST 벽시계 시각 기준 직접 비교). */ + private static Specification createdBetween(LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + if (startDate != null) { + predicates.add( + cb.greaterThanOrEqualTo(root.get("createdAt"), startDate.atStartOfDay())); + } + if (endDate != null) { + predicates.add( + cb.lessThan(root.get("createdAt"), endDate.plusDays(1).atStartOfDay())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java b/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java new file mode 100644 index 0000000..a97d93b --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java @@ -0,0 +1,8 @@ +package com.bop.youthpick.log.repository; + +import com.bop.youthpick.log.entity.AppLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface AppLogRepository + extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/log/service/AdminAppLogService.java b/src/main/java/com/bop/youthpick/log/service/AdminAppLogService.java new file mode 100644 index 0000000..6047f2d --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/service/AdminAppLogService.java @@ -0,0 +1,32 @@ +package com.bop.youthpick.log.service; + +import com.bop.youthpick.log.dto.ApplicationLogResponse; +import com.bop.youthpick.log.repository.AdminAppLogSpecifications; +import com.bop.youthpick.log.repository.AppLogRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminAppLogService { + + private final AppLogRepository appLogRepository; + + @Transactional(readOnly = true) + public Page search( + String logLevel, + String keyword, + LocalDate startDate, + LocalDate endDate, + Pageable pageable) { + return appLogRepository + .findAll( + AdminAppLogSpecifications.filter(logLevel, keyword, startDate, endDate), + pageable) + .map(ApplicationLogResponse::from); + } +} diff --git a/src/test/java/com/bop/youthpick/log/controller/AdminAppLogControllerTest.java b/src/test/java/com/bop/youthpick/log/controller/AdminAppLogControllerTest.java new file mode 100644 index 0000000..e8d06e4 --- /dev/null +++ b/src/test/java/com/bop/youthpick/log/controller/AdminAppLogControllerTest.java @@ -0,0 +1,76 @@ +package com.bop.youthpick.log.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.log.dto.ApplicationLogResponse; +import com.bop.youthpick.log.service.AdminAppLogService; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminAppLogController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminAppLogControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminAppLogService adminAppLogService; + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = + new PageImpl<>( + List.of( + new ApplicationLogResponse( + 1L, + 10L, + "ERROR", + "timeout", + "trace-1", + "GET", + "/api/v1/policies", + "127.0.0.1", + "java.net.SocketTimeoutException", + "connect timed out", + "stack...", + LocalDateTime.now()))); + when(adminAppLogService.search(isNull(), isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/application-logs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].logLevel").value("ERROR")) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void logLevel_필터가_허용값이_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/admin/application-logs").param("logLevel", "TRACE")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void logLevel이_빈_문자열이면_400이_아니라_필터_없이_조회한다() throws Exception { + when(adminAppLogService.search(eq(""), isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + mockMvc.perform(get("/api/v1/admin/application-logs").param("logLevel", "")) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java new file mode 100644 index 0000000..d07ed08 --- /dev/null +++ b/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java @@ -0,0 +1,88 @@ +package com.bop.youthpick.log.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.log.entity.AppLog; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class AdminAppLogSpecificationsTest { + + @Autowired private AppLogRepository appLogRepository; + + @Autowired private TestEntityManager entityManager; + + @BeforeEach + void setUp() { + saveAppLog("ERROR", "connection timeout", "trace-1", LocalDateTime.of(2026, 3, 15, 9, 0)); + saveAppLog("WARN", "slow query", "trace-2", LocalDateTime.of(2026, 6, 1, 9, 0)); + saveAppLog("INFO", "request completed", "trace-3", LocalDateTime.of(2026, 6, 10, 9, 0)); + } + + // createdAt은 @CreatedDate + updatable=false라 auditing이 채운 뒤에는 JPA로 값을 바꿀 수 없다. + // 날짜 필터 테스트를 위해 네이티브 쿼리로 직접 덮어쓴다. + private void saveAppLog(String level, String message, String traceId, LocalDateTime createdAt) { + AppLog appLog = BeanUtils.instantiateClass(AppLog.class); + ReflectionTestUtils.setField(appLog, "level", level); + ReflectionTestUtils.setField(appLog, "message", message); + ReflectionTestUtils.setField(appLog, "traceId", traceId); + ReflectionTestUtils.setField(appLog, "uri", "/api/v1/policies"); + AppLog saved = appLogRepository.saveAndFlush(appLog); + + entityManager + .getEntityManager() + .createNativeQuery("UPDATE app_logs SET created_at = ?1 WHERE id = ?2") + .setParameter(1, createdAt) + .setParameter(2, saved.getId()) + .executeUpdate(); + entityManager.clear(); + } + + @Test + void logLevel로_필터링한다() { + List result = + appLogRepository.findAll( + AdminAppLogSpecifications.filter("WARN", null, null, null)); + + assertThat(result).extracting(AppLog::getTraceId).containsExactly("trace-2"); + } + + @Test + void keyword로_message_traceId_uri_부분일치를_검색한다() { + List result = + appLogRepository.findAll( + AdminAppLogSpecifications.filter(null, "timeout", null, null)); + + assertThat(result).extracting(AppLog::getTraceId).containsExactly("trace-1"); + } + + @Test + void 날짜범위로_필터링한다() { + List result = + appLogRepository.findAll( + AdminAppLogSpecifications.filter( + null, null, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 31))); + + assertThat(result).extracting(AppLog::getTraceId).containsExactly("trace-1"); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + appLogRepository.findAll(AdminAppLogSpecifications.filter(null, null, null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java b/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java new file mode 100644 index 0000000..4d905d6 --- /dev/null +++ b/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.log.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.log.entity.AppLog; +import com.bop.youthpick.log.repository.AppLogRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminAppLogServiceTest { + + @Mock private AppLogRepository appLogRepository; + + private AdminAppLogService adminAppLogService; + + @BeforeEach + void setUp() { + adminAppLogService = new AdminAppLogService(appLogRepository); + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { + AppLog appLog = mock(AppLog.class); + when(appLog.getId()).thenReturn(1L); + Page page = new PageImpl<>(List.of(appLog)); + when(appLogRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + + Page result = + adminAppLogService.search("ERROR", "timeout", null, null, Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + } +} From fc37de5ae5ce0f03cc548cacfc9cfa0557388479 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 16:30:21 +0900 Subject: [PATCH 22/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EC=9E=91=EC=97=85=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status(BatchStatus 실제 enum 값), 기간(requestedAt) 필터를 지원하는 읽기 전용 목록 조회를 /api/v1/admin/batch-job-logs에 추가한다. PolicyBatchHistoryRepository도 이번 작업에서 함께 만든다. POST .../sync(수동 강제 동기화)는 이번 이슈 범위에서 제외한다 — 실제 온통청년 API 연동/정책 upsert 로직(#15, 오픈)이 없어 만들 수 없다. PolicyBatchHistory에는 프론트 스펙이 요구하는 userId/inputPayload 컬럼도 없고, status는 스펙의 SUCCESS/PARTIAL/FAILED 3단계가 아니라 REQUESTED/RUNNING/SUCCEEDED/FAILED 4단계(요청 생명주기) 모델이다 — PR에 명시한다. Closes #56 --- .../AdminBatchJobLogController.java | 44 ++++++++++++ .../sync/dto/BatchJobLogResponse.java | 25 +++++++ .../AdminBatchJobLogSpecifications.java | 48 +++++++++++++ .../PolicyBatchHistoryRepository.java | 9 +++ .../sync/service/AdminBatchJobLogService.java | 27 +++++++ .../AdminBatchJobLogControllerTest.java | 63 ++++++++++++++++ .../AdminBatchJobLogSpecificationsTest.java | 71 +++++++++++++++++++ .../service/AdminBatchJobLogServiceTest.java | 46 ++++++++++++ 8 files changed, 333 insertions(+) create mode 100644 src/main/java/com/bop/youthpick/sync/controller/AdminBatchJobLogController.java create mode 100644 src/main/java/com/bop/youthpick/sync/dto/BatchJobLogResponse.java create mode 100644 src/main/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecifications.java create mode 100644 src/main/java/com/bop/youthpick/sync/repository/PolicyBatchHistoryRepository.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/AdminBatchJobLogService.java create mode 100644 src/test/java/com/bop/youthpick/sync/controller/AdminBatchJobLogControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecificationsTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/AdminBatchJobLogServiceTest.java diff --git a/src/main/java/com/bop/youthpick/sync/controller/AdminBatchJobLogController.java b/src/main/java/com/bop/youthpick/sync/controller/AdminBatchJobLogController.java new file mode 100644 index 0000000..48e2dc9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/controller/AdminBatchJobLogController.java @@ -0,0 +1,44 @@ +package com.bop.youthpick.sync.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.sync.service.AdminBatchJobLogService; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/batch-job-logs") +@RequiredArgsConstructor +@Validated +public class AdminBatchJobLogController { + + private final AdminBatchJobLogService adminBatchJobLogService; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) + @Pattern( + regexp = "REQUESTED|RUNNING|SUCCEEDED|FAILED", + message = "status는 REQUESTED, RUNNING, SUCCEEDED, FAILED 중 하나여야 합니다.") + String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + adminBatchJobLogService.search(status, startDate, endDate, pageable); + return ApiResponse.ok(page.getContent(), page); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/dto/BatchJobLogResponse.java b/src/main/java/com/bop/youthpick/sync/dto/BatchJobLogResponse.java new file mode 100644 index 0000000..daaada1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/dto/BatchJobLogResponse.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.sync.dto; + +import com.bop.youthpick.sync.entity.BatchStatus; +import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import java.time.LocalDateTime; + +public record BatchJobLogResponse( + Long id, + BatchStatus status, + LocalDateTime executedAt, + int createdPolicyCount, + int updatedPolicyCount, + int disappearedPolicyCount, + int failedPolicyCount) { + public static BatchJobLogResponse from(PolicyBatchHistory history) { + return new BatchJobLogResponse( + history.getId(), + history.getStatus(), + history.getRequestedAt(), + history.getNewCount(), + history.getUpdatedCount(), + history.getMissingCount(), + history.getErrorCount()); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecifications.java b/src/main/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecifications.java new file mode 100644 index 0000000..619eb0a --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecifications.java @@ -0,0 +1,48 @@ +package com.bop.youthpick.sync.repository; + +import com.bop.youthpick.sync.entity.BatchStatus; +import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import jakarta.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +/** 관리자 배치 작업 로그 목록 조회의 선택적 필터(status/기간)를 조합한다. */ +public final class AdminBatchJobLogSpecifications { + + private AdminBatchJobLogSpecifications() {} + + public static Specification filter( + String status, LocalDate startDate, LocalDate endDate) { + return Specification.allOf(statusEquals(status), requestedBetween(startDate, endDate)); + } + + private static Specification statusEquals(String status) { + if (status == null || status.isBlank()) { + return null; + } + BatchStatus value = BatchStatus.valueOf(status); + return (root, query, cb) -> cb.equal(root.get("status"), value); + } + + /** requestedAt이 [startDate, endDate] 날짜 범위 안에 있는 로그만 남긴다(KST 벽시계 시각 기준 직접 비교). */ + private static Specification requestedBetween( + LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + if (startDate != null) { + predicates.add( + cb.greaterThanOrEqualTo(root.get("requestedAt"), startDate.atStartOfDay())); + } + if (endDate != null) { + predicates.add( + cb.lessThan(root.get("requestedAt"), endDate.plusDays(1).atStartOfDay())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/bop/youthpick/sync/repository/PolicyBatchHistoryRepository.java b/src/main/java/com/bop/youthpick/sync/repository/PolicyBatchHistoryRepository.java new file mode 100644 index 0000000..a9d9d59 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/repository/PolicyBatchHistoryRepository.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.sync.repository; + +import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface PolicyBatchHistoryRepository + extends JpaRepository, + JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/sync/service/AdminBatchJobLogService.java b/src/main/java/com/bop/youthpick/sync/service/AdminBatchJobLogService.java new file mode 100644 index 0000000..6611a8a --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/AdminBatchJobLogService.java @@ -0,0 +1,27 @@ +package com.bop.youthpick.sync.service; + +import com.bop.youthpick.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.sync.repository.AdminBatchJobLogSpecifications; +import com.bop.youthpick.sync.repository.PolicyBatchHistoryRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminBatchJobLogService { + + private final PolicyBatchHistoryRepository policyBatchHistoryRepository; + + @Transactional(readOnly = true) + public Page search( + String status, LocalDate startDate, LocalDate endDate, Pageable pageable) { + return policyBatchHistoryRepository + .findAll( + AdminBatchJobLogSpecifications.filter(status, startDate, endDate), pageable) + .map(BatchJobLogResponse::from); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/controller/AdminBatchJobLogControllerTest.java b/src/test/java/com/bop/youthpick/sync/controller/AdminBatchJobLogControllerTest.java new file mode 100644 index 0000000..944b8d6 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/controller/AdminBatchJobLogControllerTest.java @@ -0,0 +1,63 @@ +package com.bop.youthpick.sync.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.sync.entity.BatchStatus; +import com.bop.youthpick.sync.service.AdminBatchJobLogService; +import java.time.LocalDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminBatchJobLogController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminBatchJobLogControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminBatchJobLogService adminBatchJobLogService; + + @Test + void 목록_조회는_200과_페이지_데이터를_반환한다() throws Exception { + Page page = + new PageImpl<>( + List.of( + new BatchJobLogResponse( + 1L, + BatchStatus.SUCCEEDED, + LocalDateTime.now(), + 10, + 5, + 2, + 0))); + when(adminBatchJobLogService.search(isNull(), isNull(), isNull(), any(Pageable.class))) + .thenReturn(page); + + mockMvc.perform(get("/api/v1/admin/batch-job-logs")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(1)) + .andExpect(jsonPath("$.data[0].status").value("SUCCEEDED")) + .andExpect(jsonPath("$.data[0].createdPolicyCount").value(10)) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void status_필터가_허용값이_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/admin/batch-job-logs").param("status", "PARTIAL")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecificationsTest.java new file mode 100644 index 0000000..5ad2d22 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecificationsTest.java @@ -0,0 +1,71 @@ +package com.bop.youthpick.sync.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.sync.entity.BatchMode; +import com.bop.youthpick.sync.entity.BatchStatus; +import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +class AdminBatchJobLogSpecificationsTest { + + @Autowired private PolicyBatchHistoryRepository policyBatchHistoryRepository; + + @BeforeEach + void setUp() { + policyBatchHistoryRepository.save( + newHistory(BatchStatus.SUCCEEDED, LocalDateTime.of(2026, 3, 15, 9, 0))); + policyBatchHistoryRepository.save( + newHistory(BatchStatus.FAILED, LocalDateTime.of(2026, 6, 1, 9, 0))); + policyBatchHistoryRepository.save( + newHistory(BatchStatus.SUCCEEDED, LocalDateTime.of(2026, 6, 10, 9, 0))); + } + + private PolicyBatchHistory newHistory(BatchStatus status, LocalDateTime requestedAt) { + PolicyBatchHistory history = BeanUtils.instantiateClass(PolicyBatchHistory.class); + ReflectionTestUtils.setField(history, "mode", BatchMode.FULL); + ReflectionTestUtils.setField(history, "status", status); + ReflectionTestUtils.setField(history, "requestedAt", requestedAt); + return history; + } + + @Test + void status로_필터링한다() { + List result = + policyBatchHistoryRepository.findAll( + AdminBatchJobLogSpecifications.filter("FAILED", null, null)); + + assertThat(result) + .extracting(PolicyBatchHistory::getStatus) + .containsExactly(BatchStatus.FAILED); + } + + @Test + void 날짜범위로_필터링한다() { + List result = + policyBatchHistoryRepository.findAll( + AdminBatchJobLogSpecifications.filter( + null, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 31))); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getStatus()).isEqualTo(BatchStatus.SUCCEEDED); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = + policyBatchHistoryRepository.findAll( + AdminBatchJobLogSpecifications.filter(null, null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/AdminBatchJobLogServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/AdminBatchJobLogServiceTest.java new file mode 100644 index 0000000..f7a1912 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/AdminBatchJobLogServiceTest.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.sync.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import com.bop.youthpick.sync.repository.PolicyBatchHistoryRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminBatchJobLogServiceTest { + + @Mock private PolicyBatchHistoryRepository policyBatchHistoryRepository; + + private AdminBatchJobLogService adminBatchJobLogService; + + @BeforeEach + void setUp() { + adminBatchJobLogService = new AdminBatchJobLogService(policyBatchHistoryRepository); + } + + @Test + void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { + PolicyBatchHistory history = mock(PolicyBatchHistory.class); + when(history.getId()).thenReturn(1L); + Page page = new PageImpl<>(List.of(history)); + when(policyBatchHistoryRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(page); + + Page result = + adminBatchJobLogService.search("SUCCEEDED", null, null, Pageable.unpaged()); + + assertThat(result.getContent()).hasSize(1); + } +} From 402a020f10c854977f4b495193934b1b420d4f5d Mon Sep 17 00:00:00 2001 From: myh7754 Date: Wed, 15 Jul 2026 16:40:13 +0900 Subject: [PATCH 23/93] =?UTF-8?q?chore:=20regions=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=8B=9C=EB=93=9C=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(V3)=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: regions 지역 마스터 시드 마이그레이션 추가 (V3) Co-Authored-By: Claude Fable 5 * chore: Flyway 폴더를 migration(DDL)/seed(데이터)로 분리 리뷰 피드백 반영 — 스키마 변경(db/migration)과 데이터 적재(db/seed)를 폴더로 분리하고 spring.flyway.locations에 두 경로를 명시한다. 버전(V{n})은 두 폴더에 걸쳐 하나의 순서를 공유한다. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017NGhwM7GG2Zacc8DYr5PqA --------- Co-authored-by: Claude Fable 5 --- src/main/resources/application.yml | 4 + .../resources/db/seed/V3__seed_regions.sql | 312 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 src/main/resources/db/seed/V3__seed_regions.sql diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ae2462f..9d99f58 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,10 @@ spring: # 기본 비활성(안전 기본값) — MySQL을 쓰는 프로필(local, 추후 prod)이 각자 true로 켠다. # test는 H2 + ddl-auto(create-drop)라 꺼진 기본값을 그대로 쓴다. enabled: false + # DDL(migration)과 데이터 적재(seed)를 폴더로 분리. 버전(V{n})은 두 폴더에 걸쳐 하나의 순서를 공유한다. + locations: + - classpath:db/migration + - classpath:db/seed http: client: # OAuthClient 등 자동구성된 RestClient.Builder에 전역 적용. provider가 응답이 없을 때 diff --git a/src/main/resources/db/seed/V3__seed_regions.sql b/src/main/resources/db/seed/V3__seed_regions.sql new file mode 100644 index 0000000..4c151bb --- /dev/null +++ b/src/main/resources/db/seed/V3__seed_regions.sql @@ -0,0 +1,312 @@ +-- ------------------------------------------------------------ +-- V3: regions 지역 마스터 시드 (#35) +-- +-- 소스: docs/법정동코드 전체자료 (현행 법정동코드, 폐지 제외) +-- - 시군구 레벨(코드 뒤 5자리 = 00000) + 시도 레벨(XX000) 상위 5자리 추출 +-- - 시도 레벨 포함 이유: 온통청년 zipCd 명세 예시에 시도 단위 코드(11000 등)가 있어 방어적 포함 +-- 검증: 온통청년 API 실측 '전국' 정책의 zipCd 256개 코드가 전부 포함됨을 확인 +-- - 12xxx = 전남광주통합특별시 (舊 광주 29xxx / 전남 46xxx는 폐지 코드라 제외됨) +-- 행정구역 개편 시: 기존 파일 수정이 아니라 새 V{n} 마이그레이션으로 반영한다. +-- ------------------------------------------------------------ + +INSERT INTO regions (code, sido_name, name) VALUES +-- 서울특별시 +('11000', '서울특별시', '서울특별시'), +('11110', '서울특별시', '종로구'), +('11140', '서울특별시', '중구'), +('11170', '서울특별시', '용산구'), +('11200', '서울특별시', '성동구'), +('11215', '서울특별시', '광진구'), +('11230', '서울특별시', '동대문구'), +('11260', '서울특별시', '중랑구'), +('11290', '서울특별시', '성북구'), +('11305', '서울특별시', '강북구'), +('11320', '서울특별시', '도봉구'), +('11350', '서울특별시', '노원구'), +('11380', '서울특별시', '은평구'), +('11410', '서울특별시', '서대문구'), +('11440', '서울특별시', '마포구'), +('11470', '서울특별시', '양천구'), +('11500', '서울특별시', '강서구'), +('11530', '서울특별시', '구로구'), +('11545', '서울특별시', '금천구'), +('11560', '서울특별시', '영등포구'), +('11590', '서울특별시', '동작구'), +('11620', '서울특별시', '관악구'), +('11650', '서울특별시', '서초구'), +('11680', '서울특별시', '강남구'), +('11710', '서울특별시', '송파구'), +('11740', '서울특별시', '강동구'), +-- 전남광주통합특별시 +('12000', '전남광주통합특별시', '전남광주통합특별시'), +('12110', '전남광주통합특별시', '목포시'), +('12130', '전남광주통합특별시', '여수시'), +('12150', '전남광주통합특별시', '순천시'), +('12170', '전남광주통합특별시', '나주시'), +('12190', '전남광주통합특별시', '광양시'), +('12210', '전남광주통합특별시', '동구'), +('12240', '전남광주통합특별시', '서구'), +('12270', '전남광주통합특별시', '남구'), +('12300', '전남광주통합특별시', '북구'), +('12330', '전남광주통합특별시', '광산구'), +('12710', '전남광주통합특별시', '담양군'), +('12720', '전남광주통합특별시', '곡성군'), +('12730', '전남광주통합특별시', '구례군'), +('12740', '전남광주통합특별시', '고흥군'), +('12750', '전남광주통합특별시', '보성군'), +('12760', '전남광주통합특별시', '화순군'), +('12770', '전남광주통합특별시', '장흥군'), +('12780', '전남광주통합특별시', '강진군'), +('12790', '전남광주통합특별시', '해남군'), +('12800', '전남광주통합특별시', '영암군'), +('12810', '전남광주통합특별시', '무안군'), +('12820', '전남광주통합특별시', '함평군'), +('12830', '전남광주통합특별시', '영광군'), +('12840', '전남광주통합특별시', '장성군'), +('12850', '전남광주통합특별시', '완도군'), +('12860', '전남광주통합특별시', '진도군'), +('12870', '전남광주통합특별시', '신안군'), +-- 부산광역시 +('26000', '부산광역시', '부산광역시'), +('26110', '부산광역시', '중구'), +('26140', '부산광역시', '서구'), +('26170', '부산광역시', '동구'), +('26200', '부산광역시', '영도구'), +('26230', '부산광역시', '부산진구'), +('26260', '부산광역시', '동래구'), +('26290', '부산광역시', '남구'), +('26320', '부산광역시', '북구'), +('26350', '부산광역시', '해운대구'), +('26380', '부산광역시', '사하구'), +('26410', '부산광역시', '금정구'), +('26440', '부산광역시', '강서구'), +('26470', '부산광역시', '연제구'), +('26500', '부산광역시', '수영구'), +('26530', '부산광역시', '사상구'), +('26710', '부산광역시', '기장군'), +-- 대구광역시 +('27000', '대구광역시', '대구광역시'), +('27110', '대구광역시', '중구'), +('27140', '대구광역시', '동구'), +('27170', '대구광역시', '서구'), +('27200', '대구광역시', '남구'), +('27230', '대구광역시', '북구'), +('27260', '대구광역시', '수성구'), +('27290', '대구광역시', '달서구'), +('27710', '대구광역시', '달성군'), +('27720', '대구광역시', '군위군'), +-- 인천광역시 +('28000', '인천광역시', '인천광역시'), +('28125', '인천광역시', '제물포구'), +('28155', '인천광역시', '영종구'), +('28177', '인천광역시', '미추홀구'), +('28185', '인천광역시', '연수구'), +('28200', '인천광역시', '남동구'), +('28237', '인천광역시', '부평구'), +('28245', '인천광역시', '계양구'), +('28275', '인천광역시', '서해구'), +('28290', '인천광역시', '검단구'), +('28710', '인천광역시', '강화군'), +('28720', '인천광역시', '옹진군'), +-- 대전광역시 +('30000', '대전광역시', '대전광역시'), +('30110', '대전광역시', '동구'), +('30140', '대전광역시', '중구'), +('30170', '대전광역시', '서구'), +('30200', '대전광역시', '유성구'), +('30230', '대전광역시', '대덕구'), +-- 울산광역시 +('31000', '울산광역시', '울산광역시'), +('31110', '울산광역시', '중구'), +('31140', '울산광역시', '남구'), +('31170', '울산광역시', '동구'), +('31200', '울산광역시', '북구'), +('31710', '울산광역시', '울주군'), +-- 세종특별자치시 +('36110', '세종특별자치시', '세종특별자치시'), +-- 경기도 +('41000', '경기도', '경기도'), +('41110', '경기도', '수원시'), +('41111', '경기도', '수원시 장안구'), +('41113', '경기도', '수원시 권선구'), +('41115', '경기도', '수원시 팔달구'), +('41117', '경기도', '수원시 영통구'), +('41130', '경기도', '성남시'), +('41131', '경기도', '성남시 수정구'), +('41133', '경기도', '성남시 중원구'), +('41135', '경기도', '성남시 분당구'), +('41150', '경기도', '의정부시'), +('41170', '경기도', '안양시'), +('41171', '경기도', '안양시 만안구'), +('41173', '경기도', '안양시 동안구'), +('41190', '경기도', '부천시'), +('41192', '경기도', '부천시 원미구'), +('41194', '경기도', '부천시 소사구'), +('41196', '경기도', '부천시 오정구'), +('41210', '경기도', '광명시'), +('41220', '경기도', '평택시'), +('41250', '경기도', '동두천시'), +('41270', '경기도', '안산시'), +('41271', '경기도', '안산시 상록구'), +('41273', '경기도', '안산시 단원구'), +('41280', '경기도', '고양시'), +('41281', '경기도', '고양시 덕양구'), +('41285', '경기도', '고양시 일산동구'), +('41287', '경기도', '고양시 일산서구'), +('41290', '경기도', '과천시'), +('41310', '경기도', '구리시'), +('41360', '경기도', '남양주시'), +('41370', '경기도', '오산시'), +('41390', '경기도', '시흥시'), +('41410', '경기도', '군포시'), +('41430', '경기도', '의왕시'), +('41450', '경기도', '하남시'), +('41460', '경기도', '용인시'), +('41461', '경기도', '용인시 처인구'), +('41463', '경기도', '용인시 기흥구'), +('41465', '경기도', '용인시 수지구'), +('41480', '경기도', '파주시'), +('41500', '경기도', '이천시'), +('41550', '경기도', '안성시'), +('41570', '경기도', '김포시'), +('41590', '경기도', '화성시'), +('41591', '경기도', '화성시 만세구'), +('41593', '경기도', '화성시 효행구'), +('41595', '경기도', '화성시 병점구'), +('41597', '경기도', '화성시 동탄구'), +('41610', '경기도', '광주시'), +('41630', '경기도', '양주시'), +('41650', '경기도', '포천시'), +('41670', '경기도', '여주시'), +('41800', '경기도', '연천군'), +('41820', '경기도', '가평군'), +('41830', '경기도', '양평군'), +-- 충청북도 +('43000', '충청북도', '충청북도'), +('43110', '충청북도', '청주시'), +('43111', '충청북도', '청주시 상당구'), +('43112', '충청북도', '청주시 서원구'), +('43113', '충청북도', '청주시 흥덕구'), +('43114', '충청북도', '청주시 청원구'), +('43130', '충청북도', '충주시'), +('43150', '충청북도', '제천시'), +('43720', '충청북도', '보은군'), +('43730', '충청북도', '옥천군'), +('43740', '충청북도', '영동군'), +('43745', '충청북도', '증평군'), +('43750', '충청북도', '진천군'), +('43760', '충청북도', '괴산군'), +('43770', '충청북도', '음성군'), +('43800', '충청북도', '단양군'), +-- 충청남도 +('44000', '충청남도', '충청남도'), +('44130', '충청남도', '천안시'), +('44131', '충청남도', '천안시 동남구'), +('44133', '충청남도', '천안시 서북구'), +('44150', '충청남도', '공주시'), +('44180', '충청남도', '보령시'), +('44200', '충청남도', '아산시'), +('44210', '충청남도', '서산시'), +('44230', '충청남도', '논산시'), +('44250', '충청남도', '계룡시'), +('44270', '충청남도', '당진시'), +('44710', '충청남도', '금산군'), +('44760', '충청남도', '부여군'), +('44770', '충청남도', '서천군'), +('44790', '충청남도', '청양군'), +('44800', '충청남도', '홍성군'), +('44810', '충청남도', '예산군'), +('44825', '충청남도', '태안군'), +-- 경상북도 +('47000', '경상북도', '경상북도'), +('47110', '경상북도', '포항시'), +('47111', '경상북도', '포항시 남구'), +('47113', '경상북도', '포항시 북구'), +('47130', '경상북도', '경주시'), +('47150', '경상북도', '김천시'), +('47170', '경상북도', '안동시'), +('47190', '경상북도', '구미시'), +('47210', '경상북도', '영주시'), +('47230', '경상북도', '영천시'), +('47250', '경상북도', '상주시'), +('47280', '경상북도', '문경시'), +('47290', '경상북도', '경산시'), +('47730', '경상북도', '의성군'), +('47750', '경상북도', '청송군'), +('47760', '경상북도', '영양군'), +('47770', '경상북도', '영덕군'), +('47820', '경상북도', '청도군'), +('47830', '경상북도', '고령군'), +('47840', '경상북도', '성주군'), +('47850', '경상북도', '칠곡군'), +('47900', '경상북도', '예천군'), +('47920', '경상북도', '봉화군'), +('47930', '경상북도', '울진군'), +('47940', '경상북도', '울릉군'), +-- 경상남도 +('48000', '경상남도', '경상남도'), +('48120', '경상남도', '창원시'), +('48121', '경상남도', '창원시 의창구'), +('48123', '경상남도', '창원시 성산구'), +('48125', '경상남도', '창원시 마산합포구'), +('48127', '경상남도', '창원시 마산회원구'), +('48129', '경상남도', '창원시 진해구'), +('48170', '경상남도', '진주시'), +('48220', '경상남도', '통영시'), +('48240', '경상남도', '사천시'), +('48250', '경상남도', '김해시'), +('48270', '경상남도', '밀양시'), +('48310', '경상남도', '거제시'), +('48330', '경상남도', '양산시'), +('48720', '경상남도', '의령군'), +('48730', '경상남도', '함안군'), +('48740', '경상남도', '창녕군'), +('48820', '경상남도', '고성군'), +('48840', '경상남도', '남해군'), +('48850', '경상남도', '하동군'), +('48860', '경상남도', '산청군'), +('48870', '경상남도', '함양군'), +('48880', '경상남도', '거창군'), +('48890', '경상남도', '합천군'), +-- 제주특별자치도 +('50000', '제주특별자치도', '제주특별자치도'), +('50110', '제주특별자치도', '제주시'), +('50130', '제주특별자치도', '서귀포시'), +-- 강원특별자치도 +('51000', '강원특별자치도', '강원특별자치도'), +('51110', '강원특별자치도', '춘천시'), +('51130', '강원특별자치도', '원주시'), +('51150', '강원특별자치도', '강릉시'), +('51170', '강원특별자치도', '동해시'), +('51190', '강원특별자치도', '태백시'), +('51210', '강원특별자치도', '속초시'), +('51230', '강원특별자치도', '삼척시'), +('51720', '강원특별자치도', '홍천군'), +('51730', '강원특별자치도', '횡성군'), +('51750', '강원특별자치도', '영월군'), +('51760', '강원특별자치도', '평창군'), +('51770', '강원특별자치도', '정선군'), +('51780', '강원특별자치도', '철원군'), +('51790', '강원특별자치도', '화천군'), +('51800', '강원특별자치도', '양구군'), +('51810', '강원특별자치도', '인제군'), +('51820', '강원특별자치도', '고성군'), +('51830', '강원특별자치도', '양양군'), +-- 전북특별자치도 +('52000', '전북특별자치도', '전북특별자치도'), +('52110', '전북특별자치도', '전주시'), +('52111', '전북특별자치도', '전주시 완산구'), +('52113', '전북특별자치도', '전주시 덕진구'), +('52130', '전북특별자치도', '군산시'), +('52140', '전북특별자치도', '익산시'), +('52180', '전북특별자치도', '정읍시'), +('52190', '전북특별자치도', '남원시'), +('52210', '전북특별자치도', '김제시'), +('52710', '전북특별자치도', '완주군'), +('52720', '전북특별자치도', '진안군'), +('52730', '전북특별자치도', '무주군'), +('52740', '전북특별자치도', '장수군'), +('52750', '전북특별자치도', '임실군'), +('52770', '전북특별자치도', '순창군'), +('52790', '전북특별자치도', '고창군'), +('52800', '전북특별자치도', '부안군'); From 58bacf77cef694f5b44b34aba6745348c4d3fc68 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 16:44:17 +0900 Subject: [PATCH 24/93] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20API=EC=9D=98=20AppLog=E2=86=92ApplicationL?= =?UTF-8?q?og=20=EB=A6=AC=EB=84=A4=EC=9E=84=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #42에서 로그 엔티티를 AppLog에서 ApplicationLog로 리네임했지만, 이후 #55에서 추가된 관리자 애플리케이션 로그 조회 API 파일들이 옛 이름을 계속 참조해 컴파일이 실패했다. 타입 참조를 ApplicationLog로 맞추고, 테스트의 네이티브 쿼리가 참조하던 옛 테이블명(app_logs)도 실제 테이블명(application_logs)으로 수정했다. Closes #61 Co-authored-by: Claude Sonnet 5 --- .../log/dto/ApplicationLogResponse.java | 4 ++-- .../repository/AdminAppLogSpecifications.java | 11 +++++----- .../log/repository/AppLogRepository.java | 4 ++-- .../AdminAppLogSpecificationsTest.java | 22 +++++++++---------- .../log/service/AdminAppLogServiceTest.java | 6 ++--- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java b/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java index f9778b2..d944f6d 100644 --- a/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java +++ b/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java @@ -1,6 +1,6 @@ package com.bop.youthpick.log.dto; -import com.bop.youthpick.log.entity.AppLog; +import com.bop.youthpick.log.entity.ApplicationLog; import java.time.LocalDateTime; public record ApplicationLogResponse( @@ -16,7 +16,7 @@ public record ApplicationLogResponse( String exceptionMessage, String stackTrace, LocalDateTime createdAt) { - public static ApplicationLogResponse from(AppLog appLog) { + public static ApplicationLogResponse from(ApplicationLog appLog) { return new ApplicationLogResponse( appLog.getId(), appLog.getUserId(), diff --git a/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java b/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java index c0d0fca..d870269 100644 --- a/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java +++ b/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java @@ -1,6 +1,6 @@ package com.bop.youthpick.log.repository; -import com.bop.youthpick.log.entity.AppLog; +import com.bop.youthpick.log.entity.ApplicationLog; import jakarta.persistence.criteria.Predicate; import java.time.LocalDate; import java.util.ArrayList; @@ -12,7 +12,7 @@ public final class AdminAppLogSpecifications { private AdminAppLogSpecifications() {} - public static Specification filter( + public static Specification filter( String logLevel, String keyword, LocalDate startDate, LocalDate endDate) { return Specification.allOf( levelEquals(logLevel), @@ -20,7 +20,7 @@ public static Specification filter( createdBetween(startDate, endDate)); } - private static Specification levelEquals(String logLevel) { + private static Specification levelEquals(String logLevel) { if (logLevel == null || logLevel.isBlank()) { return null; } @@ -28,7 +28,7 @@ private static Specification levelEquals(String logLevel) { return (root, query, cb) -> cb.equal(cb.upper(root.get("level")), logLevel.toUpperCase()); } - private static Specification keywordContains(String keyword) { + private static Specification keywordContains(String keyword) { if (keyword == null || keyword.isBlank()) { return null; } @@ -41,7 +41,8 @@ private static Specification keywordContains(String keyword) { } /** createdAt이 [startDate, endDate] 날짜 범위 안에 있는 로그만 남긴다(KST 벽시계 시각 기준 직접 비교). */ - private static Specification createdBetween(LocalDate startDate, LocalDate endDate) { + private static Specification createdBetween( + LocalDate startDate, LocalDate endDate) { if (startDate == null && endDate == null) { return null; } diff --git a/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java b/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java index a97d93b..23a2844 100644 --- a/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java +++ b/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java @@ -1,8 +1,8 @@ package com.bop.youthpick.log.repository; -import com.bop.youthpick.log.entity.AppLog; +import com.bop.youthpick.log.entity.ApplicationLog; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface AppLogRepository - extends JpaRepository, JpaSpecificationExecutor {} + extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java index d07ed08..6b13a8a 100644 --- a/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; -import com.bop.youthpick.log.entity.AppLog; +import com.bop.youthpick.log.entity.ApplicationLog; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -34,16 +34,16 @@ void setUp() { // createdAt은 @CreatedDate + updatable=false라 auditing이 채운 뒤에는 JPA로 값을 바꿀 수 없다. // 날짜 필터 테스트를 위해 네이티브 쿼리로 직접 덮어쓴다. private void saveAppLog(String level, String message, String traceId, LocalDateTime createdAt) { - AppLog appLog = BeanUtils.instantiateClass(AppLog.class); + ApplicationLog appLog = BeanUtils.instantiateClass(ApplicationLog.class); ReflectionTestUtils.setField(appLog, "level", level); ReflectionTestUtils.setField(appLog, "message", message); ReflectionTestUtils.setField(appLog, "traceId", traceId); ReflectionTestUtils.setField(appLog, "uri", "/api/v1/policies"); - AppLog saved = appLogRepository.saveAndFlush(appLog); + ApplicationLog saved = appLogRepository.saveAndFlush(appLog); entityManager .getEntityManager() - .createNativeQuery("UPDATE app_logs SET created_at = ?1 WHERE id = ?2") + .createNativeQuery("UPDATE application_logs SET created_at = ?1 WHERE id = ?2") .setParameter(1, createdAt) .setParameter(2, saved.getId()) .executeUpdate(); @@ -52,35 +52,35 @@ private void saveAppLog(String level, String message, String traceId, LocalDateT @Test void logLevel로_필터링한다() { - List result = + List result = appLogRepository.findAll( AdminAppLogSpecifications.filter("WARN", null, null, null)); - assertThat(result).extracting(AppLog::getTraceId).containsExactly("trace-2"); + assertThat(result).extracting(ApplicationLog::getTraceId).containsExactly("trace-2"); } @Test void keyword로_message_traceId_uri_부분일치를_검색한다() { - List result = + List result = appLogRepository.findAll( AdminAppLogSpecifications.filter(null, "timeout", null, null)); - assertThat(result).extracting(AppLog::getTraceId).containsExactly("trace-1"); + assertThat(result).extracting(ApplicationLog::getTraceId).containsExactly("trace-1"); } @Test void 날짜범위로_필터링한다() { - List result = + List result = appLogRepository.findAll( AdminAppLogSpecifications.filter( null, null, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 31))); - assertThat(result).extracting(AppLog::getTraceId).containsExactly("trace-1"); + assertThat(result).extracting(ApplicationLog::getTraceId).containsExactly("trace-1"); } @Test void 필터가_없으면_전체를_반환한다() { - List result = + List result = appLogRepository.findAll(AdminAppLogSpecifications.filter(null, null, null, null)); assertThat(result).hasSize(3); diff --git a/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java b/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java index 4d905d6..c773145 100644 --- a/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java +++ b/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java @@ -5,7 +5,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.bop.youthpick.log.entity.AppLog; +import com.bop.youthpick.log.entity.ApplicationLog; import com.bop.youthpick.log.repository.AppLogRepository; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -32,9 +32,9 @@ void setUp() { @Test void 목록_조회는_필터를_Specification으로_넘겨_페이지를_반환한다() { - AppLog appLog = mock(AppLog.class); + ApplicationLog appLog = mock(ApplicationLog.class); when(appLog.getId()).thenReturn(1L); - Page page = new PageImpl<>(List.of(appLog)); + Page page = new PageImpl<>(List.of(appLog)); when(appLogRepository.findAll(any(Specification.class), any(Pageable.class))) .thenReturn(page); From 812259778aaec64fdd159e9e7f765f92f8c0ba31 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 17:45:47 +0900 Subject: [PATCH 25/93] =?UTF-8?q?fix:=20Flyway=20V4=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EC=86=8C=20(=EC=A0=95=EC=B1=85?= =?UTF-8?q?=20soft=20delete=20=E2=86=92=20V6)=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Flyway V4 마이그레이션 버전 충돌 해소 (정책 soft delete → V6) #42(로그 rename)와 #47(정책/지역 관리 API)가 거의 동시에 merge되며 두 PR이 각각 V4 버전의 마이그레이션을 추가해, 로컬 MySQL(Flyway)로 앱을 기동하면 "Found more than one migration with version 4"로 시작이 실패했다. 테스트는 H2 + create-drop으로 Flyway 자체가 꺼져 있어 잡히지 않는 문제였다. 로컬 flyway_schema_history를 확인해 V4__rename_app_logs_to_application_logs만 이미 적용된 것을 확인하고, V4__add_policies_deleted_at을 다음 빈 버전인 V6으로 재넘버링했다. Closes #63 Co-Authored-By: Claude Sonnet 5 * refac: 마이그레이션 버전 재정렬로 V3 공백 제거 V3가 비어있던 이유는 로그 rename 마이그레이션이 애초 V3로 계획됐다가 V4로 바뀐 이력 때문이었다. V4(rename)→V3, V5(login_histories)→V4, V6(policies.deleted_at)→V5로 한 칸씩 당겨 공백 없이 연속되게 정리했다. 로컬 MySQL 볼륨은 이미 옛 버전 번호(V4/V5/V6)로 마이그레이션 이력이 쌓여있어 파일명 변경과 충돌하므로 docker compose down -v로 초기화했다. --------- Co-authored-by: Claude Sonnet 5 --- ...ion_logs.sql => V3__rename_app_logs_to_application_logs.sql} | 0 ...reate_login_histories.sql => V4__create_login_histories.sql} | 2 +- ..._policies_deleted_at.sql => V5__add_policies_deleted_at.sql} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/main/resources/db/migration/{V4__rename_app_logs_to_application_logs.sql => V3__rename_app_logs_to_application_logs.sql} (100%) rename src/main/resources/db/migration/{V5__create_login_histories.sql => V4__create_login_histories.sql} (93%) rename src/main/resources/db/migration/{V4__add_policies_deleted_at.sql => V5__add_policies_deleted_at.sql} (88%) diff --git a/src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql b/src/main/resources/db/migration/V3__rename_app_logs_to_application_logs.sql similarity index 100% rename from src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql rename to src/main/resources/db/migration/V3__rename_app_logs_to_application_logs.sql diff --git a/src/main/resources/db/migration/V5__create_login_histories.sql b/src/main/resources/db/migration/V4__create_login_histories.sql similarity index 93% rename from src/main/resources/db/migration/V5__create_login_histories.sql rename to src/main/resources/db/migration/V4__create_login_histories.sql index ff0696c..10d6fc2 100644 --- a/src/main/resources/db/migration/V5__create_login_histories.sql +++ b/src/main/resources/db/migration/V4__create_login_histories.sql @@ -1,5 +1,5 @@ -- ============================================================ --- V5 — login_histories 신규 (관리자 로그인 이력 조회) +-- V4 — login_histories 신규 (관리자 로그인 이력 조회) -- OAuth 로그인 성공 시 1건씩 기록한다. 실무적으로는 불변 로그지만 -- 관리자 API 응답에 updated_at도 필요해 다른 엔티티와 동일하게 -- created_at/updated_at 둘 다 둔다. diff --git a/src/main/resources/db/migration/V4__add_policies_deleted_at.sql b/src/main/resources/db/migration/V5__add_policies_deleted_at.sql similarity index 88% rename from src/main/resources/db/migration/V4__add_policies_deleted_at.sql rename to src/main/resources/db/migration/V5__add_policies_deleted_at.sql index d3862f8..d17ea79 100644 --- a/src/main/resources/db/migration/V4__add_policies_deleted_at.sql +++ b/src/main/resources/db/migration/V5__add_policies_deleted_at.sql @@ -1,5 +1,5 @@ -- ============================================================ --- V4 — policies.deleted_at 추가 (관리자 정책 삭제) +-- V5 — policies.deleted_at 추가 (관리자 정책 삭제) -- visibility(VISIBLE/HIDDEN)는 배치가 원본 소실 정책을 껐다 켜는 -- 용도로 계속 쓰고, deleted_at은 관리자가 확정적으로 삭제 처리한 -- 것을 별도로 표시하는 soft delete 컬럼이다. 두 상태는 독립적이다. From dd3b45f2b6c41ac0356fa532fdac1166d84d721b Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 17:56:51 +0900 Subject: [PATCH 26/93] =?UTF-8?q?fix:=20db/seed=EC=9D=98=20V3=5F=5Fseed=5F?= =?UTF-8?q?regions.sql=EA=B3=BC=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C=20(V3=E2=86=92V4=EB=A1=9C=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B5)=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #64에서 V3 공백을 메운다고 재넘버링했으나, db/migration과 db/seed가 버전 번호를 공유한다는 걸 확인하지 않았다. db/seed/V3__seed_regions.sql (#38)이 이미 V3를 쓰고 있어 재넘버링이 실제로는 새로운 버전 충돌이었다. V3__rename_app_logs_to_application_logs.sql → V4, V4__create_login_histories.sql → V5, V5__add_policies_deleted_at.sql → V6으로 원복해 db/seed/V3와의 충돌을 없앴다. Closes #65 Co-authored-by: Claude Sonnet 5 --- ...ion_logs.sql => V4__rename_app_logs_to_application_logs.sql} | 0 ...reate_login_histories.sql => V5__create_login_histories.sql} | 2 +- ..._policies_deleted_at.sql => V6__add_policies_deleted_at.sql} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/main/resources/db/migration/{V3__rename_app_logs_to_application_logs.sql => V4__rename_app_logs_to_application_logs.sql} (100%) rename src/main/resources/db/migration/{V4__create_login_histories.sql => V5__create_login_histories.sql} (93%) rename src/main/resources/db/migration/{V5__add_policies_deleted_at.sql => V6__add_policies_deleted_at.sql} (88%) diff --git a/src/main/resources/db/migration/V3__rename_app_logs_to_application_logs.sql b/src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql similarity index 100% rename from src/main/resources/db/migration/V3__rename_app_logs_to_application_logs.sql rename to src/main/resources/db/migration/V4__rename_app_logs_to_application_logs.sql diff --git a/src/main/resources/db/migration/V4__create_login_histories.sql b/src/main/resources/db/migration/V5__create_login_histories.sql similarity index 93% rename from src/main/resources/db/migration/V4__create_login_histories.sql rename to src/main/resources/db/migration/V5__create_login_histories.sql index 10d6fc2..ff0696c 100644 --- a/src/main/resources/db/migration/V4__create_login_histories.sql +++ b/src/main/resources/db/migration/V5__create_login_histories.sql @@ -1,5 +1,5 @@ -- ============================================================ --- V4 — login_histories 신규 (관리자 로그인 이력 조회) +-- V5 — login_histories 신규 (관리자 로그인 이력 조회) -- OAuth 로그인 성공 시 1건씩 기록한다. 실무적으로는 불변 로그지만 -- 관리자 API 응답에 updated_at도 필요해 다른 엔티티와 동일하게 -- created_at/updated_at 둘 다 둔다. diff --git a/src/main/resources/db/migration/V5__add_policies_deleted_at.sql b/src/main/resources/db/migration/V6__add_policies_deleted_at.sql similarity index 88% rename from src/main/resources/db/migration/V5__add_policies_deleted_at.sql rename to src/main/resources/db/migration/V6__add_policies_deleted_at.sql index d17ea79..ef801cb 100644 --- a/src/main/resources/db/migration/V5__add_policies_deleted_at.sql +++ b/src/main/resources/db/migration/V6__add_policies_deleted_at.sql @@ -1,5 +1,5 @@ -- ============================================================ --- V5 — policies.deleted_at 추가 (관리자 정책 삭제) +-- V6 — policies.deleted_at 추가 (관리자 정책 삭제) -- visibility(VISIBLE/HIDDEN)는 배치가 원본 소실 정책을 껐다 켜는 -- 용도로 계속 쓰고, deleted_at은 관리자가 확정적으로 삭제 처리한 -- 것을 별도로 표시하는 soft delete 컬럼이다. 두 상태는 독립적이다. From 9be0647dc19d3f71ff8524d8d083c6ba4038d8fe Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 21:47:26 +0900 Subject: [PATCH 27/93] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20-?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EC=88=98=EC=A7=91=20=EC=9A=94=EC=95=BD?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(policy-sync?= =?UTF-8?q?-jobs/summary)=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트가 호출하는 GET /api/v1/admin/policy-sync-jobs/summary에 매핑된 컨트롤러가 없어 NoResourceFoundException(500)이 발생했다. 정책 수집 배치 파이프라인(POST /policy-sync-jobs 실행, PolicyBatchHistory 적재)은 별도 PR에서 다루므로, 이번엔 지금 실데이터로 계산 가능한 부분만 구현한다. - activeCount: Policy.visibility=VISIBLE AND deletedAt IS NULL 카운트 - missingCount: Policy.visibility=HIDDEN AND deletedAt IS NULL 카운트 (배치가 원본 소실 정책을 HIDDEN 처리하는 기존 컨벤션 그대로 활용) - parseErrorCount/dbFailCount: 배치 파이프라인이 아직 없어 항상 0. 파이프라인 PR에서 실제 집계로 교체 예정. PolicyRepository에 countByVisibilityAndDeletedAtIsNull 쿼리 메서드를 추가하고, sync 도메인에 AdminPolicySyncJobController/Service/DTO를 새로 만들었다. Closes #71 Co-authored-by: Claude Sonnet 5 --- docs/api-spec-db.md | 1 + .../policy/repository/PolicyRepository.java | 6 ++- .../AdminPolicySyncJobController.java | 22 +++++++++ .../dto/PolicySyncJobSummaryResponse.java | 10 +++++ .../service/AdminPolicySyncJobService.java | 24 ++++++++++ .../repository/PolicyRepositoryTest.java | 45 +++++++++++++++++++ .../AdminPolicySyncJobControllerTest.java | 37 +++++++++++++++ .../AdminPolicySyncJobServiceTest.java | 41 +++++++++++++++++ 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobController.java create mode 100644 src/main/java/com/bop/youthpick/sync/dto/PolicySyncJobSummaryResponse.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/AdminPolicySyncJobService.java create mode 100644 src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/AdminPolicySyncJobServiceTest.java diff --git a/docs/api-spec-db.md b/docs/api-spec-db.md index 88783df..eba6f53 100644 --- a/docs/api-spec-db.md +++ b/docs/api-spec-db.md @@ -14,6 +14,7 @@ | 액세스 토큰 재발급 | POST | `/api/v1/auth/token/refresh` | 비회원 | AUTH | 확정 | 쿠키: `refresh_token`(요청 body 없음) | body: `{accessToken, tokenType, expiresIn}`, `Set-Cookie: refresh_token`(HttpOnly, 재발급값으로 교체) | | 프로필 선택지 조회 | GET | `/api/v1/meta/profile-options` | 비회원 | ONB | 확정 | | | | 정책 수집 이력 목록 | GET | `/api/v1/admin/policy-sync-jobs` | 관리자 | SYNC | 확정 | | | +| 정책 수집 요약 조회 | GET | `/api/v1/admin/policy-sync-jobs/summary` | 관리자 | SYNC | 확정 | | body: `{activeCount, missingCount, parseErrorCount, dbFailCount}` — `parseErrorCount`/`dbFailCount`는 정책 수집 배치 파이프라인 미구현으로 항상 0 | | 맞춤정책 조회 | GET | `/api/v1/me/recommended-policies` | 회원 | REC | 확정 | | | | 회원 탈퇴 | DELETE | `/api/v1/auth/me` | 회원 | AUTH | 확정 | | | | 정책 상세 조회 | GET | `/api/v1/policies/{policyId}` | 비회원 | SEARCH | 확정 | | | diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index 177ade6..d60f670 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -1,8 +1,12 @@ package com.bop.youthpick.policy.repository; import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface PolicyRepository - extends JpaRepository, JpaSpecificationExecutor {} + extends JpaRepository, JpaSpecificationExecutor { + + long countByVisibilityAndDeletedAtIsNull(PolicyVisibility visibility); +} diff --git a/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobController.java b/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobController.java new file mode 100644 index 0000000..0c5cca1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobController.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.sync.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; +import com.bop.youthpick.sync.service.AdminPolicySyncJobService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/policy-sync-jobs") +@RequiredArgsConstructor +public class AdminPolicySyncJobController { + + private final AdminPolicySyncJobService adminPolicySyncJobService; + + @GetMapping("/summary") + public ApiResponse summary() { + return ApiResponse.ok(adminPolicySyncJobService.getSummary()); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/dto/PolicySyncJobSummaryResponse.java b/src/main/java/com/bop/youthpick/sync/dto/PolicySyncJobSummaryResponse.java new file mode 100644 index 0000000..04032f6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/dto/PolicySyncJobSummaryResponse.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.sync.dto; + +/** parseErrorCount/dbFailCount는 정책 수집 배치 파이프라인이 아직 없어 항상 0이다. 파이프라인이 추가되면 실제 집계로 교체한다. */ +public record PolicySyncJobSummaryResponse( + long activeCount, long missingCount, long parseErrorCount, long dbFailCount) { + + public static PolicySyncJobSummaryResponse of(long activeCount, long missingCount) { + return new PolicySyncJobSummaryResponse(activeCount, missingCount, 0, 0); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/service/AdminPolicySyncJobService.java b/src/main/java/com/bop/youthpick/sync/service/AdminPolicySyncJobService.java new file mode 100644 index 0000000..3ba9f19 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/AdminPolicySyncJobService.java @@ -0,0 +1,24 @@ +package com.bop.youthpick.sync.service; + +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminPolicySyncJobService { + + private final PolicyRepository policyRepository; + + @Transactional(readOnly = true) + public PolicySyncJobSummaryResponse getSummary() { + long activeCount = + policyRepository.countByVisibilityAndDeletedAtIsNull(PolicyVisibility.VISIBLE); + long missingCount = + policyRepository.countByVisibilityAndDeletedAtIsNull(PolicyVisibility.HIDDEN); + return PolicySyncJobSummaryResponse.of(activeCount, missingCount); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java new file mode 100644 index 0000000..3e20922 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -0,0 +1,45 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PolicyRepositoryTest { + + @Autowired private PolicyRepository policyRepository; + + @Test + void visibility별_soft_delete되지_않은_정책만_카운트한다() { + policyRepository.save(newPolicy("P001", PolicyVisibility.VISIBLE, null)); + policyRepository.save(newPolicy("P002", PolicyVisibility.VISIBLE, null)); + policyRepository.save( + newPolicy("P003", PolicyVisibility.VISIBLE, LocalDateTime.now())); // soft delete + policyRepository.save(newPolicy("P004", PolicyVisibility.HIDDEN, null)); + + assertThat(policyRepository.countByVisibilityAndDeletedAtIsNull(PolicyVisibility.VISIBLE)) + .isEqualTo(2); + assertThat(policyRepository.countByVisibilityAndDeletedAtIsNull(PolicyVisibility.HIDDEN)) + .isEqualTo(1); + } + + private Policy newPolicy( + String policyNo, PolicyVisibility visibility, LocalDateTime deletedAt) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "visibility", visibility); + ReflectionTestUtils.setField(policy, "deletedAt", deletedAt); + return policy; + } +} diff --git a/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobControllerTest.java b/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobControllerTest.java new file mode 100644 index 0000000..130e667 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobControllerTest.java @@ -0,0 +1,37 @@ +package com.bop.youthpick.sync.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; +import com.bop.youthpick.sync.service.AdminPolicySyncJobService; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = AdminPolicySyncJobController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminPolicySyncJobControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private AdminPolicySyncJobService adminPolicySyncJobService; + + @Test + void 요약_조회는_200과_카운트_데이터를_반환한다() throws Exception { + when(adminPolicySyncJobService.getSummary()) + .thenReturn(PolicySyncJobSummaryResponse.of(3241L, 12L)); + + mockMvc.perform(get("/api/v1/admin/policy-sync-jobs/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.activeCount").value(3241)) + .andExpect(jsonPath("$.data.missingCount").value(12)) + .andExpect(jsonPath("$.data.parseErrorCount").value(0)) + .andExpect(jsonPath("$.data.dbFailCount").value(0)); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/AdminPolicySyncJobServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/AdminPolicySyncJobServiceTest.java new file mode 100644 index 0000000..7ead836 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/AdminPolicySyncJobServiceTest.java @@ -0,0 +1,41 @@ +package com.bop.youthpick.sync.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminPolicySyncJobServiceTest { + + @Mock private PolicyRepository policyRepository; + + private AdminPolicySyncJobService adminPolicySyncJobService; + + @BeforeEach + void setUp() { + adminPolicySyncJobService = new AdminPolicySyncJobService(policyRepository); + } + + @Test + void 요약_조회는_visibility별_카운트를_담고_배치_파이프라인_미구현_필드는_0이다() { + when(policyRepository.countByVisibilityAndDeletedAtIsNull(PolicyVisibility.VISIBLE)) + .thenReturn(3241L); + when(policyRepository.countByVisibilityAndDeletedAtIsNull(PolicyVisibility.HIDDEN)) + .thenReturn(12L); + + PolicySyncJobSummaryResponse summary = adminPolicySyncJobService.getSummary(); + + assertThat(summary.activeCount()).isEqualTo(3241L); + assertThat(summary.missingCount()).isEqualTo(12L); + assertThat(summary.parseErrorCount()).isZero(); + assertThat(summary.dbFailCount()).isZero(); + } +} From 989d7da908339b706c29500aab28ce2f51efb4dc Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 21:47:42 +0900 Subject: [PATCH 28/93] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80/=EB=8C=93=EA=B8=80=20API=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A4=EB=A5=B8=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?API=EC=99=80=20=EC=9D=BC=EA=B4=80=EB=90=98=EA=B2=8C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminCommunityPostController/AdminCommunityCommentController가 /api/v1/admin/community/posts, /api/v1/admin/community/comments처럼 중첩 경로를 썼는데, login-histories/application-logs/search-logs/ batch-job-logs/policy-applications 등 다른 관리자 API는 전부 /api/v1/admin/{kebab-case} 평평한 구조다. 프론트가 관례대로 /api/v1/admin/community-posts를 호출했다가 정적 리소스로 오인되어 500(NoResourceFoundException)을 만났다. 경로를 /api/v1/admin/community-posts, /api/v1/admin/community-comments로 맞췄다. Closes #69 Co-authored-by: Claude Sonnet 5 --- .../AdminCommunityCommentController.java | 2 +- .../controller/AdminCommunityPostController.java | 2 +- .../AdminCommunityCommentControllerTest.java | 4 ++-- .../AdminCommunityPostControllerTest.java | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java index 3bb1256..854b1f3 100644 --- a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java +++ b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/admin/community/comments") +@RequestMapping("/api/v1/admin/community-comments") @RequiredArgsConstructor public class AdminCommunityCommentController { diff --git a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java index 9a5090b..8a9b1cc 100644 --- a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java +++ b/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/admin/community/posts") +@RequestMapping("/api/v1/admin/community-posts") @RequiredArgsConstructor @Validated public class AdminCommunityPostController { diff --git a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java index e5e183d..89da413 100644 --- a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java +++ b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java @@ -32,7 +32,7 @@ class AdminCommunityCommentControllerTest { 2L, 1L, null, "닉네임", "댓글", LocalDateTime.now(), LocalDateTime.now()); when(adminCommunityService.deleteComment(2L)).thenReturn(deleted); - mockMvc.perform(delete("/api/v1/admin/community/comments/{commentId}", 2L)) + mockMvc.perform(delete("/api/v1/admin/community-comments/{commentId}", 2L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.deletedAt").exists()); } @@ -42,7 +42,7 @@ class AdminCommunityCommentControllerTest { when(adminCommunityService.deleteComment(2L)) .thenThrow(new BoardException(BoardErrorCode.COMMENT_NOT_FOUND)); - mockMvc.perform(delete("/api/v1/admin/community/comments/{commentId}", 2L)) + mockMvc.perform(delete("/api/v1/admin/community-comments/{commentId}", 2L)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("B002")); } diff --git a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java index ec21d9f..35e8657 100644 --- a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java +++ b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java @@ -55,7 +55,7 @@ class AdminCommunityPostControllerTest { isNull(), isNull(), isNull(), isNull(), any(Pageable.class))) .thenReturn(page); - mockMvc.perform(get("/api/v1/admin/community/posts")) + mockMvc.perform(get("/api/v1/admin/community-posts")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].id").value(1)) .andExpect(jsonPath("$.data[0].authorName").value("닉네임")) @@ -64,7 +64,7 @@ class AdminCommunityPostControllerTest { @Test void category_필터가_허용값이_아니면_400과_C001을_반환한다() throws Exception { - mockMvc.perform(get("/api/v1/admin/community/posts").param("category", "NOTICE")) + mockMvc.perform(get("/api/v1/admin/community-posts").param("category", "NOTICE")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("C001")); } @@ -77,7 +77,7 @@ class AdminCommunityPostControllerTest { new AdminCommunityCommentResponse( 2L, 1L, null, "닉네임", "댓글", LocalDateTime.now(), null))); - mockMvc.perform(get("/api/v1/admin/community/posts/{postId}/comments", 1L)) + mockMvc.perform(get("/api/v1/admin/community-posts/{postId}/comments", 1L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].id").value(2)); } @@ -87,7 +87,7 @@ class AdminCommunityPostControllerTest { when(adminCommunityService.getComments(1L)) .thenThrow(new BoardException(BoardErrorCode.POST_NOT_FOUND)); - mockMvc.perform(get("/api/v1/admin/community/posts/{postId}/comments", 1L)) + mockMvc.perform(get("/api/v1/admin/community-posts/{postId}/comments", 1L)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("B001")); } @@ -104,7 +104,7 @@ class AdminCommunityPostControllerTest { 1024L, LocalDateTime.now()))); - mockMvc.perform(get("/api/v1/admin/community/posts/{postId}/attachments", 1L)) + mockMvc.perform(get("/api/v1/admin/community-posts/{postId}/attachments", 1L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].id").value(3)) .andExpect(jsonPath("$.data[0].fileKey").value("https://example.com/file.pdf")); @@ -125,7 +125,7 @@ class AdminCommunityPostControllerTest { LocalDateTime.now()); when(adminCommunityService.deletePost(1L)).thenReturn(deleted); - mockMvc.perform(delete("/api/v1/admin/community/posts/{postId}", 1L)) + mockMvc.perform(delete("/api/v1/admin/community-posts/{postId}", 1L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.deletedAt").exists()); } @@ -135,7 +135,7 @@ class AdminCommunityPostControllerTest { when(adminCommunityService.deletePost(eq(1L))) .thenThrow(new BoardException(BoardErrorCode.POST_NOT_FOUND)); - mockMvc.perform(delete("/api/v1/admin/community/posts/{postId}", 1L)) + mockMvc.perform(delete("/api/v1/admin/community-posts/{postId}", 1L)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("B001")); } From 92daab11ba8f9eaa016a21183e7e8fa16dd4d3a6 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 15 Jul 2026 21:47:56 +0900 Subject: [PATCH 29/93] =?UTF-8?q?refac:=20refresh=20token=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89(rotate)=20=EC=A0=9C=EA=B1=B0,=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=8B=9C=EC=A0=90=20TTL=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20(#6?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/v1/auth/token/refresh가 access token뿐 아니라 refresh token도 매번 재발급(rotate)해 Redis 값과 쿠키를 함께 교체하던 것을, refresh token은 최초 로그인 시점에 발급된 값을 설정된 TTL이 다할 때까지 그대로 재사용하도록 바꿨다. - RefreshTokenStore: 원자적 교체용 rotate()(Lua 스크립트)를 제거하고 TTL을 건드리지 않는 matches() 조회 메서드로 교체. - AuthService.refresh(): 새 refresh token을 생성하지 않고 matches()로 검증 후 access token만 재발급. - AuthController.refresh(): refresh token이 바뀌지 않으므로 Set-Cookie를 다시 내려주지 않음. - 관련 테스트와 문서(auth-security.md, security-review/checklist.md, api-spec-db.md, spring-request-processing-and-jwt-auth.md)를 실제 동작에 맞게 갱신. Closes #67 Co-authored-by: Claude Sonnet 5 --- .claude/rules/auth-security.md | 2 +- .claude/skills/security-review/checklist.md | 2 +- docs/api-spec-db.md | 2 +- .../spring-request-processing-and-jwt-auth.md | 16 ++++--- .../auth/controller/AuthController.java | 6 ++- .../youthpick/auth/service/AuthService.java | 18 +++----- .../auth/service/RefreshTokenStore.java | 35 +++------------ .../auth/controller/AuthControllerTest.java | 9 ++-- .../auth/service/AuthServiceTest.java | 23 ++++------ .../auth/service/RefreshTokenStoreTest.java | 43 ++++++++----------- 10 files changed, 60 insertions(+), 96 deletions(-) diff --git a/.claude/rules/auth-security.md b/.claude/rules/auth-security.md index 0c2fcd6..e60df58 100644 --- a/.claude/rules/auth-security.md +++ b/.claude/rules/auth-security.md @@ -11,7 +11,7 @@ Spring Security **STATELESS** + **JWT(access/refresh) 인증** + OAuth 소셜 - access token 인증은 `Authorization: Bearer ` 헤더로 이루어진다. `JwtAuthenticationFilter`가 매 요청에서 토큰을 검증해 `SecurityContext`를 채우고, `SecurityConfig`는 `SessionCreationPolicy.STATELESS`를 유지한다. - refresh token은 **HttpOnly 쿠키**(`RefreshTokenCookieSupport`, 쿠키명 `RefreshTokenCookieSupport.COOKIE_NAME`)로만 클라이언트에 전달한다. JS가 `document.cookie`로 읽을 수 없게 해 XSS로부터 보호하기 위함이다 — 요청 body/응답 body 어디에도 refresh token 원문을 담지 않는다(`AuthController`는 `AccessTokenResponse`로 accessToken만 body에 내려준다). 쿠키 속성(`secure`/`sameSite`/`path`)은 `RefreshCookieProperties`(`youthpick.auth.refresh-cookie.*`)로 프로필별 관리한다 — 프론트와 백엔드가 크로스사이트인 배포 환경은 `SameSite=None; Secure` 필수, `localhost` 로컬 개발은 `SameSite=Lax`로 완화한다. -- refresh token은 발급 시 `RefreshTokenStore`(Redis, `youthpick.jwt.refresh-token-expiration` TTL)에 사용자별로 저장한다(쿠키는 전달 경로일 뿐이고, 유효성의 근원은 여전히 Redis다). `/api/v1/auth/token/refresh`는 쿠키에서 꺼낸 토큰의 JWT 서명·만료를 검증한 뒤 Redis에 저장된 값과 일치하는지 확인하고, 통과하면 access/refresh를 모두 재발급(rotate)해 새 쿠키로 교체한다. 로그아웃은 Redis의 refresh token을 삭제하고 쿠키도 함께 지운다(`RefreshTokenCookieSupport.clear()`). +- refresh token은 발급 시 `RefreshTokenStore`(Redis, `youthpick.jwt.refresh-token-expiration` TTL)에 사용자별로 저장한다(쿠키는 전달 경로일 뿐이고, 유효성의 근원은 여전히 Redis다). `/api/v1/auth/token/refresh`는 쿠키에서 꺼낸 토큰의 JWT 서명·만료를 검증한 뒤 Redis에 저장된 값과 일치하는지(`RefreshTokenStore.matches`) 확인하고, 통과하면 access token만 새로 발급한다 — refresh token은 재발급(rotate)하지 않고 최초 로그인 시점의 TTL이 다할 때까지 그대로 재사용하므로 쿠키도 다시 내려주지 않는다. 로그아웃은 Redis의 refresh token을 삭제하고 쿠키도 함께 지운다(`RefreshTokenCookieSupport.clear()`). - refresh 엔드포인트는 쿠키만으로 동작해 preflight 없는 단순 요청도 도달할 수 있으므로, `AuthController.refresh()`는 `Origin` 헤더가 있는데 `SecurityConfig.ALLOWED_ORIGINS`에 없으면 서비스 호출 전에 거부한다(CSRF 최소 방어). 새로 쿠키를 소비하는 엔드포인트를 추가하면 동일한 가드를 적용할지 검토한다. - OAuth authorization-url 발급 시 CSRF 방지용 `state`는 세션이 아니라 `OAuthStateStore`(Redis, TTL)에 저장하고 콜백에서 1회 소비한다. - 인증 실패(미인증 접근)는 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려준다. diff --git a/.claude/skills/security-review/checklist.md b/.claude/skills/security-review/checklist.md index 96157f1..40dd46a 100644 --- a/.claude/skills/security-review/checklist.md +++ b/.claude/skills/security-review/checklist.md @@ -4,7 +4,7 @@ - [ ] 미인증 접근이 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려가는가. - [ ] `SecurityConfig`가 `SessionCreationPolicy.STATELESS`를 유지하는가. 세션/쿠키 기반 인증 코드가 새로 생기지 않았는가. -- [ ] access token은 짧은 TTL, refresh token은 발급 시 `RefreshTokenStore`(Redis)에 저장되고 재발급(rotate)/로그아웃 시 갱신·삭제되는가. +- [ ] access token은 짧은 TTL, refresh token은 발급 시 `RefreshTokenStore`(Redis)에 저장되고 로그아웃 시 삭제되는가. refresh token 자체는 재발급(rotate) 없이 최초 TTL이 다할 때까지 재사용된다. - [ ] `/api/v1/auth/token/refresh`가 JWT 서명·만료뿐 아니라 Redis에 저장된 값과 일치하는지도 검증하는가(탈취된 구 토큰 재사용 방지). - [ ] 인가 규칙(경로별 권한)이 `SecurityConfig`에 모여 있는가. Controller에 `if (권한)` 식으로 흩뿌려지지 않았는가. - [ ] 새로 추가된 인증 필요 엔드포인트가 `SecurityConfig`의 `authenticated()` 목록에 반영됐는가(빠지면 `anyRequest().permitAll()`로 누구나 접근 가능). diff --git a/docs/api-spec-db.md b/docs/api-spec-db.md index eba6f53..9775108 100644 --- a/docs/api-spec-db.md +++ b/docs/api-spec-db.md @@ -11,7 +11,7 @@ | 정책 비교 생성 | POST | `/api/v1/policy-comparisons` | 비회원 | CMP | 확정 | | | | OAuth 인가 URL 생성 | GET | `/api/v1/auth/oauth/{provider}/authorization-url` | 비회원 | AUTH | 확정 | | | | OAuth 콜백(로그인) | POST | `/api/v1/auth/oauth/{provider}/callback` | 비회원 | AUTH | 확정 | `{code, state}` | body: `{accessToken, tokenType, expiresIn}`, `Set-Cookie: refresh_token`(HttpOnly) | -| 액세스 토큰 재발급 | POST | `/api/v1/auth/token/refresh` | 비회원 | AUTH | 확정 | 쿠키: `refresh_token`(요청 body 없음) | body: `{accessToken, tokenType, expiresIn}`, `Set-Cookie: refresh_token`(HttpOnly, 재발급값으로 교체) | +| 액세스 토큰 재발급 | POST | `/api/v1/auth/token/refresh` | 비회원 | AUTH | 확정 | 쿠키: `refresh_token`(요청 body 없음) | body: `{accessToken, tokenType, expiresIn}` (refresh token은 재발급하지 않으므로 `Set-Cookie` 없음) | | 프로필 선택지 조회 | GET | `/api/v1/meta/profile-options` | 비회원 | ONB | 확정 | | | | 정책 수집 이력 목록 | GET | `/api/v1/admin/policy-sync-jobs` | 관리자 | SYNC | 확정 | | | | 정책 수집 요약 조회 | GET | `/api/v1/admin/policy-sync-jobs/summary` | 관리자 | SYNC | 확정 | | body: `{activeCount, missingCount, parseErrorCount, dbFailCount}` — `parseErrorCount`/`dbFailCount`는 정책 수집 배치 파이프라인 미구현으로 항상 0 | diff --git a/docs/spring-request-processing-and-jwt-auth.md b/docs/spring-request-processing-and-jwt-auth.md index 17ff873..9b5cd6f 100644 --- a/docs/spring-request-processing-and-jwt-auth.md +++ b/docs/spring-request-processing-and-jwt-auth.md @@ -188,15 +188,17 @@ public ApiResponse me(@CurrentUser Long userId) { ... } Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); // ① JWT 자체 검증 Long userId = jwtTokenProvider.getUserId(claims); ... -boolean rotated = refreshTokenStore.rotate(userId, refreshToken, newRefreshToken, ttl); // ② Redis 대조 + 교체 -if (!rotated) throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); +if (!refreshTokenStore.matches(userId, refreshToken)) { // ② Redis 대조 + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); +} +String newAccessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getRole().name()); ``` -JWT는 서명이 유효하고 만료 전이면 그 자체로는 "위조되지 않았다"만 증명할 뿐, "아직 살아있는(로그아웃되지 않은, 이미 한 번 쓰이지 않은) 토큰인가"는 증명하지 못한다. 그래서 [`RefreshTokenStore.rotate(...)`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java)가 Redis에 저장된 해시와 제출된 토큰의 해시를 **Lua 스크립트로 원자적으로 대조 후 교체**한다. +JWT는 서명이 유효하고 만료 전이면 그 자체로는 "위조되지 않았다"만 증명할 뿐, "아직 살아있는(로그아웃되지 않은) 토큰인가"는 증명하지 못한다. 그래서 [`RefreshTokenStore.matches(...)`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java)가 Redis에 저장된 해시와 제출된 토큰의 해시를 대조한다. -- 일치하면 새 refresh token 해시로 교체(재사용 방지를 위한 rotate) → 성공. -- 불일치(이미 로그아웃됨/이미 재발급에 쓰여서 폐기됨)면 거부. -- "대조"와 "교체"를 하나의 Lua 스크립트로 묶은 이유는 동시에 같은 refresh token으로 두 번 재발급 요청이 와도 **하나만 성공**하게 만들기 위해서다(따로 하면 둘 다 대조를 통과한 뒤 각자 교체해버리는 race condition이 생긴다). +- 일치하면 access token만 새로 발급 → 성공. +- 불일치(이미 로그아웃됨)면 거부. +- refresh token 자체는 재발급(rotate)하지 않는다. 최초 로그인 시점에 저장된 값과 TTL을 그대로 유지하고, 응답도 access token만 body로 내려주며 `Set-Cookie`는 다시 내려주지 않는다(쿠키 값이 바뀌지 않았으므로). `AuthController.refresh()`는 더 이상 요청 body로 refreshToken을 받지 않고 `@CookieValue`로 `refresh_token` 쿠키에서 읽는다. 쿠키만으로 동작하면 preflight 없는 단순 요청(cross-site `` 제출 등)도 쿠키를 자동으로 실어 도달할 수 있어서, `Origin` 헤더가 있는데 `SecurityConfig.ALLOWED_ORIGINS`에 없으면 서비스 호출 전에 `AuthErrorCode.FORBIDDEN`(403 `A008`)으로 거부한다(헤더 자체가 없으면 통과 — 브라우저가 아닌 클라이언트를 막지 않기 위해서다). @@ -228,7 +230,7 @@ flowchart TB | 보안 필터 체인/인가 규칙 정의 | [`SecurityConfig`](../src/main/java/com/bop/youthpick/global/config/SecurityConfig.java) | | JWT Bearer 헤더 해석 필터 | [`JwtAuthenticationFilter`](../src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java) | | JWT 발급/검증 | [`JwtTokenProvider`](../src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java) | -| Redis 기반 refresh token 저장/원자적 rotate | [`RefreshTokenStore`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java) | +| Redis 기반 refresh token 저장/대조(rotate 없음) | [`RefreshTokenStore`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java) | | refresh token HttpOnly 쿠키 발급/삭제 | [`RefreshTokenCookieSupport`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenCookieSupport.java) / [`RefreshCookieProperties`](../src/main/java/com/bop/youthpick/auth/service/RefreshCookieProperties.java) | | 인증 principal DTO | [`AuthPrincipal`](../src/main/java/com/bop/youthpick/auth/dto/AuthPrincipal.java) | | 인증 실패(401) 처리 | [`RestAuthenticationEntryPoint`](../src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java) | diff --git a/src/main/java/com/bop/youthpick/auth/controller/AuthController.java b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java index 8a87657..775fb43 100644 --- a/src/main/java/com/bop/youthpick/auth/controller/AuthController.java +++ b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java @@ -54,9 +54,11 @@ public ResponseEntity> callback( /** * refresh token은 더 이상 body가 아니라 {@link RefreshTokenCookieSupport#COOKIE_NAME} 쿠키로 받는다. 쿠키만으로 * 동작해 preflight 없는 단순 요청(cross-site form 등)도 도달할 수 있으므로, Origin 헤더가 있는데 허용 목록에 없으면 거부한다. + * refresh token 자체는 재발급(rotate)하지 않고 발급 시점의 만료 기간까지 그대로 재사용하므로, access token만 새로 내려주고 쿠키는 다시 + * 설정하지 않는다. */ @PostMapping("/token/refresh") - public ResponseEntity> refresh( + public ApiResponse refresh( @CookieValue(name = RefreshTokenCookieSupport.COOKIE_NAME, required = false) String refreshToken, @RequestHeader(value = HttpHeaders.ORIGIN, required = false) String origin) { @@ -65,7 +67,7 @@ public ResponseEntity> refresh( throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); } TokenResponse tokens = authService.refresh(refreshToken); - return withRefreshCookie(tokens); + return ApiResponse.ok(AccessTokenResponse.from(tokens)); } @PostMapping("/logout") diff --git a/src/main/java/com/bop/youthpick/auth/service/AuthService.java b/src/main/java/com/bop/youthpick/auth/service/AuthService.java index 700b080..e36f2a7 100644 --- a/src/main/java/com/bop/youthpick/auth/service/AuthService.java +++ b/src/main/java/com/bop/youthpick/auth/service/AuthService.java @@ -87,21 +87,15 @@ public TokenResponse refresh(String refreshToken) { .findById(userId) .orElseThrow(() -> new AuthException(AuthErrorCode.UNAUTHORIZED)); - String newAccessToken = - jwtTokenProvider.createAccessToken(user.getId(), user.getRole().name()); - String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getId()); - boolean rotated = - refreshTokenStore.rotate( - userId, - refreshToken, - newRefreshToken, - jwtTokenProvider.refreshTokenExpiration()); - if (!rotated) { - // 저장된 refresh token과 불일치(이미 재발급됐거나 로그아웃됨) — 동시 재발급 요청 중 하나만 성공시킨다. + if (!refreshTokenStore.matches(userId, refreshToken)) { + // 저장된 refresh token과 불일치(로그아웃되었거나 유효하지 않음) throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); } + + String newAccessToken = + jwtTokenProvider.createAccessToken(user.getId(), user.getRole().name()); return TokenResponse.of( - newAccessToken, newRefreshToken, jwtTokenProvider.accessTokenExpirationSeconds()); + newAccessToken, refreshToken, jwtTokenProvider.accessTokenExpirationSeconds()); } // Redis(refresh token 삭제)만 다루고 JPA 트랜잭션 리소스를 쓰지 않아 @Transactional 대상이 아니다. diff --git a/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java b/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java index ec0deca..0c28069 100644 --- a/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java +++ b/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java @@ -5,16 +5,13 @@ import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.HexFormat; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; -import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; /** - * 사용자별 refresh token을 Redis에 TTL과 함께 저장한다. Redis가 유출되더라도 원문 토큰이 그대로 노출되지 않도록 SHA-256 해시만 저장하고, - * 재발급(rotate)은 Lua 스크립트로 "저장값 일치 확인 + 교체"를 원자적으로 처리해 동시 요청으로 인한 중복 발급을 막는다. + * 사용자별 refresh token을 Redis에 TTL과 함께 저장한다. Redis가 유출되더라도 원문 토큰이 그대로 노출되지 않도록 SHA-256 해시만 저장한다. + * refresh token은 재발급(rotate) 없이 발급 시점의 TTL이 다할 때까지 그대로 사용한다. */ @Component @RequiredArgsConstructor @@ -22,36 +19,16 @@ public class RefreshTokenStore { private static final String KEY_PREFIX = "auth:refresh-token:"; - private static final RedisScript ROTATE_SCRIPT = - new DefaultRedisScript<>( - """ - local stored = redis.call('GET', KEYS[1]) - if stored == ARGV[1] then - redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3]) - return 1 - else - return 0 - end - """, - Long.class); - private final StringRedisTemplate redisTemplate; public void save(Long userId, String refreshToken, Duration ttl) { redisTemplate.opsForValue().set(key(userId), hash(refreshToken), ttl); } - /** 저장된 값이 {@code oldRefreshToken}의 해시와 일치할 때만 {@code newRefreshToken}으로 원자적으로 교체한다. */ - public boolean rotate( - Long userId, String oldRefreshToken, String newRefreshToken, Duration ttl) { - Long result = - redisTemplate.execute( - ROTATE_SCRIPT, - List.of(key(userId)), - hash(oldRefreshToken), - hash(newRefreshToken), - String.valueOf(ttl.toMillis())); - return result != null && result == 1L; + /** 저장된 값이 {@code refreshToken}의 해시와 일치하는지 확인한다(TTL은 건드리지 않는다). */ + public boolean matches(Long userId, String refreshToken) { + String stored = redisTemplate.opsForValue().get(key(userId)); + return stored != null && stored.equals(hash(refreshToken)); } public void delete(Long userId) { diff --git a/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java b/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java index d7a9f03..0f80559 100644 --- a/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java @@ -89,10 +89,9 @@ class AuthControllerTest { } @Test - void refresh_쿠키가_있으면_재발급하고_새_refresh_token을_쿠키로_내려간다() throws Exception { + void refresh_쿠키가_있으면_access_token만_재발급하고_refresh_쿠키는_다시_내려가지_않는다() throws Exception { when(authService.refresh("old-refresh-token")) - .thenReturn(TokenResponse.of("new-access", "new-refresh", 1800)); - stubIssuedCookie(); + .thenReturn(TokenResponse.of("new-access", "old-refresh-token", 1800)); mockMvc.perform( post("/api/v1/auth/token/refresh") @@ -103,7 +102,9 @@ class AuthControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.accessToken").value("new-access")) .andExpect(jsonPath("$.data.refreshToken").doesNotExist()) - .andExpect(cookie().value(RefreshTokenCookieSupport.COOKIE_NAME, "new-refresh")); + .andExpect(cookie().doesNotExist(RefreshTokenCookieSupport.COOKIE_NAME)); + + verify(refreshTokenCookieSupport, never()).issue(anyString()); } @Test diff --git a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java index 5372ca4..7716eb8 100644 --- a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java @@ -149,26 +149,22 @@ void setUp() { } @Test - void refresh_token_rotate가_실패하면_거부된다() { + void refresh_token이_저장된_값과_불일치하면_거부된다() { User user = mock(User.class); io.jsonwebtoken.Claims claims = mock(io.jsonwebtoken.Claims.class); - when(user.getId()).thenReturn(1L); - when(user.getRole()).thenReturn(com.bop.youthpick.user.entity.Role.USER); when(jwtTokenProvider.validateRefreshToken("refresh-token")).thenReturn(claims); when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(jwtTokenProvider.createAccessToken(1L, "USER")).thenReturn("new-access"); - when(jwtTokenProvider.createRefreshToken(1L)).thenReturn("new-refresh"); - when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); - when(refreshTokenStore.rotate(1L, "refresh-token", "new-refresh", Duration.ofDays(14))) - .thenReturn(false); + when(refreshTokenStore.matches(1L, "refresh-token")).thenReturn(false); assertThatThrownBy(() -> authService.refresh("refresh-token")) .isInstanceOf(AuthException.class); + + verify(jwtTokenProvider, never()).createAccessToken(any(), any()); } @Test - void refresh_token이_유효하면_원자적으로_rotate하고_토큰을_재발급한다() { + void refresh_token이_유효하면_회전없이_access_token만_재발급한다() { User user = mock(User.class); io.jsonwebtoken.Claims claims = mock(io.jsonwebtoken.Claims.class); when(user.getId()).thenReturn(1L); @@ -176,18 +172,15 @@ void setUp() { when(jwtTokenProvider.validateRefreshToken("refresh-token")).thenReturn(claims); when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(refreshTokenStore.matches(1L, "refresh-token")).thenReturn(true); when(jwtTokenProvider.createAccessToken(1L, "USER")).thenReturn("new-access"); - when(jwtTokenProvider.createRefreshToken(1L)).thenReturn("new-refresh"); - when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); when(jwtTokenProvider.accessTokenExpirationSeconds()).thenReturn(1800L); - when(refreshTokenStore.rotate(1L, "refresh-token", "new-refresh", Duration.ofDays(14))) - .thenReturn(true); TokenResponse tokens = authService.refresh("refresh-token"); assertThat(tokens.accessToken()).isEqualTo("new-access"); - assertThat(tokens.refreshToken()).isEqualTo("new-refresh"); - verify(refreshTokenStore).rotate(1L, "refresh-token", "new-refresh", Duration.ofDays(14)); + assertThat(tokens.refreshToken()).isEqualTo("refresh-token"); + verify(jwtTokenProvider, never()).createRefreshToken(any()); } @Test diff --git a/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java b/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java index 56499f9..4963b57 100644 --- a/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java @@ -2,13 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; -import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,7 +15,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; -import org.springframework.data.redis.core.script.RedisScript; @ExtendWith(MockitoExtension.class) class RefreshTokenStoreTest { @@ -46,32 +43,30 @@ void setUp() { } @Test - void rotate가_성공하면_true를_반환한다() { - when(redisTemplate.execute(any(RedisScript.class), anyList(), any(), any(), any())) - .thenReturn(1L); - - boolean rotated = - refreshTokenStore.rotate(1L, "old-token", "new-token", Duration.ofDays(14)); - - assertThat(rotated).isTrue(); - verify(redisTemplate) - .execute( - any(RedisScript.class), - eq(List.of("auth:refresh-token:1")), - any(), - any(), - any()); + void 저장된_해시와_일치하면_matches가_true를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + ArgumentCaptor hashCaptor = ArgumentCaptor.forClass(String.class); + refreshTokenStore.save(1L, "refresh-token", Duration.ofDays(14)); + verify(valueOperations).set(eq("auth:refresh-token:1"), hashCaptor.capture(), any()); + when(valueOperations.get("auth:refresh-token:1")).thenReturn(hashCaptor.getValue()); + + assertThat(refreshTokenStore.matches(1L, "refresh-token")).isTrue(); } @Test - void 저장된_값과_불일치하면_rotate가_false를_반환한다() { - when(redisTemplate.execute(any(RedisScript.class), anyList(), any(), any(), any())) - .thenReturn(0L); + void 저장된_값과_불일치하면_matches가_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:refresh-token:1")).thenReturn("different-hash"); - boolean rotated = - refreshTokenStore.rotate(1L, "old-token", "new-token", Duration.ofDays(14)); + assertThat(refreshTokenStore.matches(1L, "refresh-token")).isFalse(); + } + + @Test + void 저장된_값이_없으면_matches가_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:refresh-token:1")).thenReturn(null); - assertThat(rotated).isFalse(); + assertThat(refreshTokenStore.matches(1L, "refresh-token")).isFalse(); } @Test From 8a7251511fe7442dd0ea36e0a60941cdc356ff1b Mon Sep 17 00:00:00 2001 From: myh7754 Date: Thu, 16 Jul 2026 12:06:19 +0900 Subject: [PATCH 30/93] =?UTF-8?q?feat:=20=EC=98=A8=ED=86=B5=EC=B2=AD?= =?UTF-8?q?=EB=85=84=20=EC=A0=95=EC=B1=85=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=EB=B0=B0=EC=B9=98(=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC)=20=EA=B5=AC=ED=98=84=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Flyway 도입 및 V1 초기 스키마 마이그레이션 추가 - flyway-core, flyway-mysql 의존성 추가 - docs/schema.sql(PR #10) 기반 V1__init_schema.sql 생성 - 기본 프로필 Flyway 비활성(H2는 ddl-auto 유지), prod(MySQL)에서만 활성 + ddl-auto=validate Co-Authored-By: Claude Fable 5 * feat: 로컬 MySQL 프로파일에 Flyway 적용 및 V2 마이그레이션 추가 - local 프로파일을 H2 -> docker compose MySQL + Flyway(validate)로 전환 - V2: policies.missing_count 추가 (3회 연속 누락 시 HIDDEN 처리용) - 테스트는 H2 유지: test 전용 application.yml이 main 설정을 가리도록 파일명 정렬 - docker-compose.yml MySQL 서비스 활성화 Co-Authored-By: Claude Fable 5 * docs: 로컬 DB 규칙을 Flyway + MySQL 기준으로 갱신 - infra.md: 로컬 기본 H2 문장을 compose MySQL + Flyway(validate)로 교체, 스키마 변경은 새 V{n} 마이그레이션 추가 원칙 명시 - 테스트는 H2 유지 + test 설정 shadowing 주의사항 추가 - AGENTS.md 기술 스택 요약 동기화 Co-Authored-By: Claude Fable 5 * feat: 빈 DB일 때 앱 시작 시 초기 정책 수집 트리거 추가 - PolicySyncStartupRunner: policies 0건이면 전량 수집 1회 실행 - youthpick.sync.startup-on-empty 프로퍼티로 on/off (기본 off, local만 on) — 테스트/CI의 빈 H2에서 외부 API 호출 사고 방지 - PolicySyncService: 수집 진입점 (본 구현은 #15에서) - PolicyRepository 추가 Co-Authored-By: Claude Fable 5 * docs: 로컬 DB 기본 계정이 compose 로컬 전용임을 주석으로 명시 CodeRabbit 리뷰 반영: fallback 제거 대신 로컬 전용 기본값임을 문서화. (제거 시 zero-config 로컬 실행이 깨지고, 같은 값이 compose fallback에도 있어 제거 실익이 없음) Co-Authored-By: Claude Fable 5 * docs: application.yml flyway 주석을 변경된 전략에 맞게 갱신 Co-Authored-By: Claude Fable 5 * docs: application.yml flyway 주석을 변경된 전략에 맞게 갱신 Co-Authored-By: Claude Fable 5 * refac: 리뷰 반영 — 테이블명 2건 변경 - application_checklists -> policy_application_checklists (부모 policy_applications와 prefix 정렬, 기존 단수 prefix 컨벤션 유지) - search_logs -> search_histories - V1 직접 수정: 아직 dev merge 전이라 rename 마이그레이션 대신 V1 정정 (적용된 로컬 DB는 볼륨 리셋 필요 — docker compose down -v) - 엔티티 @Table/@Index, docs/schema.sql 동기화 Co-Authored-By: Claude Fable 5 * feat: 온통청년 API 응답 DTO 추가 (plan 1-1) - YouthPolicyApiResponse/YouthPolicyItem record — 전 필드 @JsonProperty 명시 - 실제 응답 형태 픽스처(JSON) + 역직렬화 테스트 (sbizCd 소문자, 빈 문자열 케이스) Co-Authored-By: Claude Fable 5 * feat: 온통청년 정책 API 클라이언트 추가 (plan 1-2, 1-3) - PolicyApiClient: pageSize=1000 페이지 순회 + 누적==totCount 검증, 성공 3중 체크(HTTP/JSON파싱/resultCode), 페이지당 3회 재시도 후 회차 중단 - PolicySyncException: 배치 내부 실패 전용 (Job이 잡아 FAILED 기록) - 타임아웃 spring.http.client.*, 키는 YOUTH_API_KEY 환경변수 주입(.env.example) - MockRestServiceServer 테스트 6종: 성공/403 errorCode/HTML/resultCode!=200/재시도/키 미설정 Co-Authored-By: Claude Fable 5 * chore: spotless 재적용 — 기존 파일 포맷 정규화 Co-Authored-By: Claude Fable 5 * feat: 정책 전처리 PolicyMapper 추가 (plan 2-1) Co-Authored-By: Claude Fable 5 * feat: zipCd를 PolicyRegion으로 정규화 (plan 2-2) Co-Authored-By: Claude Fable 5 * docs: zipCd 코드 체계 주석 정정 — 온통청년 자체 코드가 아니라 현행 법정시군구코드 Co-Authored-By: Claude Fable 5 * feat: 배치 비교용 정책 스냅샷 조회 및 배치 이력 Repository 추가 (3-1) - PolicyRepository.findSyncSnapshots(): 경량 projection(policyNo, lastMdfcnDt, visibility)으로 전체 정책 스냅샷 조회. HIDDEN 포함 — VISIBLE만 조회하면 재등장 정책이 신규로 분류돼 policy_no 유니크 위반이 나므로 전체 조회로 설계 변경 - PolicyBatchHistoryRepository 추가 (조회 메서드는 Phase 5에서) - @DataJpaTest: 스냅샷 필드 검증 + lastMdfcnDt null 정책 + 빈 테이블 Co-Authored-By: Claude Fable 5 * feat: 정책 청크 업서트 Writer 및 누락 3회 숨김 규칙 추가 (3-2) - Policy: missingCount 필드 + markMissing(3회 도달 시 HIDDEN), updateFrom(전 필드 갱신 + 누락 상태 리셋), copyOf(재시도용 비영속 복사본) - PolicyUpsertWriter: 청크(100) 단위 트랜잭션 커밋, policyNo 조회로 신규 INSERT/변경 UPDATE 분기, 지역 링크 교체(delete 선-flush로 유니크 충돌 방지), 청크 실패 시 건별 재시도로 범인 격리(error_count), hideMissing으로 사라진 정책 누락 집계 - TDD(Red-Green)로 진행. 잡은 버그: 청크 롤백 후 IDENTITY id가 엔티티에 남아 재시도 저장이 실패 → copyOf 복사본으로 해결 - 테스트: PolicyTest(단위 3), PolicyUpsertWriterTest(@DataJpaTest 4 — 신규/변경/범인격리/숨김), 스냅샷 HIDDEN 포함 검증, PolicyFixture 공용화 Co-Authored-By: Claude Fable 5 * feat: 정책 전량 수집 오케스트레이션 구현 (4-1) - PolicySyncService.runFullSync 본 구현: 이력 REQUESTED→RUNNING → 전량 fetch → 스냅샷/지역 각 1쿼리 로드 → 메모리 비교(신규/변경/SKIP) → 청크 upsert → 누락 처리 → 이력 SUCCEEDED. fetch 실패 시 DB 무손상 FAILED 기록 후 예외 재던짐 - SKIP 조건은 수정일 동일 + VISIBLE — HIDDEN이 같은 수정일로 재등장하면 변경으로 취급해 updateFrom이 복구(3-1 스냅샷 visibility 활용) - PolicyBatchHistory: request/start/succeed/fail 상태 전이 메서드 (fail 메시지 1000자 절단), 단위 테스트 5건 - PolicyMapper.toRegions → resolveRegions(List) 리팩토링 — PolicyRegion 링크 생성 책임을 Writer로 일원화 - 통합 테스트 3건(@DataJpaTest + 목 클라이언트): 성공 4분기+이력, 회차 실패(DB 무손상+FAILED), HIDDEN 재등장 복구 Co-Authored-By: Claude Fable 5 * feat: 정책 수집 배치 중복 실행 가드(Redis lock) 추가 - PolicySyncLock: SETNX+TTL(10분)+UUID 소유 토큰, 해제는 Lua 스크립트로 소유 확인+삭제 원자 처리 (RefreshTokenStore와 동일 패턴) - runFullSync: 락 획득 실패 시 이력 없이 PolicySyncException, finally 해제 - 테스트: 락 획득/거부/해제 단위 3건 + 서비스 통합(거부 시 무동작, 실패 회차에도 해제) Co-Authored-By: Claude Fable 5 * feat: 정책 수집 일일 스케줄러 추가 (설정 on/off, 기본 off) - PolicySyncScheduler: @Scheduled cron(기본 새벽 4시), 예외는 로그만 남기고 삼킴 - @ConditionalOnProperty(youthpick.sync.scheduler.enabled) — 로컬 기본 off - @EnableScheduling을 스케줄러 클래스에 부착해 off면 스케줄링 인프라도 미기동 - 테스트: 조건부 빈 등록 2건 + 예외 무전파 1건 Co-Authored-By: Claude Fable 5 * feat: 정책 수집 수동 트리거 관리자 API 추가 (202/409) - POST /api/v1/admin/batch/policy-sync: 락 동기 획득 후 백그라운드 실행, 즉시 202 - 실행 중이면 SyncErrorCode.SYNC_ALREADY_RUNNING(SY001) 409 - 백그라운드 실패는 이력(FAILED)+로그만, 예외 무전파 - GET status는 미구현 - dev의 GET /admin/batch-job-logs(#57)가 상위호환으로 대체 - 테스트: 컨트롤러 202/409 + 서비스 락거부/성공/백그라운드실패 5건 (TDD) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .env.example | 4 + .../policy/dto/PolicySyncSnapshot.java | 13 + .../bop/youthpick/policy/entity/Policy.java | 177 +++++++++++++ .../bop/youthpick/policy/entity/Region.java | 8 + .../repository/PolicyRegionRepository.java | 3 + .../policy/repository/PolicyRepository.java | 12 + .../controller/AdminPolicySyncController.java | 27 ++ .../youthpick/sync/dto/PolicyUpsertItem.java | 11 + .../youthpick/sync/dto/PolicyWriteResult.java | 4 + .../sync/dto/YouthPolicyApiResponse.java | 28 ++ .../youthpick/sync/dto/YouthPolicyItem.java | 74 ++++++ .../sync/entity/PolicyBatchHistory.java | 35 +++ .../sync/exception/PolicySyncException.java | 16 ++ .../sync/exception/SyncErrorCode.java | 17 ++ .../sync/service/PolicyApiClient.java | 119 +++++++++ .../youthpick/sync/service/PolicyMapper.java | 218 ++++++++++++++++ .../sync/service/PolicySyncLock.java | 51 ++++ .../sync/service/PolicySyncScheduler.java | 34 +++ .../sync/service/PolicySyncService.java | 166 ++++++++++++ .../sync/service/PolicySyncStartupRunner.java | 40 +++ .../sync/service/PolicyUpsertWriter.java | 142 ++++++++++ src/main/resources/application-local.yml | 4 +- src/main/resources/application.yml | 14 +- .../policy/entity/PolicyFixture.java | 59 +++++ .../youthpick/policy/entity/PolicyTest.java | 49 ++++ .../repository/PolicyRepositoryTest.java | 50 ++++ .../AdminPolicySyncControllerTest.java | 46 ++++ .../sync/dto/YouthPolicyApiResponseTest.java | 76 ++++++ .../sync/entity/PolicyBatchHistoryTest.java | 66 +++++ .../sync/service/PolicyApiClientTest.java | 140 ++++++++++ .../sync/service/PolicyMapperTest.java | 239 +++++++++++++++++ .../sync/service/PolicySyncLockTest.java | 69 +++++ .../sync/service/PolicySyncSchedulerTest.java | 43 +++ .../sync/service/PolicySyncServiceTest.java | 245 ++++++++++++++++++ .../service/PolicySyncStartupRunnerTest.java | 43 +++ .../sync/service/PolicyUpsertWriterTest.java | 133 ++++++++++ .../fixtures/youth-policy-response.json | 107 ++++++++ 37 files changed, 2579 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicySyncSnapshot.java create mode 100644 src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncController.java create mode 100644 src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java create mode 100644 src/main/java/com/bop/youthpick/sync/dto/PolicyWriteResult.java create mode 100644 src/main/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponse.java create mode 100644 src/main/java/com/bop/youthpick/sync/dto/YouthPolicyItem.java create mode 100644 src/main/java/com/bop/youthpick/sync/exception/PolicySyncException.java create mode 100644 src/main/java/com/bop/youthpick/sync/exception/SyncErrorCode.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/PolicySyncLock.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java create mode 100644 src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java create mode 100644 src/test/java/com/bop/youthpick/policy/entity/PolicyFixture.java create mode 100644 src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponseTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicyApiClientTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicySyncLockTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicySyncSchedulerTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicySyncStartupRunnerTest.java create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java create mode 100644 src/test/resources/fixtures/youth-policy-response.json diff --git a/.env.example b/.env.example index 8acbb72..6609efb 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ JPA_DDL_AUTO=validate REDIS_HOST=localhost REDIS_PORT=6379 +# ===== 온통청년 정책 API (배치 수집용 — 비우면 배치만 실패하고 앱은 뜬다) ===== +# 발급: https://www.youthcenter.go.kr 회원가입 후 open api 인증키 신청 +YOUTH_API_KEY= + # ===== OAuth (인증 기능 구현 시 채운다. 지금은 비워둬도 앱이 뜬다) ===== # Kakao 는 "REST API 키" 를 client-id 로 사용한다(JavaScript 키 아님). # 각 provider 콘솔에 redirect_uri 로 "OAUTH_FRONTEND_CALLBACK_URI" 값을 그대로 등록해야 한다(프론트 라우트). diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicySyncSnapshot.java b/src/main/java/com/bop/youthpick/policy/dto/PolicySyncSnapshot.java new file mode 100644 index 0000000..36d2649 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicySyncSnapshot.java @@ -0,0 +1,13 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.PolicyVisibility; +import java.time.LocalDateTime; + +/** + * 배치 비교용 정책 스냅샷 (plcyNo → 변경 감지 기준). 전 필드 로드를 피하려는 경량 projection — rawPayload(LONGTEXT) 등을 수천 건 + * 메모리에 올리지 않기 위함. + * + *

HIDDEN 정책도 포함한다: VISIBLE만 조회하면 API에 재등장한 숨김 정책이 "신규"로 분류되어 policy_no 유니크 제약 위반으로 INSERT가 터진다. + */ +public record PolicySyncSnapshot( + String policyNo, LocalDateTime lastModifiedAt, PolicyVisibility visibility) {} diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java index c64d0ad..aa64933 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Policy.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -235,10 +235,187 @@ public class Policy extends BaseEntity { @Column(length = 20, nullable = false) private PolicyVisibility visibility = PolicyVisibility.VISIBLE; + /** 수집에서 안 보인 연속 횟수 — 3회 도달 시 HIDDEN, 재등장 시 0 리셋 (V2, 기획 §6) */ + @Column(name = "missing_count", nullable = false) + private int missingCount; + + private static final int MISSING_THRESHOLD = 3; + + /** + * id 없는 새 비영속 복사본. 청크 트랜잭션이 롤백돼도 IDENTITY가 이미 부여한 id는 엔티티 객체에 남으므로, 건별 재시도는 원본 재사용 대신 이 복사본으로 + * INSERT해야 한다. + */ + public static Policy copyOf(Policy source) { + Policy policy = new Policy(); + policy.updateFrom(source); + return policy; + } + + /** 이번 수집에서 안 보였음 — 연속 3회 도달 시 숨김 (즉시 숨김 금지, 기획 §6). */ + public void markMissing() { + missingCount++; + if (missingCount >= MISSING_THRESHOLD) { + visibility = PolicyVisibility.HIDDEN; + } + } + + /** + * 수집된 최신 내용으로 전 필드 갱신 + 누락 상태 리셋(재등장 포함). 필드 나열 순서 = 선언 순서 — {@link #create} 규약과 동일하게 + * PolicyMapperTest/PolicyTest가 누락을 잡는다. + */ + public void updateFrom(Policy source) { + this.policyNo = source.policyNo; + this.title = source.title; + this.description = source.description; + this.supportContent = source.supportContent; + this.keywords = source.keywords; + this.category = source.category; + this.middleCategory = source.middleCategory; + this.organizationName = source.organizationName; + this.minAge = source.minAge; + this.maxAge = source.maxAge; + this.jobCodes = source.jobCodes; + this.schoolCodes = source.schoolCodes; + this.incomeConditionCode = source.incomeConditionCode; + this.incomeMaxAmount = source.incomeMaxAmount; + this.incomeEtcContent = source.incomeEtcContent; + this.maritalStatusCode = source.maritalStatusCode; + this.majorCodes = source.majorCodes; + this.specializationCodes = source.specializationCodes; + this.additionalQualification = source.additionalQualification; + this.participationRestriction = source.participationRestriction; + this.applicationPeriodType = source.applicationPeriodType; + this.applicationPeriodRaw = source.applicationPeriodRaw; + this.applicationStartDate = source.applicationStartDate; + this.applicationEndDate = source.applicationEndDate; + this.businessPeriodBegin = source.businessPeriodBegin; + this.businessPeriodEnd = source.businessPeriodEnd; + this.businessPeriodEtc = source.businessPeriodEtc; + this.supportScaleCount = source.supportScaleCount; + this.firstComeFirstServed = source.firstComeFirstServed; + this.applicationUrl = source.applicationUrl; + this.referenceUrl1 = source.referenceUrl1; + this.referenceUrl2 = source.referenceUrl2; + this.applicationMethod = source.applicationMethod; + this.submissionDocuments = source.submissionDocuments; + this.screeningMethod = source.screeningMethod; + this.ageLimitFlag = source.ageLimitFlag; + this.incomeMinAmount = source.incomeMinAmount; + this.supportScaleLimit = source.supportScaleLimit; + this.operatingInstitutionName = source.operatingInstitutionName; + this.approvalStatusCode = source.approvalStatusCode; + this.etcMatters = source.etcMatters; + this.viewCount = source.viewCount; + this.firstRegisteredAt = source.firstRegisteredAt; + this.lastModifiedAt = source.lastModifiedAt; + this.rawPayload = source.rawPayload; + this.missingCount = 0; + this.visibility = PolicyVisibility.VISIBLE; + } + /** API 응답 원문 JSON — 스키마 진화 시 백필용 보험 */ @Column(name = "raw_payload", columnDefinition = "LONGTEXT") private String rawPayload; + /** + * 배치 전처리(PolicyMapper) 전용 생성 통로. 파라미터 순서 = 필드 선언 순서 — 인접 String이 많아 순서가 바뀌어도 컴파일은 통과하므로, 필드를 + * 추가/삭제할 때 반드시 선언 순서를 유지하고 PolicyMapperTest의 전 필드 검증으로 확인한다. + */ + public static Policy create( + String policyNo, + String title, + String description, + String supportContent, + String keywords, + String category, + String middleCategory, + String organizationName, + Integer minAge, + Integer maxAge, + String jobCodes, + String schoolCodes, + String incomeConditionCode, + Integer incomeMaxAmount, + String incomeEtcContent, + String maritalStatusCode, + String majorCodes, + String specializationCodes, + String additionalQualification, + String participationRestriction, + String applicationPeriodType, + String applicationPeriodRaw, + LocalDate applicationStartDate, + LocalDate applicationEndDate, + LocalDate businessPeriodBegin, + LocalDate businessPeriodEnd, + String businessPeriodEtc, + Integer supportScaleCount, + boolean firstComeFirstServed, + String applicationUrl, + String referenceUrl1, + String referenceUrl2, + String applicationMethod, + String submissionDocuments, + String screeningMethod, + String ageLimitFlag, + Integer incomeMinAmount, + Boolean supportScaleLimit, + String operatingInstitutionName, + String approvalStatusCode, + String etcMatters, + int viewCount, + LocalDateTime firstRegisteredAt, + LocalDateTime lastModifiedAt, + String rawPayload) { + Policy policy = new Policy(); + policy.policyNo = policyNo; + policy.title = title; + policy.description = description; + policy.supportContent = supportContent; + policy.keywords = keywords; + policy.category = category; + policy.middleCategory = middleCategory; + policy.organizationName = organizationName; + policy.minAge = minAge; + policy.maxAge = maxAge; + policy.jobCodes = jobCodes; + policy.schoolCodes = schoolCodes; + policy.incomeConditionCode = incomeConditionCode; + policy.incomeMaxAmount = incomeMaxAmount; + policy.incomeEtcContent = incomeEtcContent; + policy.maritalStatusCode = maritalStatusCode; + policy.majorCodes = majorCodes; + policy.specializationCodes = specializationCodes; + policy.additionalQualification = additionalQualification; + policy.participationRestriction = participationRestriction; + policy.applicationPeriodType = applicationPeriodType; + policy.applicationPeriodRaw = applicationPeriodRaw; + policy.applicationStartDate = applicationStartDate; + policy.applicationEndDate = applicationEndDate; + policy.businessPeriodBegin = businessPeriodBegin; + policy.businessPeriodEnd = businessPeriodEnd; + policy.businessPeriodEtc = businessPeriodEtc; + policy.supportScaleCount = supportScaleCount; + policy.firstComeFirstServed = firstComeFirstServed; + policy.applicationUrl = applicationUrl; + policy.referenceUrl1 = referenceUrl1; + policy.referenceUrl2 = referenceUrl2; + policy.applicationMethod = applicationMethod; + policy.submissionDocuments = submissionDocuments; + policy.screeningMethod = screeningMethod; + policy.ageLimitFlag = ageLimitFlag; + policy.incomeMinAmount = incomeMinAmount; + policy.supportScaleLimit = supportScaleLimit; + policy.operatingInstitutionName = operatingInstitutionName; + policy.approvalStatusCode = approvalStatusCode; + policy.etcMatters = etcMatters; + policy.viewCount = viewCount; + policy.firstRegisteredAt = firstRegisteredAt; + policy.lastModifiedAt = lastModifiedAt; + policy.rawPayload = rawPayload; + return policy; + } + /** 관리자 soft delete 시각. {@link #visibility}(배치의 재노출 가능한 상태 전환)와는 독립적이다. */ @Column(name = "deleted_at") private LocalDateTime deletedAt; diff --git a/src/main/java/com/bop/youthpick/policy/entity/Region.java b/src/main/java/com/bop/youthpick/policy/entity/Region.java index 6337606..7b44b0e 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Region.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Region.java @@ -24,4 +24,12 @@ public class Region { @Column(length = 50, nullable = false) private String name; + + public static Region create(String code, String sidoName, String name) { + Region region = new Region(); + region.code = code; + region.sidoName = sidoName; + region.name = name; + return region; + } } diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java index 49fc868..a9d903d 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java @@ -1,5 +1,6 @@ package com.bop.youthpick.policy.repository; +import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyRegion; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,6 +10,8 @@ public interface PolicyRegionRepository extends JpaRepository { + void deleteByPolicy(Policy policy); + List findByPolicyIdIn(List policyIds); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index d60f670..f7c9732 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -1,12 +1,24 @@ package com.bop.youthpick.policy.repository; +import com.bop.youthpick.policy.dto.PolicySyncSnapshot; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; +import java.util.Collection; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; public interface PolicyRepository extends JpaRepository, JpaSpecificationExecutor { + /** 배치 비교용 전체 스냅샷 — HIDDEN 포함 (제외 이유는 {@link PolicySyncSnapshot} 참고). */ + @Query( + "select new com.bop.youthpick.policy.dto.PolicySyncSnapshot(" + + "p.policyNo, p.lastModifiedAt, p.visibility) from Policy p") + List findSyncSnapshots(); + + List findByPolicyNoIn(Collection policyNos); + long countByVisibilityAndDeletedAtIsNull(PolicyVisibility visibility); } diff --git a/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncController.java b/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncController.java new file mode 100644 index 0000000..30289d1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncController.java @@ -0,0 +1,27 @@ +package com.bop.youthpick.sync.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.sync.service.PolicySyncService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/batch/policy-sync") +@RequiredArgsConstructor +public class AdminPolicySyncController { + + private final PolicySyncService policySyncService; + + /** + * 정책 수집을 비동기로 시작시키고 즉시 202를 반환한다. 이미 실행 중이면 {@code SY001} 409 (GlobalExceptionHandler 변환). 결과 + * 확인은 {@code GET /api/v1/admin/batch-job-logs}(#57)로 한다. + */ + @PostMapping + public ResponseEntity> trigger() { + policySyncService.startFullSyncAsync(); + return ResponseEntity.accepted().body(ApiResponse.ok(null)); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java b/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java new file mode 100644 index 0000000..bc13e42 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.sync.dto; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.Region; +import java.util.List; + +/** + * Writer 입력 1건 — 전처리된 정책(비영속) + 적용 지역. 지역을 {@code PolicyRegion}이 아니라 {@code Region}으로 받는 이유: 변경 + * UPDATE 경로에서는 링크를 DB의 관리 엔티티에 다시 걸어야 하므로 링크 생성은 Writer의 몫이다. + */ +public record PolicyUpsertItem(Policy policy, List regions) {} diff --git a/src/main/java/com/bop/youthpick/sync/dto/PolicyWriteResult.java b/src/main/java/com/bop/youthpick/sync/dto/PolicyWriteResult.java new file mode 100644 index 0000000..efb3b1b --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/dto/PolicyWriteResult.java @@ -0,0 +1,4 @@ +package com.bop.youthpick.sync.dto; + +/** Writer 실행 집계 — policy_batch_history 카운트 기록용. */ +public record PolicyWriteResult(int newCount, int updatedCount, int errorCount) {} diff --git a/src/main/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponse.java b/src/main/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponse.java new file mode 100644 index 0000000..9bdc0a4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponse.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.sync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * 온통청년 정책 API 응답 봉투 (docs/온통청년-API-명세.md). 성공 판정은 resultCode==200 && youthPolicyList 존재 — + * PolicyApiClient(1-2)에서 수행. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record YouthPolicyApiResponse( + @JsonProperty("resultCode") Integer resultCode, + @JsonProperty("resultMessage") String resultMessage, + @JsonProperty("result") Result result) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Result( + @JsonProperty("pagging") Paging paging, + @JsonProperty("youthPolicyList") List youthPolicyList) {} + + /** API 오타 그대로 "pagging" — 우리 쪽 이름만 Paging으로 교정 */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Paging( + @JsonProperty("totCount") Integer totCount, + @JsonProperty("pageNum") Integer pageNum, + @JsonProperty("pageSize") Integer pageSize) {} +} diff --git a/src/main/java/com/bop/youthpick/sync/dto/YouthPolicyItem.java b/src/main/java/com/bop/youthpick/sync/dto/YouthPolicyItem.java new file mode 100644 index 0000000..1f49ed7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/dto/YouthPolicyItem.java @@ -0,0 +1,74 @@ +package com.bop.youthpick.sync.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 온통청년 정책 1건 — API 원문을 String 그대로 보존한다. 타입 변환(숫자/날짜/Boolean)은 PolicyMapper(Phase 2)의 책임. 모든 + * 필드에 @JsonProperty 명시: 키 오타 시 조용히 null이 되는 사고 방지. 주의: 명세서 표기는 sBizCd지만 실제 JSON 키는 소문자 sbizCd (명세 + * §6-1). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record YouthPolicyItem( + // 식별·표시 + @JsonProperty("plcyNo") String plcyNo, + @JsonProperty("plcyNm") String plcyNm, + @JsonProperty("plcyExplnCn") String plcyExplnCn, + @JsonProperty("plcySprtCn") String plcySprtCn, + + // 분류·검색 + @JsonProperty("lclsfNm") String lclsfNm, + @JsonProperty("mclsfNm") String mclsfNm, + @JsonProperty("plcyKywdNm") String plcyKywdNm, + + // 지역 (→ PolicyRegion 정규화) + @JsonProperty("zipCd") String zipCd, + + // 자격 — 나이·소득 + @JsonProperty("sprtTrgtMinAge") String sprtTrgtMinAge, + @JsonProperty("sprtTrgtMaxAge") String sprtTrgtMaxAge, + @JsonProperty("sprtTrgtAgeLmtYn") String sprtTrgtAgeLmtYn, + @JsonProperty("earnCndSeCd") String earnCndSeCd, + @JsonProperty("earnMinAmt") String earnMinAmt, + @JsonProperty("earnMaxAmt") String earnMaxAmt, + @JsonProperty("earnEtcCn") String earnEtcCn, + + // 자격 — 코드 (콤마 다중값 원문 보존) + @JsonProperty("jobCd") String jobCd, + @JsonProperty("schoolCd") String schoolCd, + @JsonProperty("mrgSttsCd") String mrgSttsCd, + @JsonProperty("plcyMajorCd") String plcyMajorCd, + @JsonProperty("sbizCd") String sbizCd, + + // 상태·기간 + @JsonProperty("aplyPrdSeCd") String aplyPrdSeCd, + @JsonProperty("aplyYmd") String aplyYmd, + @JsonProperty("bizPrdBgngYmd") String bizPrdBgngYmd, + @JsonProperty("bizPrdEndYmd") String bizPrdEndYmd, + @JsonProperty("bizPrdEtcCn") String bizPrdEtcCn, + + // 규모 + @JsonProperty("sprtSclCnt") String sprtSclCnt, + @JsonProperty("sprtArvlSeqYn") String sprtArvlSeqYn, + @JsonProperty("sprtSclLmtYn") String sprtSclLmtYn, + + // 링크·신청 + @JsonProperty("aplyUrlAddr") String aplyUrlAddr, + @JsonProperty("refUrlAddr1") String refUrlAddr1, + @JsonProperty("refUrlAddr2") String refUrlAddr2, + @JsonProperty("sbmsnDcmntCn") String sbmsnDcmntCn, + @JsonProperty("plcyAplyMthdCn") String plcyAplyMthdCn, + @JsonProperty("sprvsnInstCdNm") String sprvsnInstCdNm, + @JsonProperty("operInstCdNm") String operInstCdNm, + + // 상세 보조 + @JsonProperty("addAplyQlfcCndCn") String addAplyQlfcCndCn, + @JsonProperty("ptcpPrpTrgtCn") String ptcpPrpTrgtCn, + @JsonProperty("srngMthdCn") String srngMthdCn, + @JsonProperty("etcMttrCn") String etcMttrCn, + @JsonProperty("plcyAprvSttsCd") String plcyAprvSttsCd, + + // 메타·정렬 + @JsonProperty("inqCnt") String inqCnt, + @JsonProperty("frstRegDt") String frstRegDt, + @JsonProperty("lastMdfcnDt") String lastMdfcnDt) {} diff --git a/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java index 5d357d4..eaf2dee 100644 --- a/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java +++ b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java @@ -66,4 +66,39 @@ public class PolicyBatchHistory { @Column(name = "failure_message", length = 1000) private String failureMessage; + + private static final int FAILURE_MESSAGE_MAX = 1000; + + public static PolicyBatchHistory request(BatchMode mode) { + PolicyBatchHistory history = new PolicyBatchHistory(); + history.mode = mode; + history.status = BatchStatus.REQUESTED; + history.requestedAt = LocalDateTime.now(); + return history; + } + + public void start() { + this.status = BatchStatus.RUNNING; + this.startedAt = LocalDateTime.now(); + } + + public void succeed( + int newCount, int updatedCount, int unchangedCount, int missingCount, int errorCount) { + this.status = BatchStatus.SUCCEEDED; + this.finishedAt = LocalDateTime.now(); + this.newCount = newCount; + this.updatedCount = updatedCount; + this.unchangedCount = unchangedCount; + this.missingCount = missingCount; + this.errorCount = errorCount; + } + + public void fail(String message) { + this.status = BatchStatus.FAILED; + this.finishedAt = LocalDateTime.now(); + this.failureMessage = + message != null && message.length() > FAILURE_MESSAGE_MAX + ? message.substring(0, FAILURE_MESSAGE_MAX) + : message; + } } diff --git a/src/main/java/com/bop/youthpick/sync/exception/PolicySyncException.java b/src/main/java/com/bop/youthpick/sync/exception/PolicySyncException.java new file mode 100644 index 0000000..c209655 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/exception/PolicySyncException.java @@ -0,0 +1,16 @@ +package com.bop.youthpick.sync.exception; + +/** + * 배치 내부 실패 (fetch 실패, 건수 불일치 등) — 회차 전체 중단용. HTTP 응답으로 나가는 예외가 아니라 PolicySyncJob이 잡아 이력을 FAILED로 + * 기록한다. 관리자 API용 에러코드(409 등)는 Phase 5에서 별도 정의. + */ +public class PolicySyncException extends RuntimeException { + + public PolicySyncException(String message) { + super(message); + } + + public PolicySyncException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/exception/SyncErrorCode.java b/src/main/java/com/bop/youthpick/sync/exception/SyncErrorCode.java new file mode 100644 index 0000000..8164dd2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/exception/SyncErrorCode.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.sync.exception; + +import com.bop.youthpick.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** 배치 관리자 API 에러코드. 접두어 SY(sync)는 error-handling.md 표에 없어 PR 본문에서 추가를 제안한다. */ +@Getter +@RequiredArgsConstructor +public enum SyncErrorCode implements ErrorCode { + SYNC_ALREADY_RUNNING("SY001", "정책 수집이 이미 실행 중입니다.", HttpStatus.CONFLICT); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java b/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java new file mode 100644 index 0000000..51315e8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java @@ -0,0 +1,119 @@ +package com.bop.youthpick.sync.service; + +import com.bop.youthpick.sync.dto.YouthPolicyApiResponse; +import com.bop.youthpick.sync.dto.YouthPolicyItem; +import com.bop.youthpick.sync.exception.PolicySyncException; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +/** + * 온통청년 정책 API 클라이언트 [Reader]. pageSize=1000 페이지네이션으로 전량 수집하고 누적 수신 == totCount를 검증한다. + * + *

성공 3중 체크: ① HTTP 200 (4xx/5xx는 RestClient가 예외) → ② JSON 파싱 (HTML 응답은 여기서 실패) → ③ + * resultCode==200 & youthPolicyList 존재. 페이지당 {@value MAX_ATTEMPTS}회 시도 후 실패 시 PolicySyncException으로 + * 회차 전체를 중단시킨다 (배치 설계 §5 — fetch 실패 시 DB 손 안 댐). + * + *

타임아웃은 spring.http.client.* 설정이 자동구성 RestClient.Builder에 적용한다 (application.yml). + */ +@Component +public class PolicyApiClient { + + private static final Logger log = LoggerFactory.getLogger(PolicyApiClient.class); + + private static final int PAGE_SIZE = 1000; + private static final int MAX_ATTEMPTS = 3; + + private final RestClient restClient; + private final String apiKey; + + public PolicyApiClient( + RestClient.Builder restClientBuilder, + @Value("${youthpick.sync.base-url:https://www.youthcenter.go.kr}") String baseUrl, + @Value("${youthpick.sync.api-key:}") String apiKey) { + this.restClient = restClientBuilder.baseUrl(baseUrl).build(); + this.apiKey = apiKey; + } + + /** 전량 수집. 누적 수신 건수가 totCount와 다르면 예외 (누락된 채 비교/HIDDEN 처리하는 사고 방지). */ + public List fetchAll() { + if (apiKey.isBlank()) { + throw new PolicySyncException("온통청년 API 키가 없습니다 — 환경변수 YOUTH_API_KEY를 설정하세요"); + } + List all = new ArrayList<>(); + int totCount; + int pageNum = 1; + do { + YouthPolicyApiResponse.Result result = fetchPageWithRetry(pageNum++); + totCount = result.paging().totCount(); + if (result.youthPolicyList().isEmpty() && all.size() < totCount) { + throw new PolicySyncException( + "빈 페이지 수신 — 누적 %d건, totCount %d건 (API 응답 이상)" + .formatted(all.size(), totCount)); + } + all.addAll(result.youthPolicyList()); + } while (all.size() < totCount); + + if (all.size() != totCount) { + throw new PolicySyncException( + "수신 건수 불일치 — 누적 %d건 != totCount %d건".formatted(all.size(), totCount)); + } + log.info("정책 전량 수집 완료 — {}건 ({}페이지)", all.size(), pageNum - 1); + return all; + } + + private YouthPolicyApiResponse.Result fetchPageWithRetry(int pageNum) { + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return requestPage(pageNum); + } catch (RestClientException | PolicySyncException e) { + // 키 값은 로그 금지 — 예외 메시지에도 URL 전체를 싣지 않는다 + log.warn( + "정책 API {}페이지 요청 실패 ({}/{}): {}", + pageNum, + attempt, + MAX_ATTEMPTS, + e.getMessage()); + lastFailure = e; + } + } + throw new PolicySyncException( + "정책 API %d페이지 요청이 %d회 모두 실패 — 회차 중단".formatted(pageNum, MAX_ATTEMPTS), lastFailure); + } + + private YouthPolicyApiResponse.Result requestPage(int pageNum) { + YouthPolicyApiResponse response = + restClient + .get() + .uri( + uri -> + uri.path("/go/ythip/getPlcy") + .queryParam("apiKeyNm", apiKey) + .queryParam("rtnType", "json") + .queryParam("pageType", "1") + .queryParam("pageNum", pageNum) + .queryParam("pageSize", PAGE_SIZE) + .build()) + .retrieve() + .body(YouthPolicyApiResponse.class); + if (response == null + || response.resultCode() == null + || response.resultCode() != 200 + || response.result() == null + || response.result().paging() == null + || response.result().paging().totCount() == null + || response.result().youthPolicyList() == null) { + throw new PolicySyncException( + "정책 API 응답이 성공 형식이 아닙니다 (resultCode=" + + (response == null ? "null" : response.resultCode()) + + ")"); + } + return response.result(); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java new file mode 100644 index 0000000..29fe417 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java @@ -0,0 +1,218 @@ +package com.bop.youthpick.sync.service; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.sync.dto.YouthPolicyItem; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 온통청년 raw DTO → Policy 엔티티 전처리 [Processor] (배치 설계 §4-1, 서비스 설계 §5-2). + * + *

전처리 규칙: 공백 문자열 → null trim, 숫자/날짜 문자열 → 타입 변환, "Y"/"N"/"" → Boolean(빈값 false), + * aplyYmd("yyyyMMdd ~ yyyyMMdd") → 신청 시작/마감일 (상시 0057002·빈값은 파싱 스킵). + * + *

파싱 실패는 예외로 안 터뜨린다 — 해당 필드만 null + 경고 로그(plcyNo, 필드, 원문). 필드 하나 깨졌다고 정책 1건 전체(나머지 40여 필드)를 버리는 + * 게 더 큰 손실이기 때문(설계 §5 실패 격리: 전처리 실패 단위 = 필드). + */ +@Component +@RequiredArgsConstructor +public class PolicyMapper { + + private static final Logger log = LoggerFactory.getLogger(PolicyMapper.class); + + /** aplyPrdSeCd 상시 코드 — 신청기간 파싱 대상 아님 */ + private static final String ALWAYS_OPEN_CODE = "0057002"; + + private static final DateTimeFormatter YMD = DateTimeFormatter.BASIC_ISO_DATE; + private static final DateTimeFormatter DATE_TIME = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final ObjectMapper objectMapper; + + /** 신규/변경 정책 1건 전처리. Policy.create 파라미터 순서 = Policy 필드 선언 순서. */ + public Policy toEntity(YouthPolicyItem item) { + String plcyNo = trimToNull(item.plcyNo()); + ApplyPeriod applyPeriod = parseApplyPeriod(plcyNo, item.aplyPrdSeCd(), item.aplyYmd()); + return Policy.create( + plcyNo, + trimToNull(item.plcyNm()), + trimToNull(item.plcyExplnCn()), + trimToNull(item.plcySprtCn()), + trimToNull(item.plcyKywdNm()), + trimToNull(item.lclsfNm()), + trimToNull(item.mclsfNm()), + organizationName(item), + toInt(plcyNo, "sprtTrgtMinAge", item.sprtTrgtMinAge()), + toInt(plcyNo, "sprtTrgtMaxAge", item.sprtTrgtMaxAge()), + trimToNull(item.jobCd()), + trimToNull(item.schoolCd()), + trimToNull(item.earnCndSeCd()), + toInt(plcyNo, "earnMaxAmt", item.earnMaxAmt()), + trimToNull(item.earnEtcCn()), + trimToNull(item.mrgSttsCd()), + trimToNull(item.plcyMajorCd()), + trimToNull(item.sbizCd()), + trimToNull(item.addAplyQlfcCndCn()), + trimToNull(item.ptcpPrpTrgtCn()), + trimToNull(item.aplyPrdSeCd()), + trimToNull(item.aplyYmd()), + applyPeriod.start(), + applyPeriod.end(), + toDate(plcyNo, "bizPrdBgngYmd", item.bizPrdBgngYmd()), + toDate(plcyNo, "bizPrdEndYmd", item.bizPrdEndYmd()), + trimToNull(item.bizPrdEtcCn()), + toInt(plcyNo, "sprtSclCnt", item.sprtSclCnt()), + toBoolean(item.sprtArvlSeqYn()), + trimToNull(item.aplyUrlAddr()), + trimToNull(item.refUrlAddr1()), + trimToNull(item.refUrlAddr2()), + trimToNull(item.plcyAplyMthdCn()), + trimToNull(item.sbmsnDcmntCn()), + trimToNull(item.srngMthdCn()), + trimToNull(item.sprtTrgtAgeLmtYn()), + toInt(plcyNo, "earnMinAmt", item.earnMinAmt()), + toBoolean(item.sprtSclLmtYn()), + trimToNull(item.operInstCdNm()), + trimToNull(item.plcyAprvSttsCd()), + trimToNull(item.etcMttrCn()), + toViewCount(plcyNo, item.inqCnt()), + toDateTime(plcyNo, "frstRegDt", item.frstRegDt()), + toDateTime(plcyNo, "lastMdfcnDt", item.lastMdfcnDt()), + toRawPayload(plcyNo, item)); + } + + /** + * zipCd 콤마 목록 → Region 해석 (전국이면 256개 코드). 중복 코드는 1건으로 접고, regions에 없는 코드는 FK 위반을 막기 위해 경고 로그 + + * 스킵한다 — zipCd는 현행 법정시군구코드 5자리(예: 전남광주통합특별시=12xxx)라 행정구역 개편으로 코드가 신설/폐지되면 시드에 없는 코드가 나타날 수 있다. + * + *

PolicyRegion 링크 생성은 Writer의 몫이다 — 변경 UPDATE 경로에서는 DB의 관리 엔티티에 링크를 걸어야 하므로 여기서 만들면 못 쓴다. + * + * @param regionsByCode 호출자(배치 Job)가 회차당 1회 로드한 지역 마스터 (code → Region) + */ + public List resolveRegions( + String policyNo, String zipCd, Map regionsByCode) { + String raw = trimToNull(zipCd); + if (raw == null) { + return List.of(); + } + Set seen = new LinkedHashSet<>(); + List regions = new ArrayList<>(); + for (String token : raw.split(",")) { + String code = trimToNull(token); + if (code == null || !seen.add(code)) { + continue; + } + Region region = regionsByCode.get(code); + if (region == null) { + log.warn("정책 {} 알 수 없는 지역코드 '{}' — 스킵 (regions 시드 갱신 필요)", policyNo, code); + continue; + } + regions.add(region); + } + return regions; + } + + private record ApplyPeriod(LocalDate start, LocalDate end) {} + + /** "yyyyMMdd ~ yyyyMMdd" 파싱. 상시(0057002)·빈값은 스킵(경고 없음), 형식 이상은 경고 + null. */ + private ApplyPeriod parseApplyPeriod(String plcyNo, String aplyPrdSeCd, String aplyYmd) { + String raw = trimToNull(aplyYmd); + if (raw == null || ALWAYS_OPEN_CODE.equals(trimToNull(aplyPrdSeCd))) { + return new ApplyPeriod(null, null); + } + String[] parts = raw.split("~"); + if (parts.length != 2) { + log.warn("정책 {} 필드 aplyYmd 형식 이상 — 원문 '{}'", plcyNo, raw); + return new ApplyPeriod(null, null); + } + return new ApplyPeriod( + toDate(plcyNo, "aplyYmd(시작일)", parts[0]), toDate(plcyNo, "aplyYmd(마감일)", parts[1])); + } + + /** sprvsnInstCdNm(주관기관) 우선, 비어 있으면 operInstCdNm(운영기관) fallback */ + private String organizationName(YouthPolicyItem item) { + String supervising = trimToNull(item.sprvsnInstCdNm()); + return supervising != null ? supervising : trimToNull(item.operInstCdNm()); + } + + /** 원문 보존 — 스키마 진화 시 백필용. DTO 재직렬화라 DTO에 없는 필드(버리는 17개)는 미보존. */ + private String toRawPayload(String plcyNo, YouthPolicyItem item) { + try { + return objectMapper.writeValueAsString(item); + } catch (JsonProcessingException e) { + log.warn("정책 {} raw payload 직렬화 실패: {}", plcyNo, e.getMessage()); + return null; + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Integer toInt(String plcyNo, String field, String value) { + String v = trimToNull(value); + if (v == null) { + return null; + } + try { + return Integer.valueOf(v); + } catch (NumberFormatException e) { + log.warn("정책 {} 필드 {} 숫자 파싱 실패 — 원문 '{}'", plcyNo, field, value); + return null; + } + } + + /** viewCount는 엔티티가 NOT NULL(int) — 파싱 실패/빈값은 0 */ + private static int toViewCount(String plcyNo, String value) { + Integer parsed = toInt(plcyNo, "inqCnt", value); + return parsed == null ? 0 : parsed; + } + + private static boolean toBoolean(String value) { + return "Y".equals(trimToNull(value)); + } + + private static LocalDate toDate(String plcyNo, String field, String value) { + String v = trimToNull(value); + if (v == null) { + return null; + } + try { + return LocalDate.parse(v, YMD); + } catch (DateTimeParseException e) { + log.warn("정책 {} 필드 {} 날짜 파싱 실패 — 원문 '{}'", plcyNo, field, value); + return null; + } + } + + private static LocalDateTime toDateTime(String plcyNo, String field, String value) { + String v = trimToNull(value); + if (v == null) { + return null; + } + try { + return LocalDateTime.parse(v, DATE_TIME); + } catch (DateTimeParseException e) { + log.warn("정책 {} 필드 {} 일시 파싱 실패 — 원문 '{}'", plcyNo, field, value); + return null; + } + } +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncLock.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncLock.java new file mode 100644 index 0000000..6c194c3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncLock.java @@ -0,0 +1,51 @@ +package com.bop.youthpick.sync.service; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +/** + * 정책 수집 배치의 중복 실행을 막는 Redis 락 (SETNX + TTL + 소유 토큰). + * + *

TTL은 락 소유자가 죽어도(OOM, kill) 락이 영원히 남지 않게 하는 안전장치이고, 소유 토큰은 TTL 만료 후 다른 실행이 잡은 락을 이전 실행이 해제하는 + * 사고를 막는다. 해제는 RefreshTokenStore와 같은 방식의 Lua 스크립트로 "소유 확인 + 삭제"를 원자 처리한다. + */ +@Component +@RequiredArgsConstructor +public class PolicySyncLock { + + static final String LOCK_KEY = "sync:policy-sync:lock"; + // 전량 수집이 수십 초 규모라 10분이면 충분히 여유 — 소유자가 죽었을 때 최대 이 시간만 잠김 + static final Duration LOCK_TTL = Duration.ofMinutes(10); + + private static final RedisScript RELEASE_SCRIPT = + new DefaultRedisScript<>( + """ + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) + else + return 0 + end + """, + Long.class); + + private final StringRedisTemplate redisTemplate; + + /** 락이 비어 있으면 획득하고 소유 토큰을 반환한다. 다른 실행이 쥐고 있으면 빈값. */ + public Optional tryAcquire() { + String token = UUID.randomUUID().toString(); + Boolean acquired = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, token, LOCK_TTL); + return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty(); + } + + /** 저장된 값이 자기 토큰일 때만 락을 삭제한다 — 남의 락은 건드리지 않는다. */ + public void release(String token) { + redisTemplate.execute(RELEASE_SCRIPT, List.of(LOCK_KEY), token); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java new file mode 100644 index 0000000..c78ff92 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java @@ -0,0 +1,34 @@ +package com.bop.youthpick.sync.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 정책 전량 수집을 매일 1회 실행하는 스케줄러. 로컬 기본 off — 팀원이 앱을 켤 때마다 실 API를 호출하는 사고 방지 + * (`youthpick.sync.scheduler.enabled`). + * + *

{@code @EnableScheduling}을 이 클래스에 두어 스케줄러가 꺼져 있으면 스케줄링 인프라 자체가 뜨지 않는다. + */ +@Slf4j +@Component +@EnableScheduling +@RequiredArgsConstructor +@ConditionalOnProperty(name = "youthpick.sync.scheduler.enabled", havingValue = "true") +public class PolicySyncScheduler { + + private final PolicySyncService policySyncService; + + /** 실패해도 예외를 던지지 않는다 — 결과는 policy_batch_history(FAILED)에 이미 남고, 다음 스케줄은 계속 돌아야 한다. */ + @Scheduled(cron = "${youthpick.sync.scheduler.cron}") + public void runDailySync() { + try { + policySyncService.runFullSync(); + } catch (RuntimeException e) { + log.error("정책 수집 스케줄 실행 실패 — 상세는 policy_batch_history 참조", e); + } + } +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java new file mode 100644 index 0000000..7810fe3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java @@ -0,0 +1,166 @@ +package com.bop.youthpick.sync.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicySyncSnapshot; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.sync.dto.PolicyUpsertItem; +import com.bop.youthpick.sync.dto.PolicyWriteResult; +import com.bop.youthpick.sync.dto.YouthPolicyItem; +import com.bop.youthpick.sync.entity.BatchMode; +import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import com.bop.youthpick.sync.exception.PolicySyncException; +import com.bop.youthpick.sync.exception.SyncErrorCode; +import com.bop.youthpick.sync.repository.PolicyBatchHistoryRepository; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; + +/** 온통청년 정책 전량 수집 배치. 상세 흐름: docs/2026-06-29-데이터수집-배치-설계.md (팀 루트 저장소) */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicySyncService { + + private final PolicySyncLock policySyncLock; + private final TaskExecutor taskExecutor; + private final PolicyApiClient policyApiClient; + private final PolicyMapper policyMapper; + private final PolicyUpsertWriter policyUpsertWriter; + private final PolicyRepository policyRepository; + private final RegionRepository regionRepository; + private final PolicyBatchHistoryRepository historyRepository; + + /** + * 전량 수집 1회 실행: 이력 REQUESTED→RUNNING → 전량 fetch → 스냅샷 비교(신규/변경/SKIP) → 청크 upsert → 누락 처리 → 이력 + * SUCCEEDED. fetch가 실패하면 DB에 손대지 않고 FAILED 기록 후 예외를 다시 던진다. + * + *

이 메서드 자체는 트랜잭션이 아니다 — 커밋 단위는 Writer의 청크와 이력 save 각각이다. + * + *

중복 실행은 Redis 락으로 거부한다. 거부된 시도는 회차가 아니므로 이력을 남기지 않는다. + */ + public PolicyBatchHistory runFullSync() { + String lockToken = + policySyncLock + .tryAcquire() + .orElseThrow(() -> new PolicySyncException("정책 수집이 이미 실행 중 — 중복 실행 거부")); + try { + return doRunFullSync(); + } finally { + policySyncLock.release(lockToken); + } + } + + /** + * 관리자 수동 실행. 락은 이 스레드에서 동기로 획득해 즉시 202/409를 판정할 수 있게 하고, 수십 초짜리 실행 본체는 백그라운드로 넘긴다(HTTP 응답이 배치를 + * 기다리면 타임아웃). 백그라운드 실패는 이력(FAILED)에 이미 남으므로 로그만 남긴다 — 202가 나간 뒤라 받을 사람도 없다. + */ + public void startFullSyncAsync() { + String lockToken = + policySyncLock + .tryAcquire() + .orElseThrow(() -> new CustomException(SyncErrorCode.SYNC_ALREADY_RUNNING)); + taskExecutor.execute( + () -> { + try { + doRunFullSync(); + } catch (RuntimeException e) { + log.error("정책 수집 수동 실행 실패 — 상세는 policy_batch_history 참조", e); + } finally { + policySyncLock.release(lockToken); + } + }); + } + + private PolicyBatchHistory doRunFullSync() { + PolicyBatchHistory history = + historyRepository.save(PolicyBatchHistory.request(BatchMode.FULL)); + history.start(); + history = historyRepository.save(history); + try { + List items = policyApiClient.fetchAll(); + SyncPlan plan = plan(items); + PolicyWriteResult writeResult = policyUpsertWriter.upsertAll(plan.upserts()); + int missingMarked = policyUpsertWriter.hideMissing(plan.missingPolicyNos()); + history.succeed( + writeResult.newCount(), + writeResult.updatedCount(), + plan.unchangedCount(), + missingMarked, + writeResult.errorCount()); + log.info( + "정책 수집 완료 — 신규 {} / 변경 {} / 유지 {} / 누락 {} / 실패 {}", + writeResult.newCount(), + writeResult.updatedCount(), + plan.unchangedCount(), + missingMarked, + writeResult.errorCount()); + return historyRepository.save(history); + } catch (RuntimeException e) { + history.fail(e.getMessage()); + historyRepository.save(history); + throw e; + } + } + + /** 스냅샷(1쿼리)·지역 마스터(1쿼리)를 로드해 메모리에서 비교 분류한다. 없음=신규 / lastMdfcnDt 다름=변경 / 같음=SKIP. */ + private SyncPlan plan(List items) { + Map snapshots = + policyRepository.findSyncSnapshots().stream() + .collect( + Collectors.toMap( + PolicySyncSnapshot::policyNo, Function.identity())); + Map regionsByCode = + regionRepository.findAll().stream() + .collect(Collectors.toMap(Region::getCode, Function.identity())); + + // 수집 도중 페이지 경계가 밀리면 같은 정책이 두 번 올 수 있다 — plcyNo 기준으로 접는다(먼저 온 것 유지) + Set seenPolicyNos = new HashSet<>(); + List upserts = new ArrayList<>(); + int unchangedCount = 0; + for (YouthPolicyItem item : items) { + Policy policy = policyMapper.toEntity(item); + String policyNo = policy.getPolicyNo(); + if (policyNo == null) { + log.warn("plcyNo 없는 응답 항목 — 스킵"); + continue; + } + if (!seenPolicyNos.add(policyNo)) { + continue; + } + PolicySyncSnapshot snapshot = snapshots.get(policyNo); + // SKIP은 "수정일 동일 + 현재 보이는 정책"만. HIDDEN이 같은 수정일로 재등장하면 + // 변경으로 취급해 updateFrom이 VISIBLE 복구 + missing_count 리셋을 수행하게 한다. + if (snapshot != null + && snapshot.visibility() == PolicyVisibility.VISIBLE + && Objects.equals(snapshot.lastModifiedAt(), policy.getLastModifiedAt())) { + unchangedCount++; + continue; + } + List regions = + policyMapper.resolveRegions(policyNo, item.zipCd(), regionsByCode); + upserts.add(new PolicyUpsertItem(policy, regions)); + } + + // API 응답에 아예 안 나타난 정책만 누락 — SKIP(unchanged)된 정책은 존재하는 것 + List missingPolicyNos = + snapshots.keySet().stream().filter(no -> !seenPolicyNos.contains(no)).toList(); + return new SyncPlan(upserts, missingPolicyNos, unchangedCount); + } + + // 앞으로 Insert/Delete할 정책들(신규+변경), 앞으로 누락 처리 할 정책번호들, 아무것도 안할 건수(이력 기록용) + private record SyncPlan( + List upserts, List missingPolicyNos, int unchangedCount) {} +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java new file mode 100644 index 0000000..48ea594 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java @@ -0,0 +1,40 @@ +package com.bop.youthpick.sync.service; + +import com.bop.youthpick.policy.repository.PolicyRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 앱 시작 시 policies가 비어 있으면 초기 수집을 1회 실행한다. 팀원 누구나 compose up + 앱 실행만으로 실데이터를 갖게 하는 장치. + * + *

youthpick.sync.startup-on-empty=true 인 프로파일에서만 동작한다 (기본 off — 테스트/CI의 빈 H2에서 외부 API를 치는 사고 + * 방지). + */ +@Component +@ConditionalOnProperty(name = "youthpick.sync.startup-on-empty", havingValue = "true") +@RequiredArgsConstructor +public class PolicySyncStartupRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(PolicySyncStartupRunner.class); + + private final PolicyRepository policyRepository; + private final PolicySyncService policySyncService; + + // ponytail: 동기 실행 — 초기 수집(~수십 초)이 앱 기동을 막는다. + // 기동 시간이 문제되면 @Async 또는 별도 스레드로 전환. + @Override + public void run(ApplicationArguments args) { + long count = policyRepository.count(); + if (count > 0) { + log.info("policies {}건 존재 — 시작 시 초기 수집 생략", count); + return; + } + log.info("policies 비어 있음 — 초기 수집을 시작합니다"); + policySyncService.runFullSync(); + } +} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java b/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java new file mode 100644 index 0000000..6723fa8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java @@ -0,0 +1,142 @@ +package com.bop.youthpick.sync.service; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.sync.dto.PolicyUpsertItem; +import com.bop.youthpick.sync.dto.PolicyWriteResult; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +/** 전처리된 정책을 청크 단위 트랜잭션으로 저장(신규 INSERT / 변경 UPDATE)하고, 사라진 정책을 숨김 처리한다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyUpsertWriter { + + static final int CHUNK_SIZE = 100; + + private final PolicyRepository policyRepository; + private final PolicyRegionRepository policyRegionRepository; + private final TransactionTemplate transactionTemplate; + + public PolicyWriteResult upsertAll(List items) { + int newCount = 0; + int updatedCount = 0; + int errorCount = 0; + for (int from = 0; from < items.size(); from += CHUNK_SIZE) { + List chunk = + items.subList(from, Math.min(from + CHUNK_SIZE, items.size())); + ChunkResult result; + try { + result = transactionTemplate.execute(status -> writeChunk(chunk)); + } catch (RuntimeException e) { + // 청크에 깨진 건이 섞임 — 건별 재시도로 범인만 제외하고 나머지는 살린다. + log.warn("정책 청크 저장 실패({}건) — 건별 재시도", chunk.size(), e); + result = retryOneByOne(chunk); + } + newCount += result.newCount(); + updatedCount += result.updatedCount(); + errorCount += result.errorCount(); + } + return new PolicyWriteResult(newCount, updatedCount, errorCount); + } + + private ChunkResult retryOneByOne(List chunk) { + int newCount = 0; + int updatedCount = 0; + int errorCount = 0; + for (PolicyUpsertItem item : chunk) { + String policyNo = item.policy().getPolicyNo(); + // 실패한 청크에서 IDENTITY가 id를 부여한 채 롤백된 엔티티일 수 있어 재사용 금지 — 새 복사본으로 저장 + PolicyUpsertItem freshItem = + new PolicyUpsertItem(Policy.copyOf(item.policy()), item.regions()); + try { + boolean isNew = + transactionTemplate.execute( + status -> + writeOne( + freshItem, + policyRepository + .findByPolicyNoIn(List.of(policyNo)) + .stream() + .findFirst() + .orElse(null))); + if (isNew) { + newCount++; + } else { + updatedCount++; + } + } catch (RuntimeException e) { + errorCount++; + log.warn("정책 {} 저장 실패 — 이번 회차 스킵", policyNo, e); + } + } + return new ChunkResult(newCount, updatedCount, errorCount); + } + + private ChunkResult writeChunk(List chunk) { + Map existingByNo = + policyRepository + .findByPolicyNoIn( + chunk.stream().map(i -> i.policy().getPolicyNo()).toList()) + .stream() + .collect(Collectors.toMap(Policy::getPolicyNo, p -> p)); + int newCount = 0; + int updatedCount = 0; + for (PolicyUpsertItem item : chunk) { + if (writeOne(item, existingByNo.get(item.policy().getPolicyNo()))) { + newCount++; + } else { + updatedCount++; + } + } + return new ChunkResult(newCount, updatedCount, 0); + } + + /** 1건 저장. 신규면 true, 변경이면 false. */ + private boolean writeOne(PolicyUpsertItem item, Policy existing) { + Policy target; + boolean isNew = existing == null; + if (isNew) { + target = policyRepository.save(item.policy()); + } else { + existing.updateFrom(item.policy()); + // Hibernate는 flush 시 INSERT를 DELETE보다 먼저 실행하므로, 같은 지역이 유지되는 + // 정책에서 (policy_id, region_code) 유니크 충돌이 난다 — 삭제를 먼저 flush한다. + policyRegionRepository.deleteByPolicy(existing); + policyRegionRepository.flush(); + target = existing; + } + for (var region : item.regions()) { + policyRegionRepository.save(PolicyRegion.create(target, region)); + } + return isNew; + } + + private record ChunkResult(int newCount, int updatedCount, int errorCount) {} + + /** 이번 수집에서 사라진 정책의 누락 횟수를 올린다(3회 도달 시 HIDDEN — Policy.markMissing). DB에 없는 번호는 무시. */ + public int hideMissing(List missingPolicyNos) { + int marked = 0; + for (int from = 0; from < missingPolicyNos.size(); from += CHUNK_SIZE) { + List chunk = + missingPolicyNos.subList( + from, Math.min(from + CHUNK_SIZE, missingPolicyNos.size())); + marked += + transactionTemplate.execute( + status -> { + List found = policyRepository.findByPolicyNoIn(chunk); + found.forEach(Policy::markMissing); + return found.size(); + }); + } + return marked; + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index d1fdb5e..1b6d4b0 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -32,6 +32,8 @@ youthpick: # SameSite=Lax로도 쿠키가 정상 전송된다(운영은 application.yml의 SameSite=None+Secure 기본값을 쓴다). secure: false same-site: Lax + sync: + startup-on-empty: true # policies 비어 있으면 앱 시작 시 초기 수집 1회 (기본 off) oauth: # provider 콘솔에 등록하는 redirect_uri와 동일해야 하는 프론트엔드 콜백 라우트 frontend-callback-uri: ${OAUTH_FRONTEND_CALLBACK_URI:http://localhost:3000/oauth/callback} @@ -44,4 +46,4 @@ youthpick: client-secret: ${NAVER_OAUTH_CLIENT_SECRET:} kakao: client-id: ${KAKAO_OAUTH_CLIENT_ID:} - client-secret: ${KAKAO_OAUTH_CLIENT_SECRET:} \ No newline at end of file + client-secret: ${KAKAO_OAUTH_CLIENT_SECRET:} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d99f58..f42f64f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,8 +11,10 @@ spring: - classpath:db/seed http: client: - # OAuthClient 등 자동구성된 RestClient.Builder에 전역 적용. provider가 응답이 없을 때 + # OAuthClient·PolicyApiClient 등 자동구성된 RestClient.Builder에 전역 적용. provider가 응답이 없을 때 # 스레드/DB 커넥션을 오래 붙잡지 않도록 한다. + # read-timeout은 "연속 무응답" 기준(소켓 SO_TIMEOUT)이라 배치의 대용량 페이지 수신도 + # 스트림이 흐르는 한 걸리지 않는다 — 배치 실측(페이지당 ~2초)과도 여유 있음. connect-timeout: 3s read-timeout: 5s @@ -23,4 +25,12 @@ youthpick: # 로컬(application-local.yml)은 http라 secure=false, localhost 동일 사이트라 SameSite=Lax로 완화한다. path: /api/v1/auth secure: true - same-site: None \ No newline at end of file + same-site: None + sync: + # 온통청년 API 키 — 코드/로그 노출 금지, .env.example 참고 + api-key: ${YOUTH_API_KEY:} + scheduler: + # 기본 off(안전 기본값) — 실행하려는 환경(배포 prod 등)이 각자 true로 켠다. flyway.enabled와 같은 방식. + enabled: false + # 새벽 4시: 온통청년 갱신 반영 + 트래픽 최저 시간대 + cron: "0 0 4 * * *" diff --git a/src/test/java/com/bop/youthpick/policy/entity/PolicyFixture.java b/src/test/java/com/bop/youthpick/policy/entity/PolicyFixture.java new file mode 100644 index 0000000..1258e5d --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/entity/PolicyFixture.java @@ -0,0 +1,59 @@ +package com.bop.youthpick.policy.entity; + +import java.time.LocalDateTime; + +/** 테스트 공용 Policy 생성 헬퍼 — 스냅샷/업서트 검증에 필요한 필드만 채운 최소 정책. */ +public final class PolicyFixture { + + private PolicyFixture() {} + + /** 파라미터 순서 = Policy 필드 선언 순서 (Policy.create 규약). */ + public static Policy policy(String policyNo, String title, LocalDateTime lastModifiedAt) { + return Policy.create( + policyNo, + title, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + false, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 0, + null, + lastModifiedAt, + null); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java b/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java new file mode 100644 index 0000000..47ed62b --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java @@ -0,0 +1,49 @@ +package com.bop.youthpick.policy.entity; + +import static com.bop.youthpick.policy.entity.PolicyFixture.policy; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class PolicyTest { + + @Test + void 수집에서_2회_연속_사라져도_아직_보인다() { + Policy policy = policy("R2026-001", "제목", null); + + policy.markMissing(); + policy.markMissing(); + + assertThat(policy.getMissingCount()).isEqualTo(2); + assertThat(policy.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + } + + @Test + void 수집에서_3회_연속_사라지면_숨김_처리된다() { + Policy policy = policy("R2026-001", "제목", null); + + policy.markMissing(); + policy.markMissing(); + policy.markMissing(); + + assertThat(policy.getMissingCount()).isEqualTo(3); + assertThat(policy.getVisibility()).isEqualTo(PolicyVisibility.HIDDEN); + } + + @Test + void updateFrom은_내용을_갱신하고_누락_상태를_리셋한다() { + Policy policy = policy("R2026-001", "옛 제목", LocalDateTime.of(2026, 1, 1, 0, 0)); + policy.markMissing(); + policy.markMissing(); + policy.markMissing(); // HIDDEN 상태에서 재등장하는 시나리오 + + LocalDateTime freshModifiedAt = LocalDateTime.of(2026, 7, 1, 12, 0); + policy.updateFrom(policy("R2026-001", "새 제목", freshModifiedAt)); + + assertThat(policy.getTitle()).isEqualTo("새 제목"); + assertThat(policy.getLastModifiedAt()).isEqualTo(freshModifiedAt); + assertThat(policy.getMissingCount()).isZero(); + assertThat(policy.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java index 3e20922..a8277b2 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -1,11 +1,14 @@ package com.bop.youthpick.policy.repository; +import static com.bop.youthpick.policy.entity.PolicyFixture.policy; import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.dto.PolicySyncSnapshot; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -13,12 +16,59 @@ import org.springframework.context.annotation.Import; import org.springframework.test.util.ReflectionTestUtils; +// @DataJpaTest는 슬라이스 스캔 대상에서 일반 @Configuration을 제외하므로, BaseEntity의 +// createdAt/updatedAt을 채우는 JpaAuditingConfig(@EnableJpaAuditing)를 직접 import한다. @DataJpaTest @Import(JpaAuditingConfig.class) class PolicyRepositoryTest { @Autowired private PolicyRepository policyRepository; + @Test + void 저장된_모든_정책의_비교용_스냅샷을_반환한다() { + LocalDateTime modifiedAt = LocalDateTime.of(2026, 7, 1, 10, 30); + policyRepository.save(policy("R2026-001", "테스트 정책", modifiedAt)); + // lastMdfcnDt가 비어 오는 정책도 실존한다(원천 데이터 품질) — 스냅샷에서 null 유지 확인 + policyRepository.save(policy("R2026-002", "테스트 정책", null)); + + List snapshots = policyRepository.findSyncSnapshots(); + + assertThat(snapshots) + .extracting(PolicySyncSnapshot::policyNo) + .containsExactlyInAnyOrder("R2026-001", "R2026-002"); + PolicySyncSnapshot first = + snapshots.stream() + .filter(s -> s.policyNo().equals("R2026-001")) + .findFirst() + .orElseThrow(); + assertThat(first.lastModifiedAt()).isEqualTo(modifiedAt); + assertThat(first.visibility()).isEqualTo(PolicyVisibility.VISIBLE); + PolicySyncSnapshot second = + snapshots.stream() + .filter(s -> s.policyNo().equals("R2026-002")) + .findFirst() + .orElseThrow(); + assertThat(second.lastModifiedAt()).isNull(); + } + + @Test + void 정책이_없으면_빈_스냅샷을_반환한다() { + assertThat(policyRepository.findSyncSnapshots()).isEmpty(); + } + + @Test + void 숨김_처리된_정책도_스냅샷에_포함된다() { + var hidden = policy("R2026-003", "숨김 정책", null); + hidden.markMissing(); + hidden.markMissing(); + hidden.markMissing(); // 3회 누락 → HIDDEN + policyRepository.save(hidden); + + assertThat(policyRepository.findSyncSnapshots()) + .singleElement() + .satisfies(s -> assertThat(s.visibility()).isEqualTo(PolicyVisibility.HIDDEN)); + } + @Test void visibility별_soft_delete되지_않은_정책만_카운트한다() { policyRepository.save(newPolicy("P001", PolicyVisibility.VISIBLE, null)); diff --git a/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncControllerTest.java b/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncControllerTest.java new file mode 100644 index 0000000..3ea74f7 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncControllerTest.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.sync.controller; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.sync.exception.SyncErrorCode; +import com.bop.youthpick.sync.service.PolicySyncService; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +// 권한(ADMIN) 검증은 SecurityConfig의 /api/v1/admin/** 경로 규칙이 담당 — +// 팀 컨벤션(AdminBatchJobLogControllerTest 등)대로 필터를 끄고 컨트롤러 동작만 검증한다. +@WebMvcTest(controllers = AdminPolicySyncController.class) +@AutoConfigureMockMvc(addFilters = false) +class AdminPolicySyncControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private PolicySyncService policySyncService; + + @Test + void 수동_실행_요청은_비동기로_시작시키고_즉시_202를_반환한다() throws Exception { + mockMvc.perform(post("/api/v1/admin/batch/policy-sync")).andExpect(status().isAccepted()); + + verify(policySyncService).startFullSyncAsync(); + } + + @Test + void 이미_실행_중이면_409와_SY001을_반환한다() throws Exception { + doThrow(new CustomException(SyncErrorCode.SYNC_ALREADY_RUNNING)) + .when(policySyncService) + .startFullSyncAsync(); + + mockMvc.perform(post("/api/v1/admin/batch/policy-sync")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("SY001")); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponseTest.java b/src/test/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponseTest.java new file mode 100644 index 0000000..ab2b6a9 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/dto/YouthPolicyApiResponseTest.java @@ -0,0 +1,76 @@ +package com.bop.youthpick.sync.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 온통청년 API 응답 픽스처(JSON) 역직렬화 검증. 명세서 표기(sBizCd)와 다른 실제 키(sbizCd), 빈 문자열, 버리는 필드(bscPlanCycl) 케이스 + * 포함. + */ +class YouthPolicyApiResponseTest { + + private static YouthPolicyApiResponse response; + + @BeforeAll + static void deserializeFixture() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + try (InputStream json = + YouthPolicyApiResponseTest.class.getResourceAsStream( + "/fixtures/youth-policy-response.json")) { + response = objectMapper.readValue(json, YouthPolicyApiResponse.class); + } + } + + @Test + @DisplayName("A: 응답 봉투(resultCode, pagging.totCount, 리스트 크기)를 역직렬화한다") + void deserializesEnvelope() { + assertThat(response.resultCode()).isEqualTo(200); + assertThat(response.result().paging().totCount()).isEqualTo(2635); + assertThat(response.result().youthPolicyList()).hasSize(2); + } + + @Test + @DisplayName("A: 정책 필드를 원문 그대로(변환 없이) 담는다") + void deserializesPolicyFieldsAsRawStrings() { + YouthPolicyItem item = response.result().youthPolicyList().get(0); + + assertThat(item.plcyNo()).isEqualTo("R2024033012016"); + assertThat(item.plcyNm()).isEqualTo("청년 월세 특별지원"); + assertThat(item.sprtTrgtMinAge()).isEqualTo("19"); + assertThat(item.earnMaxAmt()).isEqualTo("5000"); + assertThat(item.aplyYmd()).isEqualTo("20260101 ~ 20261231"); + assertThat(item.zipCd()).isEqualTo("11110,11140,11170"); + assertThat(item.sprtArvlSeqYn()).isEqualTo("Y"); + assertThat(item.lastMdfcnDt()).isEqualTo("2026-06-15 14:22:01"); + } + + @Test + @DisplayName("X: 실제 JSON 키는 소문자 sbizCd — 오타 시 null이 되므로 값 존재를 확인한다") + void mapsLowercaseSbizCdKey() { + YouthPolicyItem item = response.result().youthPolicyList().get(0); + + assertThat(item.sbizCd()).isEqualTo("0014010"); + } + + @Test + @DisplayName("X: 빈 문자열 필드는 빈 문자열 그대로, 변환은 Processor 책임") + void keepsEmptyStringsUntouched() { + YouthPolicyItem ongoing = response.result().youthPolicyList().get(1); + + assertThat(ongoing.aplyYmd()).isEmpty(); + assertThat(ongoing.sprtArvlSeqYn()).isEmpty(); + assertThat(ongoing.zipCd()).isEmpty(); + assertThat(ongoing.plcyExplnCn()).isEqualTo(" "); + } + + @Test + @DisplayName("E: 적재하지 않는 필드(bscPlanCycl 등)가 있어도 역직렬화에 실패하지 않는다") + void ignoresUnknownFields() { + assertThat(response.result().youthPolicyList().get(0).plcyNo()).isNotNull(); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java b/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java new file mode 100644 index 0000000..7d08b45 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java @@ -0,0 +1,66 @@ +package com.bop.youthpick.sync.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class PolicyBatchHistoryTest { + + @Test + void request로_생성하면_REQUESTED_상태와_요청시각이_기록된다() { + PolicyBatchHistory history = PolicyBatchHistory.request(BatchMode.FULL); + + assertThat(history.getMode()).isEqualTo(BatchMode.FULL); + assertThat(history.getStatus()).isEqualTo(BatchStatus.REQUESTED); + assertThat(history.getRequestedAt()).isNotNull(); + assertThat(history.getStartedAt()).isNull(); + assertThat(history.getFinishedAt()).isNull(); + } + + @Test + void start하면_RUNNING_상태와_시작시각이_기록된다() { + PolicyBatchHistory history = PolicyBatchHistory.request(BatchMode.FULL); + + history.start(); + + assertThat(history.getStatus()).isEqualTo(BatchStatus.RUNNING); + assertThat(history.getStartedAt()).isNotNull(); + } + + @Test + void succeed하면_SUCCEEDED_상태와_종료시각_카운트가_기록된다() { + PolicyBatchHistory history = PolicyBatchHistory.request(BatchMode.FULL); + history.start(); + + history.succeed(10, 5, 2000, 3, 1); + + assertThat(history.getStatus()).isEqualTo(BatchStatus.SUCCEEDED); + assertThat(history.getFinishedAt()).isNotNull(); + assertThat(history.getNewCount()).isEqualTo(10); + assertThat(history.getUpdatedCount()).isEqualTo(5); + assertThat(history.getUnchangedCount()).isEqualTo(2000); + assertThat(history.getMissingCount()).isEqualTo(3); + assertThat(history.getErrorCount()).isEqualTo(1); + } + + @Test + void fail하면_FAILED_상태와_실패_메시지가_기록된다() { + PolicyBatchHistory history = PolicyBatchHistory.request(BatchMode.FULL); + history.start(); + + history.fail("정책 API 3페이지 요청이 3회 모두 실패 — 회차 중단"); + + assertThat(history.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(history.getFinishedAt()).isNotNull(); + assertThat(history.getFailureMessage()).contains("회차 중단"); + } + + @Test + void 실패_메시지가_컬럼_한도를_넘으면_잘라서_기록한다() { + PolicyBatchHistory history = PolicyBatchHistory.request(BatchMode.FULL); + + history.fail("가".repeat(2000)); + + assertThat(history.getFailureMessage()).hasSize(1000); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyApiClientTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyApiClientTest.java new file mode 100644 index 0000000..14605d1 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyApiClientTest.java @@ -0,0 +1,140 @@ +package com.bop.youthpick.sync.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.ExpectedCount.times; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestToUriTemplate; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import com.bop.youthpick.sync.dto.YouthPolicyItem; +import com.bop.youthpick.sync.exception.PolicySyncException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +/** + * PolicyApiClient 목 서버 테스트 — 실측 기반 실패 3종(성공/403 errorCode JSON/HTML)과 재시도·건수 검증. 실제 API를 치지 않는다. + */ +class PolicyApiClientTest { + + private static final String URL = + "https://api.test/go/ythip/getPlcy" + + "?apiKeyNm={key}&rtnType=json&pageType=1&pageNum={page}&pageSize=1000"; + + private MockRestServiceServer server; + private PolicyApiClient client; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + client = new PolicyApiClient(builder, "https://api.test", "test-key"); + } + + private static String pageJson(int totCount, String... plcyNos) { + StringBuilder items = new StringBuilder(); + for (String no : plcyNos) { + if (!items.isEmpty()) { + items.append(','); + } + items.append("{\"plcyNo\":\"").append(no).append("\"}"); + } + return """ + {"resultCode":200,"resultMessage":"OK", + "result":{"pagging":{"totCount":%d,"pageNum":1,"pageSize":1000}, + "youthPolicyList":[%s]}}""" + .formatted(totCount, items); + } + + @Test + @DisplayName("A: totCount까지 페이지를 순회해 전량 수집한다 (2페이지)") + void fetchesAllPagesUntilTotCount() { + // pageSize=1000이지만 테스트 응답은 페이지당 2건씩 잘라 페이지 순회를 검증한다 + server.expect(requestToUriTemplate(URL, "test-key", 1)) + .andExpect(queryParam("rtnType", "json")) + .andExpect(queryParam("pageType", "1")) + .andRespond(withSuccess(pageJson(3, "P1", "P2"), MediaType.APPLICATION_JSON)); + server.expect(requestToUriTemplate(URL, "test-key", 2)) + .andRespond(withSuccess(pageJson(3, "P3"), MediaType.APPLICATION_JSON)); + + List all = client.fetchAll(); + + assertThat(all).extracting(YouthPolicyItem::plcyNo).containsExactly("P1", "P2", "P3"); + server.verify(); + } + + @Test + @DisplayName("E: 403 + errorCode JSON(잘못된 키)은 3회 시도 후 회차 중단 예외") + void failsAfterRetriesOn403() { + server.expect(times(3), requestToUriTemplate(URL, "test-key", 1)) + .andRespond( + withStatus(HttpStatus.FORBIDDEN) + .contentType(MediaType.APPLICATION_JSON) + .body( + "{\"errorCode\":\"e001\",\"errorMsg\":\"invalid api key.\"}")); + + assertThatThrownBy(client::fetchAll) + .isInstanceOf(PolicySyncException.class) + .hasMessageContaining("3회 모두 실패"); + server.verify(); + } + + @Test + @DisplayName("E: HTML 응답(키 누락)은 JSON 파싱 실패로 간주하고 3회 시도 후 예외") + void failsAfterRetriesOnHtmlResponse() { + server.expect(times(3), requestToUriTemplate(URL, "test-key", 1)) + .andRespond(withSuccess("error", MediaType.TEXT_HTML)); + + assertThatThrownBy(client::fetchAll) + .isInstanceOf(PolicySyncException.class) + .hasMessageContaining("3회 모두 실패"); + server.verify(); + } + + @Test + @DisplayName("X: HTTP 200이라도 resultCode!=200이면 실패로 간주한다 (3중 체크)") + void failsOnNonSuccessResultCode() { + server.expect(times(3), requestToUriTemplate(URL, "test-key", 1)) + .andRespond( + withSuccess( + "{\"resultCode\":500,\"resultMessage\":\"server error\"}", + MediaType.APPLICATION_JSON)); + + assertThatThrownBy(client::fetchAll).isInstanceOf(PolicySyncException.class); + server.verify(); + } + + @Test + @DisplayName("A: 일시 오류(5xx)는 재시도해서 성공하면 정상 수집한다") + void retriesTransientErrorThenSucceeds() { + server.expect(requestToUriTemplate(URL, "test-key", 1)).andRespond(withServerError()); + server.expect(requestToUriTemplate(URL, "test-key", 1)) + .andRespond(withSuccess(pageJson(1, "P1"), MediaType.APPLICATION_JSON)); + + List all = client.fetchAll(); + + assertThat(all).extracting(YouthPolicyItem::plcyNo).containsExactly("P1"); + server.verify(); + } + + @Test + @DisplayName("X: API 키 미설정이면 요청 없이 즉시 예외 (HTML 오류로 헤매는 사고 방지)") + void failsFastWithoutApiKey() { + RestClient.Builder builder = RestClient.builder(); + MockRestServiceServer noCallServer = MockRestServiceServer.bindTo(builder).build(); + PolicyApiClient keylessClient = new PolicyApiClient(builder, "https://api.test", ""); + + assertThatThrownBy(keylessClient::fetchAll) + .isInstanceOf(PolicySyncException.class) + .hasMessageContaining("YOUTH_API_KEY"); + noCallServer.verify(); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java new file mode 100644 index 0000000..be54fb2 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java @@ -0,0 +1,239 @@ +package com.bop.youthpick.sync.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.sync.dto.YouthPolicyApiResponse; +import com.bop.youthpick.sync.dto.YouthPolicyItem; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * PolicyMapper 순수 단위 테스트 — 자가진단 정확도에 직결되는 전처리 규칙 검증(배치 설계 §8 최우선). 정상 케이스는 실측 픽스처의 전 필드를 대조해 + * Policy.create 위치 파라미터 순서 실수까지 잡는다. + */ +class PolicyMapperTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final PolicyMapper mapper = new PolicyMapper(objectMapper); + + private YouthPolicyItem fixtureItem(int index) throws IOException { + try (InputStream in = + getClass().getResourceAsStream("/fixtures/youth-policy-response.json")) { + return objectMapper + .readValue(in, YouthPolicyApiResponse.class) + .result() + .youthPolicyList() + .get(index); + } + } + + private YouthPolicyItem item(String json) throws IOException { + return objectMapper.readValue(json, YouthPolicyItem.class); + } + + @Test + @DisplayName("A: 정상 정책은 45개 필드 전부가 규칙대로 매핑된다 (파라미터 순서 실수 감지)") + void mapsAllFieldsFromNormalItem() throws IOException { + Policy policy = mapper.toEntity(fixtureItem(0)); + + assertThat(policy.getPolicyNo()).isEqualTo("R2024033012016"); + assertThat(policy.getTitle()).isEqualTo("청년 월세 특별지원"); + assertThat(policy.getDescription()).isEqualTo("경제적 어려움을 겪는 청년층의 주거비 부담 경감"); + assertThat(policy.getSupportContent()).isEqualTo("월 최대 20만원 월세 지원 (12개월)"); + assertThat(policy.getKeywords()).isEqualTo("월세,주거비,청년"); + assertThat(policy.getCategory()).isEqualTo("주거"); + assertThat(policy.getMiddleCategory()).isEqualTo("주택 및 거주지"); + assertThat(policy.getOrganizationName()).isEqualTo("국토교통부"); + assertThat(policy.getMinAge()).isEqualTo(19); + assertThat(policy.getMaxAge()).isEqualTo(34); + assertThat(policy.getJobCodes()).isEqualTo("0013001,0013002"); + assertThat(policy.getSchoolCodes()).isEqualTo("0049010"); + assertThat(policy.getIncomeConditionCode()).isEqualTo("0043002"); + assertThat(policy.getIncomeMaxAmount()).isEqualTo(5000); + assertThat(policy.getIncomeEtcContent()).isNull(); // 빈 문자열 → null + assertThat(policy.getMaritalStatusCode()).isEqualTo("0055003"); + assertThat(policy.getMajorCodes()).isEqualTo("0011009"); + assertThat(policy.getSpecializationCodes()).isEqualTo("0014010"); + assertThat(policy.getAdditionalQualification()).isEqualTo("무주택자"); + assertThat(policy.getParticipationRestriction()).isEqualTo("주택 소유자 제외"); + assertThat(policy.getApplicationPeriodType()).isEqualTo("0057001"); + assertThat(policy.getApplicationPeriodRaw()).isEqualTo("20260101 ~ 20261231"); + assertThat(policy.getApplicationStartDate()).isEqualTo(LocalDate.of(2026, 1, 1)); + assertThat(policy.getApplicationEndDate()).isEqualTo(LocalDate.of(2026, 12, 31)); + assertThat(policy.getBusinessPeriodBegin()).isEqualTo(LocalDate.of(2026, 1, 1)); + assertThat(policy.getBusinessPeriodEnd()).isEqualTo(LocalDate.of(2026, 12, 31)); + assertThat(policy.getBusinessPeriodEtc()).isNull(); + assertThat(policy.getSupportScaleCount()).isEqualTo(15000); + assertThat(policy.isFirstComeFirstServed()).isTrue(); + assertThat(policy.getApplicationUrl()).isEqualTo("https://www.myhome.go.kr"); + assertThat(policy.getReferenceUrl1()).isEqualTo("https://www.molit.go.kr"); + assertThat(policy.getReferenceUrl2()).isNull(); + assertThat(policy.getApplicationMethod()).isEqualTo("온라인 신청"); + assertThat(policy.getSubmissionDocuments()).isEqualTo("신분증, 임대차계약서"); + assertThat(policy.getScreeningMethod()).isEqualTo("서류 심사"); + assertThat(policy.getAgeLimitFlag()).isEqualTo("Y"); + assertThat(policy.getIncomeMinAmount()).isZero(); + assertThat(policy.getSupportScaleLimit()).isTrue(); + assertThat(policy.getOperatingInstitutionName()).isEqualTo("한국토지주택공사"); + assertThat(policy.getApprovalStatusCode()).isEqualTo("0044002"); + assertThat(policy.getEtcMatters()).isNull(); + assertThat(policy.getViewCount()).isEqualTo(125478); + assertThat(policy.getFirstRegisteredAt()) + .isEqualTo(LocalDateTime.of(2026, 1, 2, 9, 15, 30)); + assertThat(policy.getLastModifiedAt()).isEqualTo(LocalDateTime.of(2026, 6, 15, 14, 22, 1)); + assertThat(policy.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + assertThat(policy.getRawPayload()).contains("\"plcyNo\":\"R2024033012016\""); + } + + @Test + @DisplayName("A: 상시(0057002)·빈값 정책은 날짜 스킵, 빈 Y/N은 false, 공백 설명은 null") + void mapsAlwaysOpenItemWithBlanks() throws IOException { + Policy policy = mapper.toEntity(fixtureItem(1)); + + assertThat(policy.getApplicationStartDate()).isNull(); + assertThat(policy.getApplicationEndDate()).isNull(); + assertThat(policy.getApplicationPeriodRaw()).isNull(); // 빈 aplyYmd + assertThat(policy.getDescription()).isNull(); // " " 공백만 → null + assertThat(policy.isFirstComeFirstServed()).isFalse(); // "" → false + assertThat(policy.getSupportScaleLimit()).isFalse(); // "" → false + assertThat(policy.getBusinessPeriodBegin()).isNull(); + assertThat(policy.getBusinessPeriodEnd()).isNull(); + assertThat(policy.getOrganizationName()).isEqualTo("중소벤처기업부"); + assertThat(policy.getOperatingInstitutionName()).isNull(); // "" → null + assertThat(policy.getViewCount()).isEqualTo(302); + } + + @Test + @DisplayName("E: 깨진 숫자/날짜/일시는 예외 없이 해당 필드만 null (viewCount는 0)") + void brokenValuesBecomeNullWithoutException() throws IOException { + Policy policy = + mapper.toEntity( + item( + """ + {"plcyNo":"BAD1","plcyNm":"깨진 정책", + "sprtTrgtMinAge":"열아홉","earnMaxAmt":"5,000","inqCnt":"많음", + "bizPrdBgngYmd":"2026-01-01","frstRegDt":"2026/01/02 09:00:00", + "aplyPrdSeCd":"0057001","aplyYmd":"20260101 ~ 2026123X"} + """)); + + assertThat(policy.getMinAge()).isNull(); + assertThat(policy.getIncomeMaxAmount()).isNull(); + assertThat(policy.getViewCount()).isZero(); + assertThat(policy.getBusinessPeriodBegin()).isNull(); // yyyyMMdd 아님 + assertThat(policy.getFirstRegisteredAt()).isNull(); + assertThat(policy.getApplicationStartDate()).isEqualTo(LocalDate.of(2026, 1, 1)); // 앞쪽은 정상 + assertThat(policy.getApplicationEndDate()).isNull(); // 마감일만 깨짐 + assertThat(policy.getApplicationPeriodRaw()).isEqualTo("20260101 ~ 2026123X"); // 원문은 보존 + } + + @Test + @DisplayName("X: aplyYmd에 구분자(~)가 없거나, 상시 코드면 날짜가 있어도 파싱하지 않는다") + void applyPeriodEdgeCases() throws IOException { + Policy noSeparator = + mapper.toEntity( + item( + "{\"plcyNo\":\"X1\",\"aplyPrdSeCd\":\"0057001\",\"aplyYmd\":\"20260101\"}")); + assertThat(noSeparator.getApplicationStartDate()).isNull(); + assertThat(noSeparator.getApplicationEndDate()).isNull(); + + Policy alwaysOpen = + mapper.toEntity( + item( + "{\"plcyNo\":\"X2\",\"aplyPrdSeCd\":\"0057002\"," + + "\"aplyYmd\":\"20260101 ~ 20261231\"}")); + assertThat(alwaysOpen.getApplicationStartDate()).isNull(); + assertThat(alwaysOpen.getApplicationEndDate()).isNull(); + assertThat(alwaysOpen.getApplicationPeriodRaw()).isEqualTo("20260101 ~ 20261231"); // 원문은 보존 + } + + @Test + @DisplayName("X: 주관기관이 비면 운영기관으로 fallback, 둘 다 비면 null / N은 false") + void organizationFallbackAndBooleanN() throws IOException { + Policy fallback = + mapper.toEntity( + item( + "{\"plcyNo\":\"X3\",\"sprvsnInstCdNm\":\" \"," + + "\"operInstCdNm\":\"운영기관\",\"sprtArvlSeqYn\":\"N\"}")); + assertThat(fallback.getOrganizationName()).isEqualTo("운영기관"); + assertThat(fallback.isFirstComeFirstServed()).isFalse(); // "N" → false + + Policy bothBlank = + mapper.toEntity( + item("{\"plcyNo\":\"X4\",\"sprvsnInstCdNm\":\"\",\"operInstCdNm\":\"\"}")); + assertThat(bothBlank.getOrganizationName()).isNull(); + } + + // ---- resolveRegions: zipCd → Region 해석 (plan 2-2, 링크 생성은 Writer 몫) ---- + + private static Map regionMap(String... codes) { + Map map = new HashMap<>(); + for (String code : codes) { + map.put(code, Region.create(code, "시도" + code, "시군구" + code)); + } + return map; + } + + @Test + @DisplayName("A: 단일 코드는 Region 1건으로 해석된다") + void mapsSingleRegionCode() { + List regions = mapper.resolveRegions("R1", "11110", regionMap("11110")); + + assertThat(regions).hasSize(1); + assertThat(regions.get(0).getCode()).isEqualTo("11110"); + } + + @Test + @DisplayName("A: 콤마 다중 목록은 행 단위로 분해되고, 중복 코드·공백 토큰은 접힌다") + void mapsMultipleCodesWithDedup() { + List regions = + mapper.resolveRegions( + "R2", "11110, 11140 ,11110,,11170", regionMap("11110", "11140", "11170")); + + assertThat(regions).extracting(Region::getCode).containsExactly("11110", "11140", "11170"); + } + + @Test + @DisplayName("A: 전국 정책(256개 코드)은 전부 행으로 정규화된다") + void mapsNationwideCodeList() { + List codes = + IntStream.range(0, 256).mapToObj(i -> String.valueOf(10000 + i * 10)).toList(); + Map regions = regionMap(codes.toArray(String[]::new)); + + List result = mapper.resolveRegions("R3", String.join(",", codes), regions); + + assertThat(result).hasSize(256); + assertThat(result.stream().map(Region::getCode).collect(Collectors.toSet())).hasSize(256); + } + + @Test + @DisplayName("X: regions에 없는 코드는 FK 위반 대신 스킵하고 아는 코드만 매핑한다") + void skipsUnknownCodes() { + List regions = + mapper.resolveRegions("R4", "11110,99999,11140", regionMap("11110", "11140")); + + assertThat(regions).extracting(Region::getCode).containsExactly("11110", "11140"); + } + + @Test + @DisplayName("X: zipCd가 빈값/공백/null이면 빈 리스트 (전국 아님 — 지역정보 없음)") + void emptyZipCdMeansNoRegions() { + Map regions = regionMap("11110"); + + assertThat(mapper.resolveRegions("R5", "", regions)).isEmpty(); + assertThat(mapper.resolveRegions("R5", " ", regions)).isEmpty(); + assertThat(mapper.resolveRegions("R5", null, regions)).isEmpty(); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncLockTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncLockTest.java new file mode 100644 index 0000000..98d1de8 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncLockTest.java @@ -0,0 +1,69 @@ +package com.bop.youthpick.sync.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.RedisScript; + +@ExtendWith(MockitoExtension.class) +class PolicySyncLockTest { + + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + + private PolicySyncLock policySyncLock; + + @BeforeEach + void setUp() { + policySyncLock = new PolicySyncLock(redisTemplate); + } + + @Test + void 락이_비어있으면_TTL과_함께_획득하고_소유_토큰을_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent( + eq("sync:policy-sync:lock"), anyString(), eq(Duration.ofMinutes(10)))) + .thenReturn(true); + + Optional token = policySyncLock.tryAcquire(); + + assertThat(token).isPresent(); + assertThat(token.get()).isNotBlank(); + } + + @Test + void 다른_실행이_락을_쥐고_있으면_빈값을_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))) + .thenReturn(false); + + Optional token = policySyncLock.tryAcquire(); + + assertThat(token).isEmpty(); + } + + @Test + void 해제는_소유자_확인_스크립트로_자기_토큰을_전달한다() { + policySyncLock.release("my-token"); + + verify(redisTemplate) + .execute( + any(RedisScript.class), + eq(List.of("sync:policy-sync:lock")), + eq("my-token")); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncSchedulerTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncSchedulerTest.java new file mode 100644 index 0000000..6e67901 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncSchedulerTest.java @@ -0,0 +1,43 @@ +package com.bop.youthpick.sync.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.sync.exception.PolicySyncException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class PolicySyncSchedulerTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withBean(PolicySyncService.class, () -> mock(PolicySyncService.class)) + .withUserConfiguration(PolicySyncScheduler.class); + + @Test + void 설정이_켜져_있으면_스케줄러_빈이_등록된다() { + runner.withPropertyValues( + "youthpick.sync.scheduler.enabled=true", + "youthpick.sync.scheduler.cron=0 0 4 * * *") + .run(context -> assertThat(context).hasSingleBean(PolicySyncScheduler.class)); + } + + @Test + void 설정이_없거나_꺼져_있으면_스케줄러_빈이_등록되지_않는다() { + runner.run(context -> assertThat(context).doesNotHaveBean(PolicySyncScheduler.class)); + runner.withPropertyValues("youthpick.sync.scheduler.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(PolicySyncScheduler.class)); + } + + @Test + void 배치가_실패해도_예외를_밖으로_던지지_않는다() { + PolicySyncService service = mock(PolicySyncService.class); + when(service.runFullSync()).thenThrow(new PolicySyncException("정책 수집이 이미 실행 중")); + + PolicySyncScheduler scheduler = new PolicySyncScheduler(service); + + assertThatCode(scheduler::runDailySync).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java new file mode 100644 index 0000000..4db41c6 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java @@ -0,0 +1,245 @@ +package com.bop.youthpick.sync.service; + +import static com.bop.youthpick.policy.entity.PolicyFixture.policy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.sync.dto.YouthPolicyItem; +import com.bop.youthpick.sync.entity.BatchStatus; +import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import com.bop.youthpick.sync.exception.PolicySyncException; +import com.bop.youthpick.sync.repository.PolicyBatchHistoryRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +// Writer가 청크 커밋하는 흐름 전체를 검증하므로 테스트 트랜잭션을 끄고(@AfterEach 정리), +// 외부 API만 목으로 대체한 통합 테스트다 (배치 설계 §검증 — 성공/회차실패 시나리오). +@DataJpaTest +@Import({ + PolicySyncService.class, + PolicyUpsertWriter.class, + PolicyMapper.class, + JpaAuditingConfig.class +}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class PolicySyncServiceTest { + + @TestConfiguration + static class ObjectMapperConfig { + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper(); + } + } + + private static final ObjectMapper OM = new ObjectMapper(); + + @MockitoBean private PolicyApiClient policyApiClient; + @MockitoBean private PolicySyncLock policySyncLock; + + // @DataJpaTest 슬라이스에는 Boot의 applicationTaskExecutor가 없다. 목으로 두면 + // 기본은 "실행 안 됨"이고, 필요한 테스트만 runInline()으로 현재 스레드 실행을 흉내낸다. + @MockitoBean private org.springframework.core.task.TaskExecutor taskExecutor; + + @Autowired private PolicySyncService policySyncService; + @Autowired private PolicyRepository policyRepository; + @Autowired private PolicyRegionRepository policyRegionRepository; + @Autowired private RegionRepository regionRepository; + @Autowired private PolicyBatchHistoryRepository historyRepository; + + @BeforeEach + void acquireLockByDefault() { + when(policySyncLock.tryAcquire()).thenReturn(Optional.of("test-token")); + } + + @AfterEach + void cleanUp() { + policyRegionRepository.deleteAll(); + policyRepository.deleteAll(); + regionRepository.deleteAll(); + historyRepository.deleteAll(); + } + + @Test + void 성공_회차는_신규_변경_SKIP_누락을_나눠_처리하고_이력에_기록한다() throws IOException { + regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + policyRepository.save(policy("P-UPD", "옛 제목", LocalDateTime.of(2026, 1, 1, 0, 0))); + policyRepository.save(policy("P-SAME", "그대로", LocalDateTime.of(2026, 1, 1, 0, 0))); + policyRepository.save(policy("P-GONE", "사라질 정책", null)); + when(policyApiClient.fetchAll()) + .thenReturn( + List.of( + item( + "{\"plcyNo\":\"P-NEW\",\"plcyNm\":\"신규 정책\"," + + "\"zipCd\":\"11000\"}"), + item( + "{\"plcyNo\":\"P-UPD\",\"plcyNm\":\"갱신된 제목\"," + + "\"lastMdfcnDt\":\"2026-07-01 00:00:00\"}"), + item( + "{\"plcyNo\":\"P-SAME\",\"plcyNm\":\"그대로\"," + + "\"lastMdfcnDt\":\"2026-01-01 00:00:00\"}"))); + + PolicyBatchHistory result = policySyncService.runFullSync(); + + assertThat(result.getStatus()).isEqualTo(BatchStatus.SUCCEEDED); + assertThat(result.getNewCount()).isEqualTo(1); + assertThat(result.getUpdatedCount()).isEqualTo(1); + assertThat(result.getUnchangedCount()).isEqualTo(1); + assertThat(result.getMissingCount()).isEqualTo(1); + assertThat(result.getErrorCount()).isZero(); + assertThat(historyRepository.count()).isEqualTo(1); // 요청→실행→성공이 한 행에서 전이 + + Policy created = policyRepository.findByPolicyNoIn(List.of("P-NEW")).getFirst(); + assertThat(created.getTitle()).isEqualTo("신규 정책"); + assertThat(policyRegionRepository.findAll()) + .singleElement() + .satisfies(link -> assertThat(link.getRegion().getCode()).isEqualTo("11000")); + + Policy updated = policyRepository.findByPolicyNoIn(List.of("P-UPD")).getFirst(); + assertThat(updated.getTitle()).isEqualTo("갱신된 제목"); + + Policy gone = policyRepository.findByPolicyNoIn(List.of("P-GONE")).getFirst(); + assertThat(gone.getMissingCount()).isEqualTo(1); // 첫 누락 — 아직 VISIBLE + assertThat(gone.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + } + + @Test + void fetch가_실패하면_DB에_손대지_않고_FAILED_이력을_남긴_뒤_예외를_다시_던진다() { + policyRepository.save(policy("P-KEEP", "건드리면 안 됨", null)); + when(policyApiClient.fetchAll()) + .thenThrow(new PolicySyncException("정책 API 1페이지 요청이 3회 모두 실패 — 회차 중단")); + + assertThatThrownBy(policySyncService::runFullSync).isInstanceOf(PolicySyncException.class); + + assertThat(historyRepository.findAll()) + .singleElement() + .satisfies( + h -> { + assertThat(h.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(h.getFailureMessage()).contains("회차 중단"); + }); + Policy kept = policyRepository.findByPolicyNoIn(List.of("P-KEEP")).getFirst(); + assertThat(kept.getMissingCount()).isZero(); // 어제 데이터 그대로 — 누락 처리도 안 함 + assertThat(kept.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + verify(policySyncLock).release("test-token"); // 실패해도 락은 반드시 해제 + } + + @Test + void 락_획득에_실패하면_이력도_남기지_않고_아무_작업_없이_예외를_던진다() { + when(policySyncLock.tryAcquire()).thenReturn(Optional.empty()); + + assertThatThrownBy(policySyncService::runFullSync) + .isInstanceOf(PolicySyncException.class) + .hasMessageContaining("이미 실행 중"); + + assertThat(historyRepository.count()).isZero(); // 거부된 시도는 회차가 아니다 + verify(policyApiClient, never()).fetchAll(); + verify(policySyncLock, never()).release(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void 숨겨진_정책이_같은_수정일로_재등장해도_변경으로_취급해_다시_보이게_한다() throws IOException { + Policy hidden = policy("P-BACK", "돌아온 정책", LocalDateTime.of(2026, 1, 1, 0, 0)); + hidden.markMissing(); + hidden.markMissing(); + hidden.markMissing(); // HIDDEN + policyRepository.save(hidden); + when(policyApiClient.fetchAll()) + .thenReturn( + List.of( + item( + "{\"plcyNo\":\"P-BACK\",\"plcyNm\":\"돌아온 정책\"," + + "\"lastMdfcnDt\":\"2026-01-01 00:00:00\"}"))); + + PolicyBatchHistory result = policySyncService.runFullSync(); + + assertThat(result.getUpdatedCount()).isEqualTo(1); // SKIP이 아니라 변경으로 분류돼야 복구된다 + Policy back = policyRepository.findByPolicyNoIn(List.of("P-BACK")).getFirst(); + assertThat(back.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + assertThat(back.getMissingCount()).isZero(); + } + + @Test + void 수동_실행은_락_거부시_409용_CustomException을_던지고_아무_작업도_하지_않는다() { + when(policySyncLock.tryAcquire()).thenReturn(Optional.empty()); + + assertThatThrownBy(policySyncService::startFullSyncAsync) + .isInstanceOf(com.bop.youthpick.global.error.CustomException.class) + .satisfies( + e -> + assertThat( + ((com.bop.youthpick.global.error.CustomException) e) + .getErrorCode()) + .isEqualTo( + com.bop.youthpick.sync.exception.SyncErrorCode + .SYNC_ALREADY_RUNNING)); + + assertThat(historyRepository.count()).isZero(); + verify(taskExecutor, never()).execute(org.mockito.ArgumentMatchers.any()); + } + + @Test + void 수동_실행은_백그라운드에서_배치를_수행하고_락을_해제한다() { + runInline(); + when(policyApiClient.fetchAll()).thenReturn(List.of()); + + policySyncService.startFullSyncAsync(); + + assertThat(historyRepository.findAll()) + .singleElement() + .satisfies(h -> assertThat(h.getStatus()).isEqualTo(BatchStatus.SUCCEEDED)); + verify(policySyncLock).release("test-token"); + } + + @Test + void 수동_실행의_백그라운드_실패는_밖으로_던지지_않고_FAILED_이력과_락_해제만_남긴다() { + runInline(); + when(policyApiClient.fetchAll()).thenThrow(new PolicySyncException("페이지 요청 3회 실패")); + + policySyncService.startFullSyncAsync(); // 202는 이미 나간 뒤 — 예외가 여기로 나오면 안 된다 + + assertThat(historyRepository.findAll()) + .singleElement() + .satisfies(h -> assertThat(h.getStatus()).isEqualTo(BatchStatus.FAILED)); + verify(policySyncLock).release("test-token"); + } + + /** 목 executor가 제출된 작업을 현재 스레드에서 즉시 실행하게 한다. */ + private void runInline() { + org.mockito.Mockito.doAnswer( + inv -> { + ((Runnable) inv.getArgument(0)).run(); + return null; + }) + .when(taskExecutor) + .execute(org.mockito.ArgumentMatchers.any(Runnable.class)); + } + + private static YouthPolicyItem item(String json) throws IOException { + return OM.readValue(json, YouthPolicyItem.class); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncStartupRunnerTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncStartupRunnerTest.java new file mode 100644 index 0000000..530e21e --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncStartupRunnerTest.java @@ -0,0 +1,43 @@ +package com.bop.youthpick.sync.service; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.policy.repository.PolicyRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PolicySyncStartupRunnerTest { + + @Mock private PolicyRepository policyRepository; + + @Mock private PolicySyncService policySyncService; + + @InjectMocks private PolicySyncStartupRunner runner; + + @Test + @DisplayName("policies가 비어 있으면 시작 시 전량 수집을 실행한다") + void runsInitialSyncWhenEmpty() { + when(policyRepository.count()).thenReturn(0L); + + runner.run(null); + + verify(policySyncService).runFullSync(); + } + + @Test + @DisplayName("policies에 데이터가 있으면 수집을 실행하지 않는다") + void skipsWhenPoliciesExist() { + when(policyRepository.count()).thenReturn(2633L); + + runner.run(null); + + verify(policySyncService, never()).runFullSync(); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java new file mode 100644 index 0000000..b153a3c --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java @@ -0,0 +1,133 @@ +package com.bop.youthpick.sync.service; + +import static com.bop.youthpick.policy.entity.PolicyFixture.policy; +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.sync.dto.PolicyUpsertItem; +import com.bop.youthpick.sync.dto.PolicyWriteResult; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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.context.annotation.Import; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +// Writer가 청크마다 자체 트랜잭션을 커밋하는 컴포넌트라, 테스트를 감싸는 트랜잭션(기본 롤백)과 +// 충돌하지 않도록 NOT_SUPPORTED로 끄고 @AfterEach에서 직접 정리한다. +@DataJpaTest +@Import({PolicyUpsertWriter.class, JpaAuditingConfig.class}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class PolicyUpsertWriterTest { + + @Autowired private PolicyUpsertWriter writer; + @Autowired private PolicyRepository policyRepository; + @Autowired private PolicyRegionRepository policyRegionRepository; + @Autowired private RegionRepository regionRepository; + + @AfterEach + void cleanUp() { + policyRegionRepository.deleteAll(); + policyRepository.deleteAll(); + regionRepository.deleteAll(); + } + + @Test + void 신규_정책을_INSERT하고_지역을_연결한다() { + Region seoul = regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + + PolicyWriteResult result = + writer.upsertAll( + List.of( + new PolicyUpsertItem( + policy("R2026-001", "새 정책", null), List.of(seoul)))); + + assertThat(result).isEqualTo(new PolicyWriteResult(1, 0, 0)); + assertThat(policyRepository.findAll()) + .singleElement() + .satisfies(saved -> assertThat(saved.getTitle()).isEqualTo("새 정책")); + assertThat(policyRegionRepository.findAll()) + .singleElement() + .satisfies(link -> assertThat(link.getRegion().getCode()).isEqualTo("11000")); + } + + @Test + void 기존_정책은_UPDATE하고_지역_링크를_교체하며_숨김_상태를_리셋한다() { + Region seoul = regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + Region busan = regionRepository.save(Region.create("26000", "부산광역시", "부산광역시")); + Policy existing = policy("R2026-001", "옛 제목", LocalDateTime.of(2026, 1, 1, 0, 0)); + existing.markMissing(); + existing.markMissing(); + existing.markMissing(); // HIDDEN — 재등장 시 리셋되는지 확인용 + existing = policyRepository.save(existing); + policyRegionRepository.save(PolicyRegion.create(existing, seoul)); + + PolicyWriteResult result = + writer.upsertAll( + List.of( + new PolicyUpsertItem( + policy( + "R2026-001", + "새 제목", + LocalDateTime.of(2026, 7, 1, 0, 0)), + List.of(busan)))); + + assertThat(result).isEqualTo(new PolicyWriteResult(0, 1, 0)); + assertThat(policyRepository.findAll()) + .singleElement() + .satisfies( + saved -> { + assertThat(saved.getTitle()).isEqualTo("새 제목"); + assertThat(saved.getMissingCount()).isZero(); + assertThat(saved.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + }); + assertThat(policyRegionRepository.findAll()) + .singleElement() + .satisfies(link -> assertThat(link.getRegion().getCode()).isEqualTo("26000")); + } + + @Test + void 깨진_정책이_섞인_청크는_건별_재시도로_범인만_제외하고_저장한다() { + // title은 NOT NULL — null이면 INSERT가 터지는 "범인" + PolicyWriteResult result = + writer.upsertAll( + List.of( + new PolicyUpsertItem(policy("R2026-001", "정상1", null), List.of()), + new PolicyUpsertItem(policy("R2026-002", null, null), List.of()), + new PolicyUpsertItem(policy("R2026-003", "정상2", null), List.of()))); + + assertThat(result).isEqualTo(new PolicyWriteResult(2, 0, 1)); + assertThat(policyRepository.findAll()) + .extracting(Policy::getPolicyNo) + .containsExactlyInAnyOrder("R2026-001", "R2026-003"); + } + + @Test + void 사라진_정책은_누락_횟수를_올리고_3회_도달_시_숨긴다() { + Policy twiceMissed = policy("R2026-001", "2회 누락됨", null); + twiceMissed.markMissing(); + twiceMissed.markMissing(); + policyRepository.save(twiceMissed); + policyRepository.save(policy("R2026-002", "첫 누락", null)); + + int marked = writer.hideMissing(List.of("R2026-001", "R2026-002", "R9999-999")); + + assertThat(marked).isEqualTo(2); // DB에 없는 번호는 무시 + Policy first = policyRepository.findByPolicyNoIn(List.of("R2026-001")).getFirst(); + Policy second = policyRepository.findByPolicyNoIn(List.of("R2026-002")).getFirst(); + assertThat(first.getMissingCount()).isEqualTo(3); + assertThat(first.getVisibility()).isEqualTo(PolicyVisibility.HIDDEN); + assertThat(second.getMissingCount()).isEqualTo(1); + assertThat(second.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + } +} diff --git a/src/test/resources/fixtures/youth-policy-response.json b/src/test/resources/fixtures/youth-policy-response.json new file mode 100644 index 0000000..3ecac38 --- /dev/null +++ b/src/test/resources/fixtures/youth-policy-response.json @@ -0,0 +1,107 @@ +{ + "resultCode": 200, + "resultMessage": "성공", + "result": { + "pagging": { + "totCount": 2635, + "pageNum": 1, + "pageSize": 2 + }, + "youthPolicyList": [ + { + "plcyNo": "R2024033012016", + "plcyNm": "청년 월세 특별지원", + "plcyExplnCn": "경제적 어려움을 겪는 청년층의 주거비 부담 경감", + "plcySprtCn": "월 최대 20만원 월세 지원 (12개월)", + "lclsfNm": "주거", + "mclsfNm": "주택 및 거주지", + "plcyKywdNm": "월세,주거비,청년", + "zipCd": "11110,11140,11170", + "sprtTrgtMinAge": "19", + "sprtTrgtMaxAge": "34", + "sprtTrgtAgeLmtYn": "Y", + "earnCndSeCd": "0043002", + "earnMinAmt": "0", + "earnMaxAmt": "5000", + "earnEtcCn": "", + "jobCd": "0013001,0013002", + "schoolCd": "0049010", + "mrgSttsCd": "0055003", + "plcyMajorCd": "0011009", + "sbizCd": "0014010", + "aplyPrdSeCd": "0057001", + "aplyYmd": "20260101 ~ 20261231", + "bizPrdBgngYmd": "20260101", + "bizPrdEndYmd": "20261231", + "bizPrdEtcCn": "", + "sprtSclCnt": "15000", + "sprtArvlSeqYn": "Y", + "sprtSclLmtYn": "Y", + "aplyUrlAddr": "https://www.myhome.go.kr", + "refUrlAddr1": "https://www.molit.go.kr", + "refUrlAddr2": "", + "sbmsnDcmntCn": "신분증, 임대차계약서", + "plcyAplyMthdCn": "온라인 신청", + "srngMthdCn": "서류 심사", + "addAplyQlfcCndCn": "무주택자", + "ptcpPrpTrgtCn": "주택 소유자 제외", + "etcMttrCn": "", + "sprvsnInstCdNm": "국토교통부", + "operInstCdNm": "한국토지주택공사", + "plcyAprvSttsCd": "0044002", + "inqCnt": "125478", + "frstRegDt": "2026-01-02 09:15:30", + "lastMdfcnDt": "2026-06-15 14:22:01", + "bscPlanCycl": "제1차(2026~2030)", + "pvsnInstGroupCd": "0053002" + }, + { + "plcyNo": "R2024100599999", + "plcyNm": "청년 창업 지원 (상시)", + "plcyExplnCn": " ", + "plcySprtCn": "창업 자금 지원", + "lclsfNm": "일자리", + "mclsfNm": "창업", + "plcyKywdNm": "창업", + "zipCd": "", + "sprtTrgtMinAge": "0", + "sprtTrgtMaxAge": "0", + "sprtTrgtAgeLmtYn": "N", + "earnCndSeCd": "0043001", + "earnMinAmt": "0", + "earnMaxAmt": "0", + "earnEtcCn": "", + "jobCd": "0013010", + "schoolCd": "0049010", + "mrgSttsCd": "0055003", + "plcyMajorCd": "0011009", + "sbizCd": "", + "aplyPrdSeCd": "0057002", + "aplyYmd": "", + "bizPrdBgngYmd": "", + "bizPrdEndYmd": "", + "bizPrdEtcCn": "연중", + "sprtSclCnt": "0", + "sprtArvlSeqYn": "", + "sprtSclLmtYn": "", + "aplyUrlAddr": "", + "refUrlAddr1": "", + "refUrlAddr2": "", + "sbmsnDcmntCn": "", + "plcyAplyMthdCn": "", + "srngMthdCn": "", + "addAplyQlfcCndCn": "", + "ptcpPrpTrgtCn": "", + "etcMttrCn": "", + "sprvsnInstCdNm": "중소벤처기업부", + "operInstCdNm": "", + "plcyAprvSttsCd": "0044002", + "inqCnt": "302", + "frstRegDt": "2026-03-10 11:00:00", + "lastMdfcnDt": "2026-03-10 11:00:00", + "bscPlanCycl": "", + "pvsnInstGroupCd": "" + } + ] + } +} From caec61bbd908de1b6d4a7dfb1050e268c54c1033 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 16 Jul 2026 12:34:30 +0900 Subject: [PATCH 31/93] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EB=B3=B8=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(=EC=A0=95=EC=B1=85=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=8B=9C=20=EA=B8=B0=EB=A1=9D=20+=20=EB=AA=A9=EB=A1=9D=20AP?= =?UTF-8?q?I)=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 최근 본 정책 기능 추가 (정책 상세 조회 시 기록 + 목록 API) Co-Authored-By: Claude Fable 5 * refac: 최근 본 정책 기록 실패가 상세 조회를 실패시키지 않게 격리, 보관 상한 로직 제거 Co-Authored-By: Claude Fable 5 * refac: 최근 본 정책 테이블/클래스 명명을 policy_recent_views(PolicyRecentView) 기준으로 통일 Co-Authored-By: Claude Fable 5 * refac: 최근 본 정책 API 경로를 엔티티 명명에 맞춰 /api/v1/policy-recent-views로 변경 Co-Authored-By: Claude Fable 5 * refac: CurrentUserArgumentResolver의 불필요한 annotation null 가드 제거 (리뷰 반영) Co-Authored-By: Claude Fable 5 * chore: PolicyRepository import 순서 포맷 수정 (spotlessApply) Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Fable 5 --- docs/api-spec.md | 16 ++- .../youthpick/auth/service/CurrentUser.java | 8 +- .../service/CurrentUserArgumentResolver.java | 5 + .../global/config/SecurityConfig.java | 3 +- .../policy/controller/PolicyController.java | 26 ++++ .../PolicyRecentViewController.java | 31 +++++ .../policy/dto/PolicyDetailResponse.java | 77 +++++++++++ .../policy/dto/PolicyRecentViewResponse.java | 35 +++++ .../policy/entity/PolicyRecentView.java | 67 +++++++++ .../PolicyRecentViewRepository.java | 35 +++++ .../policy/repository/PolicyRepository.java | 3 + .../service/PolicyRecentViewService.java | 44 ++++++ .../policy/service/PolicyService.java | 58 ++++++++ .../V7__create_policy_recent_views.sql | 20 +++ .../CurrentUserArgumentResolverTest.java | 28 ++++ .../controller/PolicyControllerTest.java | 117 ++++++++++++++++ .../PolicyRecentViewControllerTest.java | 86 ++++++++++++ .../PolicyRecentViewRepositoryTest.java | 85 ++++++++++++ .../service/PolicyRecentViewServiceTest.java | 99 ++++++++++++++ .../policy/service/PolicyServiceTest.java | 127 ++++++++++++++++++ 20 files changed, 967 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/controller/PolicyController.java create mode 100644 src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyRecentViewResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/PolicyRecentView.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepository.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyRecentViewService.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyService.java create mode 100644 src/main/resources/db/migration/V7__create_policy_recent_views.sql create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyRecentViewControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepositoryTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyRecentViewServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java diff --git a/docs/api-spec.md b/docs/api-spec.md index 2485ee3..5e786fd 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -33,6 +33,7 @@ Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 A | 정책 검색 | 검색어 제안 | `GET` | `/api/v1/policies/search-suggestions` | 비회원 | query: keyword 선택 | | 정책 검색 | 정책 상세 조회 | `GET` | `/api/v1/policies/{policyId}` | 비회원 | path: policyId | | 맞춤 추천 | 맞춤정책 조회 | `GET` | `/api/v1/recommended-policies` | 회원 | query: region 선택, category 선택, keyword 선택 | +| 최근 본 정책 | 최근 본 정책 목록 | `GET` | `/api/v1/policy-recent-views` | 회원 | query: page 기본 0, size 기본 20 | | 관심 정책 | 관심정책 목록 | `GET` | `/api/v1/policy-applications` | 회원 | 없음 | | 관심 정책 | 관심정책 등록 | `PUT` | `/api/v1/policy-applications/{policyId}` | 회원 | path: policyId | | 관심 정책 | 관심정책 해제 | `DELETE` | `/api/v1/policy-applications/{policyId}` | 회원 | path: policyId | @@ -176,7 +177,7 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 ### 정책 상세 조회 -단일 정책의 상세 정보를 조회한다. +단일 정책의 상세 정보를 조회한다. 삭제·숨김 처리된 정책은 404(`P001`)로 응답한다. 로그인 사용자(access token 포함 요청)가 조회하면 해당 정책이 최근 본 정책으로 기록된다. | 항목 | 내용 | |---|---| @@ -198,6 +199,19 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 | 권한 | 회원 | | 파라미터 | query: region 선택, category 선택, keyword 선택 | +## 최근 본 정책 + +### 최근 본 정책 목록 + +로그인 사용자가 최근에 상세 조회한 정책 목록을 마지막 조회 시각 내림차순으로 조회한다. 같은 정책을 다시 보면 기록이 늘어나지 않고 조회 시각만 갱신된다. 삭제·숨김 처리된 정책은 목록에서 제외된다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policy-recent-views` | +| 권한 | 회원 | +| 파라미터 | query: page 기본 0, size 기본 20 | + ## 관심 정책 ### 관심정책 목록 diff --git a/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java b/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java index c182a29..16675e6 100644 --- a/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java +++ b/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java @@ -9,7 +9,13 @@ * {@code Long} 파라미터에 붙이면 {@code Authorization} 헤더의 JWT access token(= {@code * JwtAuthenticationFilter}가 채운 인증 principal)에서 userId를 읽어 그대로 주입한다. DB 조회는 하지 않는다. 인증되지 않은 요청이면 * {@code AuthErrorCode.UNAUTHORIZED}로 거부된다. + * + *

비회원도 허용하는 공개 엔드포인트에서 로그인 사용자만 선택적으로 식별하려면 {@code required = false}로 지정한다. 이 경우 인증되지 않은 요청에는 거부 + * 대신 {@code null}이 주입된다. */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) -public @interface CurrentUser {} +public @interface CurrentUser { + + boolean required() default true; +} diff --git a/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java b/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java index 9efcea8..c7eb284 100644 --- a/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java +++ b/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java @@ -14,6 +14,7 @@ /** * {@link CurrentUser}가 붙은 {@code Long} 파라미터를 SecurityContext의 {@link AuthPrincipal} userId로 채운다. + * {@code required = false}면 미인증 요청에 거부 대신 {@code null}을 주입한다. */ @Component public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { @@ -33,6 +34,10 @@ public Object resolveArgument( Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !(authentication.getPrincipal() instanceof AuthPrincipal principal)) { + // supportsParameter가 @CurrentUser 파라미터만 통과시키므로 annotation은 항상 존재한다. + if (!parameter.getParameterAnnotation(CurrentUser.class).required()) { + return null; + } throw new AuthException(AuthErrorCode.UNAUTHORIZED); } return principal.userId(); diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 8ba88ae..9a50905 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -73,12 +73,13 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/v1/health") .permitAll() // 회원 전용 — 로그인 상태 조회/로그아웃, 회원 탈퇴(컨트롤러 미구현, 경로만 선점), - // 마이페이지(관심정책/추천/읽음/프로필), 챗봇 프로필 동의 + // 마이페이지(관심정책/추천/읽음/프로필), 최근 본 정책, 챗봇 프로필 동의 .requestMatchers( "/api/v1/auth/me", "/api/v1/auth/logout", "/api/v1/users", "/api/v1/me/**", + "/api/v1/policy-recent-views", "/api/v1/policy-chat/profile-consent") .authenticated() // 명세에 없는 나머지 경로(구현 중인 다른 도메인 등)는 개발 편의상 열어 둔다. diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java new file mode 100644 index 0000000..89477e3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java @@ -0,0 +1,26 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.PolicyDetailResponse; +import com.bop.youthpick.policy.service.PolicyService; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RestController +@RequestMapping("/api/v1/policies") +@RequiredArgsConstructor +public class PolicyController { + + private final PolicyService policyService; + + /** 정책 상세 조회 (비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다. */ + @GetMapping("/{policyId}") + public ApiResponse getDetail( + @PathVariable Long policyId, @CurrentUser(required = false) Long userId) { + return ApiResponse.ok(policyService.getDetail(policyId, userId)); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java new file mode 100644 index 0000000..ad6b1c9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java @@ -0,0 +1,31 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.PolicyRecentViewResponse; +import com.bop.youthpick.policy.service.PolicyRecentViewService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/policy-recent-views") +@RequiredArgsConstructor +public class PolicyRecentViewController { + + private final PolicyRecentViewService policyRecentViewService; + + /** 최근 본 정책 목록 (회원 전용). 마지막 조회 시각 내림차순. */ + @GetMapping + public ApiResponse> list( + @CurrentUser Long userId, @PageableDefault(size = 20) Pageable pageable) { + Page page = + policyRecentViewService.getRecentPolicies(userId, pageable); + return ApiResponse.ok(page.getContent(), page); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java new file mode 100644 index 0000000..bfa3614 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java @@ -0,0 +1,77 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Policy; +import java.time.LocalDate; +import java.util.List; + +/** 정책 상세 조회 응답 (유저 대상). 자격 판정용 내부 코드·보류 필드·raw payload는 노출하지 않는다. */ +public record PolicyDetailResponse( + Long id, + String policyNo, + String title, + String description, + String supportContent, + String keywords, + String category, + String middleCategory, + String organizationName, + Integer minAge, + Integer maxAge, + String incomeConditionCode, + Integer incomeMaxAmount, + String incomeEtcContent, + String additionalQualification, + String participationRestriction, + String applicationPeriodType, + LocalDate applicationStartDate, + LocalDate applicationEndDate, + LocalDate businessPeriodBegin, + LocalDate businessPeriodEnd, + String businessPeriodEtc, + Integer supportScaleCount, + boolean firstComeFirstServed, + String applicationUrl, + String referenceUrl1, + String referenceUrl2, + String applicationMethod, + String submissionDocuments, + String screeningMethod, + int viewCount, + List regionCodes) { + + public static PolicyDetailResponse from(Policy policy, List regionCodes) { + return new PolicyDetailResponse( + policy.getId(), + policy.getPolicyNo(), + policy.getTitle(), + policy.getDescription(), + policy.getSupportContent(), + policy.getKeywords(), + policy.getCategory(), + policy.getMiddleCategory(), + policy.getOrganizationName(), + policy.getMinAge(), + policy.getMaxAge(), + policy.getIncomeConditionCode(), + policy.getIncomeMaxAmount(), + policy.getIncomeEtcContent(), + policy.getAdditionalQualification(), + policy.getParticipationRestriction(), + policy.getApplicationPeriodType(), + policy.getApplicationStartDate(), + policy.getApplicationEndDate(), + policy.getBusinessPeriodBegin(), + policy.getBusinessPeriodEnd(), + policy.getBusinessPeriodEtc(), + policy.getSupportScaleCount(), + policy.isFirstComeFirstServed(), + policy.getApplicationUrl(), + policy.getReferenceUrl1(), + policy.getReferenceUrl2(), + policy.getApplicationMethod(), + policy.getSubmissionDocuments(), + policy.getScreeningMethod(), + policy.getViewCount(), + regionCodes); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyRecentViewResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyRecentViewResponse.java new file mode 100644 index 0000000..d4d3f09 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyRecentViewResponse.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRecentView; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 최근 본 정책 목록의 카드 응답. */ +public record PolicyRecentViewResponse( + Long policyId, + String title, + String description, + String category, + String middleCategory, + String organizationName, + LocalDate applicationStartDate, + LocalDate applicationEndDate, + String applicationUrl, + LocalDateTime viewedAt) { + + public static PolicyRecentViewResponse from(PolicyRecentView view) { + Policy policy = view.getPolicy(); + return new PolicyRecentViewResponse( + policy.getId(), + policy.getTitle(), + policy.getDescription(), + policy.getCategory(), + policy.getMiddleCategory(), + policy.getOrganizationName(), + policy.getApplicationStartDate(), + policy.getApplicationEndDate(), + policy.getApplicationUrl(), + view.getViewedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyRecentView.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyRecentView.java new file mode 100644 index 0000000..e161522 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyRecentView.java @@ -0,0 +1,67 @@ +package com.bop.youthpick.policy.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 최근 본 정책 — 로그인 사용자가 정책 상세를 조회한 기록. 같은 정책을 다시 보면 행을 늘리지 않고 {@link #touch()}로 viewed_at만 갱신한다 + * (UNIQUE(user, policy)). + */ +@Entity +@Table( + name = "policy_recent_views", + uniqueConstraints = + @UniqueConstraint( + name = "uk_policy_recent_views_user_policy", + columnNames = {"user_id", "policy_id"}), + indexes = + @Index( + name = "idx_policy_recent_views_user_viewed", + columnList = "user_id, viewed_at")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PolicyRecentView extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "policy_id", nullable = false) + private Policy policy; + + /** 마지막 조회 시각 (재조회 시 갱신) */ + @Column(name = "viewed_at", nullable = false) + private LocalDateTime viewedAt; + + public static PolicyRecentView create(User user, Policy policy) { + PolicyRecentView view = new PolicyRecentView(); + view.user = user; + view.policy = policy; + view.viewedAt = LocalDateTime.now(); + return view; + } + + public void touch() { + this.viewedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepository.java new file mode 100644 index 0000000..bd29d85 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepository.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.PolicyRecentView; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PolicyRecentViewRepository extends JpaRepository { + + Optional findByUserIdAndPolicyId(Long userId, Long policyId); + + /** 삭제/숨김 처리된 정책은 목록에서 제외한다. */ + @Query( + value = + """ + select rpv from PolicyRecentView rpv + join fetch rpv.policy p + where rpv.user.id = :userId + and p.deletedAt is null + and p.visibility = com.bop.youthpick.policy.entity.PolicyVisibility.VISIBLE + order by rpv.viewedAt desc + """, + countQuery = + """ + select count(rpv) from PolicyRecentView rpv + join rpv.policy p + where rpv.user.id = :userId + and p.deletedAt is null + and p.visibility = com.bop.youthpick.policy.entity.PolicyVisibility.VISIBLE + """) + Page findVisibleByUserId(@Param("userId") Long userId, Pageable pageable); +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index f7c9732..6239fdc 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -5,6 +5,7 @@ import com.bop.youthpick.policy.entity.PolicyVisibility; import java.util.Collection; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; @@ -21,4 +22,6 @@ public interface PolicyRepository List findByPolicyNoIn(Collection policyNos); long countByVisibilityAndDeletedAtIsNull(PolicyVisibility visibility); + + Optional findByIdAndVisibilityAndDeletedAtIsNull(Long id, PolicyVisibility visibility); } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyRecentViewService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyRecentViewService.java new file mode 100644 index 0000000..e6d1816 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyRecentViewService.java @@ -0,0 +1,44 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.policy.dto.PolicyRecentViewResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRecentView; +import com.bop.youthpick.policy.repository.PolicyRecentViewRepository; +import com.bop.youthpick.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PolicyRecentViewService { + + private final PolicyRecentViewRepository policyRecentViewRepository; + private final UserRepository userRepository; + + /** + * 정책 상세 조회 기록. 이미 본 정책이면 viewed_at만 갱신한다. 기록 실패가 상세 조회 응답까지 실패시키지 않도록 별도 트랜잭션(REQUIRES_NEW)으로 + * 실행한다 — 같은 트랜잭션에서 예외를 잡기만 하면 rollback-only로 표시돼 조회 커밋까지 함께 실패한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(Long userId, Policy policy) { + policyRecentViewRepository + .findByUserIdAndPolicyId(userId, policy.getId()) + .ifPresentOrElse( + PolicyRecentView::touch, + () -> + policyRecentViewRepository.save( + PolicyRecentView.create( + userRepository.getReferenceById(userId), policy))); + } + + @Transactional(readOnly = true) + public Page getRecentPolicies(Long userId, Pageable pageable) { + return policyRecentViewRepository + .findVisibleByUserId(userId, pageable) + .map(PolicyRecentViewResponse::from); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java new file mode 100644 index 0000000..3e6ff35 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -0,0 +1,58 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyDetailResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 유저 대상 정책 조회. 관리자용 조회/수정은 {@link AdminPolicyService}. */ +@Service +@RequiredArgsConstructor +public class PolicyService { + + private static final Logger log = LoggerFactory.getLogger(PolicyService.class); + + private final PolicyRepository policyRepository; + private final PolicyRegionRepository policyRegionRepository; + private final PolicyRecentViewService policyRecentViewService; + + /** + * 정책 상세 조회. 삭제(soft delete)·숨김 정책은 존재하지 않는 것으로 취급한다. 로그인 사용자({@code userId != null})의 조회는 최근 본 + * 정책으로 기록하되, 기록은 부가 동작이라 실패해도 조회는 정상 응답한다. + */ + @Transactional(readOnly = true) + public PolicyDetailResponse getDetail(Long policyId, @Nullable Long userId) { + Policy policy = + policyRepository + .findByIdAndVisibilityAndDeletedAtIsNull(policyId, PolicyVisibility.VISIBLE) + .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + + if (userId != null) { + try { + policyRecentViewService.record(userId, policy); + } catch (RuntimeException e) { + log.warn( + "최근 본 정책 기록에 실패했습니다. 조회는 정상 진행합니다. userId={}, policyId={}", + userId, + policyId, + e); + } + } + + List regionCodes = + policyRegionRepository.findByPolicyIdIn(List.of(policyId)).stream() + .map(policyRegion -> policyRegion.getRegion().getCode()) + .toList(); + return PolicyDetailResponse.from(policy, regionCodes); + } +} diff --git a/src/main/resources/db/migration/V7__create_policy_recent_views.sql b/src/main/resources/db/migration/V7__create_policy_recent_views.sql new file mode 100644 index 0000000..2091055 --- /dev/null +++ b/src/main/resources/db/migration/V7__create_policy_recent_views.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- V7 — policy_recent_views 신규 (최근 본 정책) +-- 로그인 사용자가 정책 상세를 조회하면 1건씩 기록한다. +-- 같은 정책을 다시 보면 행을 늘리지 않고 viewed_at만 갱신한다 +-- (UNIQUE(user_id, policy_id)). +-- ============================================================ + +CREATE TABLE policy_recent_views ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + policy_id BIGINT NOT NULL, + viewed_at DATETIME NOT NULL COMMENT '마지막 조회 시각 (재조회 시 갱신)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_policy_recent_views_user_policy (user_id, policy_id), + KEY idx_policy_recent_views_user_viewed (user_id, viewed_at), + CONSTRAINT fk_policy_recent_views_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_policy_recent_views_policy FOREIGN KEY (policy_id) REFERENCES policies (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '최근 본 정책 (사용자별 정책 상세 조회 기록)'; diff --git a/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java b/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java index 91fe59e..a78dfd6 100644 --- a/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java @@ -61,11 +61,37 @@ void clearContext() { .isInstanceOf(AuthException.class); } + @Test + void required가_false면_인증되지_않아도_null을_반환한다() throws Exception { + Object resolved = resolver.resolveArgument(optionalUserIdParameter(), null, null, null); + + assertThat(resolved).isNull(); + } + + @Test + void required가_false여도_인증된_요청이면_userId를_반환한다() throws Exception { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(1L, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + + Object resolved = resolver.resolveArgument(optionalUserIdParameter(), null, null, null); + + assertThat(resolved).isEqualTo(1L); + } + private MethodParameter annotatedUserIdParameter() throws Exception { Method method = TestController.class.getMethod("withCurrentUser", Long.class); return new MethodParameter(method, 0); } + private MethodParameter optionalUserIdParameter() throws Exception { + Method method = TestController.class.getMethod("withOptionalCurrentUser", Long.class); + return new MethodParameter(method, 0); + } + private MethodParameter plainUserIdParameter() throws Exception { Method method = TestController.class.getMethod("withoutCurrentUser", Long.class); return new MethodParameter(method, 0); @@ -74,6 +100,8 @@ private MethodParameter plainUserIdParameter() throws Exception { private static class TestController { public void withCurrentUser(@CurrentUser Long userId) {} + public void withOptionalCurrentUser(@CurrentUser(required = false) Long userId) {} + public void withoutCurrentUser(Long userId) {} } } diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java new file mode 100644 index 0000000..3db3b4f --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java @@ -0,0 +1,117 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyDetailResponse; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.service.PolicyService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = PolicyController.class) +@AutoConfigureMockMvc(addFilters = false) +class PolicyControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private PolicyService policyService; + + private static final PolicyDetailResponse DETAIL_RESPONSE = + new PolicyDetailResponse( + 1L, + "R2026001", + "청년 월세 지원", + "설명", + "지원내용", + "월세,주거", + "주거", + "중분류", + "국토교통부", + 19, + 34, + null, + null, + null, + null, + null, + null, + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31), + null, + null, + null, + null, + false, + "https://example.com", + null, + null, + null, + null, + null, + 0, + List.of("11110")); + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void 비회원_상세_조회는_userId_없이_200과_상세를_반환한다() throws Exception { + when(policyService.getDetail(eq(1L), isNull())).thenReturn(DETAIL_RESPONSE); + + mockMvc.perform(get("/api/v1/policies/{policyId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.title").value("청년 월세 지원")) + .andExpect(jsonPath("$.data.regionCodes[0]").value("11110")); + } + + @Test + void 로그인_상태의_상세_조회는_principal의_userId가_전달된다() throws Exception { + when(policyService.getDetail(1L, 7L)).thenReturn(DETAIL_RESPONSE); + authenticateAs(7L); + + mockMvc.perform(get("/api/v1/policies/{policyId}", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1)); + } + + @Test + void 없는_정책이면_404와_P001을_반환한다() throws Exception { + when(policyService.getDetail(eq(99L), isNull())) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/policies/{policyId}", 99L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("P001")); + } + + /** addFilters=false로 시큐리티 필터를 건너뛰므로 SecurityContextHolder를 직접 채운다. */ + private void authenticateAs(Long userId) { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(userId, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyRecentViewControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyRecentViewControllerTest.java new file mode 100644 index 0000000..981d2dc --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyRecentViewControllerTest.java @@ -0,0 +1,86 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.policy.dto.PolicyRecentViewResponse; +import com.bop.youthpick.policy.service.PolicyRecentViewService; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = PolicyRecentViewController.class) +@AutoConfigureMockMvc(addFilters = false) +class PolicyRecentViewControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private PolicyRecentViewService policyRecentViewService; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void 최근_본_정책_목록은_200과_페이지_데이터를_반환한다() throws Exception { + PolicyRecentViewResponse response = + new PolicyRecentViewResponse( + 10L, + "청년 월세 지원", + "설명", + "주거", + "중분류", + "국토교통부", + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 12, 31), + "https://example.com", + LocalDateTime.of(2026, 7, 16, 12, 0)); + Page page = new PageImpl<>(List.of(response)); + when(policyRecentViewService.getRecentPolicies(eq(1L), any(Pageable.class))) + .thenReturn(page); + authenticateAs(1L); + + mockMvc.perform(get("/api/v1/policy-recent-views")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].policyId").value(10)) + .andExpect(jsonPath("$.data[0].title").value("청년 월세 지원")) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void 미인증_요청이면_401과_A001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/policy-recent-views")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + + /** addFilters=false로 시큐리티 필터를 건너뛰므로 SecurityContextHolder를 직접 채운다. */ + private void authenticateAs(Long userId) { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(userId, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepositoryTest.java new file mode 100644 index 0000000..bdab62a --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRecentViewRepositoryTest.java @@ -0,0 +1,85 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRecentView; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PolicyRecentViewRepositoryTest { + + @Autowired private PolicyRecentViewRepository policyRecentViewRepository; + @Autowired private PolicyRepository policyRepository; + @Autowired private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = userRepository.save(User.createSocialUser("google", "pid-1", null, null)); + } + + @Test + void 삭제_숨김_정책은_제외하고_마지막_조회_시각_내림차순으로_조회한다() { + Policy visibleOld = savePolicy("P001", PolicyVisibility.VISIBLE, null); + Policy visibleNew = savePolicy("P002", PolicyVisibility.VISIBLE, null); + Policy hidden = savePolicy("P003", PolicyVisibility.HIDDEN, null); + Policy deleted = savePolicy("P004", PolicyVisibility.VISIBLE, LocalDateTime.now()); + + saveView(visibleOld, LocalDateTime.now().minusDays(2)); + saveView(visibleNew, LocalDateTime.now().minusDays(1)); + saveView(hidden, LocalDateTime.now()); + saveView(deleted, LocalDateTime.now()); + + Page page = + policyRecentViewRepository.findVisibleByUserId(user.getId(), PageRequest.of(0, 20)); + + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()) + .extracting(view -> view.getPolicy().getPolicyNo()) + .containsExactly("P002", "P001"); + } + + @Test + void 다른_사용자의_기록은_조회되지_않는다() { + User other = userRepository.save(User.createSocialUser("google", "pid-2", null, null)); + Policy policy = savePolicy("P001", PolicyVisibility.VISIBLE, null); + policyRecentViewRepository.save(PolicyRecentView.create(other, policy)); + + Page page = + policyRecentViewRepository.findVisibleByUserId(user.getId(), PageRequest.of(0, 20)); + + assertThat(page.getTotalElements()).isZero(); + } + + private Policy savePolicy( + String policyNo, PolicyVisibility visibility, LocalDateTime deletedAt) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "visibility", visibility); + ReflectionTestUtils.setField(policy, "deletedAt", deletedAt); + return policyRepository.save(policy); + } + + private void saveView(Policy policy, LocalDateTime viewedAt) { + PolicyRecentView view = PolicyRecentView.create(user, policy); + ReflectionTestUtils.setField(view, "viewedAt", viewedAt); + policyRecentViewRepository.save(view); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyRecentViewServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyRecentViewServiceTest.java new file mode 100644 index 0000000..3f89d18 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyRecentViewServiceTest.java @@ -0,0 +1,99 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.policy.dto.PolicyRecentViewResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRecentView; +import com.bop.youthpick.policy.repository.PolicyRecentViewRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyRecentViewServiceTest { + + @Mock private PolicyRecentViewRepository policyRecentViewRepository; + @Mock private UserRepository userRepository; + + private PolicyRecentViewService policyRecentViewService; + + private final User user = User.createSocialUser("google", "pid", null, null); + private final Policy policy = newPolicy(10L, "청년 월세 지원"); + + @BeforeEach + void setUp() { + policyRecentViewService = + new PolicyRecentViewService(policyRecentViewRepository, userRepository); + } + + @Test + void 처음_본_정책이면_새_기록을_저장한다() { + when(policyRecentViewRepository.findByUserIdAndPolicyId(1L, 10L)) + .thenReturn(Optional.empty()); + when(userRepository.getReferenceById(1L)).thenReturn(user); + + policyRecentViewService.record(1L, policy); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PolicyRecentView.class); + verify(policyRecentViewRepository).save(captor.capture()); + assertThat(captor.getValue().getPolicy()).isEqualTo(policy); + assertThat(captor.getValue().getViewedAt()).isNotNull(); + } + + @Test + void 이미_본_정책이면_새_기록_없이_viewedAt만_갱신한다() { + PolicyRecentView existing = PolicyRecentView.create(user, policy); + LocalDateTime oldViewedAt = LocalDateTime.now().minusDays(3); + ReflectionTestUtils.setField(existing, "viewedAt", oldViewedAt); + when(policyRecentViewRepository.findByUserIdAndPolicyId(1L, 10L)) + .thenReturn(Optional.of(existing)); + + policyRecentViewService.record(1L, policy); + + verify(policyRecentViewRepository, never()).save(any()); + assertThat(existing.getViewedAt()).isAfter(oldViewedAt); + } + + @Test + void 최근_본_목록을_카드_응답으로_변환한다() { + PolicyRecentView view = PolicyRecentView.create(user, policy); + Page page = new PageImpl<>(List.of(view)); + when(policyRecentViewRepository.findVisibleByUserId(eq(1L), any(Pageable.class))) + .thenReturn(page); + + Page result = + policyRecentViewService.getRecentPolicies(1L, PageRequest.of(0, 20)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).policyId()).isEqualTo(10L); + assertThat(result.getContent().get(0).title()).isEqualTo("청년 월세 지원"); + } + + private Policy newPolicy(Long id, String title) { + Policy newPolicy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(newPolicy, "id", id); + ReflectionTestUtils.setField(newPolicy, "policyNo", "R2026" + id); + ReflectionTestUtils.setField(newPolicy, "title", title); + return newPolicy; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java new file mode 100644 index 0000000..e2f9b1e --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -0,0 +1,127 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyDetailResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeanUtils; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyServiceTest { + + @Mock private PolicyRepository policyRepository; + @Mock private PolicyRegionRepository policyRegionRepository; + @Mock private PolicyRecentViewService policyRecentViewService; + + private PolicyService policyService; + + @BeforeEach + void setUp() { + policyService = + new PolicyService( + policyRepository, policyRegionRepository, policyRecentViewService); + } + + @Test + void 노출_중인_정책이면_상세와_지역코드를_반환한다() { + Policy policy = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findByPolicyIdIn(List.of(1L))) + .thenReturn(List.of(newPolicyRegion(policy, "11110"))); + + PolicyDetailResponse response = policyService.getDetail(1L, null); + + assertThat(response.id()).isEqualTo(1L); + assertThat(response.title()).isEqualTo("청년 월세 지원"); + assertThat(response.regionCodes()).containsExactly("11110"); + } + + @Test + void 없거나_삭제_숨김된_정책이면_POLICY_NOT_FOUND_예외를_던진다() { + when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull( + 99L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> policyService.getDetail(99L, 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PolicyErrorCode.POLICY_NOT_FOUND); + verify(policyRecentViewService, never()).record(1L, null); + } + + @Test + void 로그인_사용자의_조회는_최근_본_정책으로_기록한다() { + Policy policy = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); + + policyService.getDetail(1L, 7L); + + verify(policyRecentViewService).record(7L, policy); + } + + @Test + void 기록_저장이_실패해도_상세_조회는_정상_응답한다() { + Policy policy = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); + doThrow(new DataAccessResourceFailureException("DB 연결 실패")) + .when(policyRecentViewService) + .record(7L, policy); + + PolicyDetailResponse response = policyService.getDetail(1L, 7L); + + assertThat(response.id()).isEqualTo(1L); + assertThat(response.title()).isEqualTo("청년 월세 지원"); + } + + @Test + void 비회원_조회는_기록하지_않는다() { + Policy policy = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); + + policyService.getDetail(1L, null); + + verify(policyRecentViewService, never()).record(null, policy); + } + + private Policy newPolicy(Long id, String title) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "id", id); + ReflectionTestUtils.setField(policy, "policyNo", "R2026" + id); + ReflectionTestUtils.setField(policy, "title", title); + return policy; + } + + private PolicyRegion newPolicyRegion(Policy policy, String regionCode) { + Region region = BeanUtils.instantiateClass(Region.class); + ReflectionTestUtils.setField(region, "code", regionCode); + return PolicyRegion.create(policy, region); + } +} From d3ae95ff2eeb9db2c6013b632003be6956d51ee7 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Thu, 16 Jul 2026 14:42:48 +0900 Subject: [PATCH 32/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9D=91=EB=8B=B5=20=EC=A7=80=EC=97=AD=EC=9D=84=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=E2=86=92=EC=9D=B4=EB=A6=84(RegionResponse)?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EA=B0=95=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolicyDetailResponse.regionCodes(List) → regions(List). PolicyRegion이 이미 조인한 Region의 code/sidoName/name을 그대로 매핑하므로 별도 마스터 조회 없음. 기존 RegionResponse DTO 재사용. 검증: ./gradlew spotlessCheck test 통과 Closes #76 Claude-Session: https://claude.ai/code/session_01K6oeC8nM8dQiqGj2HrfUaR Co-authored-by: Claude Opus 4.8 --- .../youthpick/policy/dto/PolicyDetailResponse.java | 6 +++--- .../bop/youthpick/policy/service/PolicyService.java | 7 ++++--- .../policy/controller/PolicyControllerTest.java | 7 +++++-- .../youthpick/policy/service/PolicyServiceTest.java | 12 ++++++++---- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java index bfa3614..310b788 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java @@ -37,9 +37,9 @@ public record PolicyDetailResponse( String submissionDocuments, String screeningMethod, int viewCount, - List regionCodes) { + List regions) { - public static PolicyDetailResponse from(Policy policy, List regionCodes) { + public static PolicyDetailResponse from(Policy policy, List regions) { return new PolicyDetailResponse( policy.getId(), policy.getPolicyNo(), @@ -72,6 +72,6 @@ public static PolicyDetailResponse from(Policy policy, List regionCodes) policy.getSubmissionDocuments(), policy.getScreeningMethod(), policy.getViewCount(), - regionCodes); + regions); } } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 3e6ff35..e2ed093 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -2,6 +2,7 @@ import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.dto.PolicyDetailResponse; +import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.exception.PolicyErrorCode; @@ -49,10 +50,10 @@ public PolicyDetailResponse getDetail(Long policyId, @Nullable Long userId) { } } - List regionCodes = + List regions = policyRegionRepository.findByPolicyIdIn(List.of(policyId)).stream() - .map(policyRegion -> policyRegion.getRegion().getCode()) + .map(policyRegion -> RegionResponse.from(policyRegion.getRegion())) .toList(); - return PolicyDetailResponse.from(policy, regionCodes); + return PolicyDetailResponse.from(policy, regions); } } diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java index 3db3b4f..042ff5c 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java @@ -10,6 +10,7 @@ import com.bop.youthpick.auth.dto.AuthPrincipal; import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.dto.PolicyDetailResponse; +import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.service.PolicyService; import java.time.LocalDate; @@ -67,7 +68,7 @@ class PolicyControllerTest { null, null, 0, - List.of("11110")); + List.of(new RegionResponse("11680", "서울특별시", "강남구"))); @AfterEach void clearContext() { @@ -82,7 +83,9 @@ void clearContext() { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.id").value(1)) .andExpect(jsonPath("$.data.title").value("청년 월세 지원")) - .andExpect(jsonPath("$.data.regionCodes[0]").value("11110")); + .andExpect(jsonPath("$.data.regions[0].regionCode").value("11680")) + .andExpect(jsonPath("$.data.regions[0].provinceName").value("서울특별시")) + .andExpect(jsonPath("$.data.regions[0].districtName").value("강남구")); } @Test diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index e2f9b1e..b0b75b7 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -10,6 +10,7 @@ import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.dto.PolicyDetailResponse; +import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyRegion; import com.bop.youthpick.policy.entity.PolicyVisibility; @@ -45,18 +46,18 @@ void setUp() { } @Test - void 노출_중인_정책이면_상세와_지역코드를_반환한다() { + void 노출_중인_정책이면_상세와_지역을_이름까지_반환한다() { Policy policy = newPolicy(1L, "청년 월세 지원"); when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); when(policyRegionRepository.findByPolicyIdIn(List.of(1L))) - .thenReturn(List.of(newPolicyRegion(policy, "11110"))); + .thenReturn(List.of(newPolicyRegion(policy, "11680", "서울특별시", "강남구"))); PolicyDetailResponse response = policyService.getDetail(1L, null); assertThat(response.id()).isEqualTo(1L); assertThat(response.title()).isEqualTo("청년 월세 지원"); - assertThat(response.regionCodes()).containsExactly("11110"); + assertThat(response.regions()).containsExactly(new RegionResponse("11680", "서울특별시", "강남구")); } @Test @@ -119,9 +120,12 @@ private Policy newPolicy(Long id, String title) { return policy; } - private PolicyRegion newPolicyRegion(Policy policy, String regionCode) { + private PolicyRegion newPolicyRegion( + Policy policy, String regionCode, String sidoName, String name) { Region region = BeanUtils.instantiateClass(Region.class); ReflectionTestUtils.setField(region, "code", regionCode); + ReflectionTestUtils.setField(region, "sidoName", sidoName); + ReflectionTestUtils.setField(region, "name", name); return PolicyRegion.create(policy, region); } } From bda114d98a8514f16fc8e04792073e2eaf0ec5e2 Mon Sep 17 00:00:00 2001 From: seohyeon-tomato Date: Thu, 16 Jul 2026 21:32:33 +0900 Subject: [PATCH 33/93] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20CRU?= =?UTF-8?q?D=20API=20=EC=B6=94=EA=B0=80=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refac: 게시판 도메인을 board→post 패키지로 재구성 - board 패키지의 엔티티를 post 패키지로 이동(Post/PostCategory/Attachment) - 게시글 에러코드/예외(BoardErrorCode/BoardException)와 PostRepository 추가 - 게시글 조회는 공개, 생성·수정·삭제는 회원 전용으로 SecurityConfig 정리 - 기존 board.entity.Comment 삭제(댓글 기능은 별도 작업에서 재구성) Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 게시글 작성(Create) API 추가 - POST /api/v1/posts — 회원이 게시글을 작성한다 - 카테고리(QUESTION/REVIEW/FREE) 검증, 자유글은 정책 연결 불가·질문/후기는 정책 필수 - PostCreateRequest(Bean Validation), 응답은 PostDetailResponse Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 게시글 조회(Read) API 추가 - GET /api/v1/posts — 삭제되지 않은 게시글 목록을 최신순으로 페이지 조회 - GET /api/v1/posts/{postId} — 게시글 상세 조회, 없으면 B001 - 목록은 PostSummaryResponse, 페이지 정보는 ApiResponse meta에 담는다 Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 게시글 수정(Update) API 추가 - PUT /api/v1/posts/{postId} — 작성자만 게시글을 수정한다 - 작성자가 아니면 B002, 없는 글이면 B001 - 카테고리 변경 시에도 정책 연결 규칙(자유글/질문·후기)을 동일하게 검증 Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 게시글 삭제(Delete) API 추가 및 게시글 테스트 작성 - DELETE /api/v1/posts/{postId} — 작성자만 삭제(soft delete, deleted_at 기록) - 작성자가 아니면 B002, 없는 글이면 B001 - Post 엔티티/PostService/PostController 단위 테스트로 A(정상)/E(예외)/X(경계) 검증 Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 게시글 기능 추가 * fix: dev 게시판 코드와 post 도메인 통합 차후 수정 예정 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../board/dto/AdminAttachmentResponse.java | 2 +- .../board/dto/AdminCommunityPostResponse.java | 4 +- .../bop/youthpick/board/entity/Comment.java | 1 + .../AdminCommunityPostSpecifications.java | 4 +- .../repository/AttachmentRepository.java | 2 +- .../board/repository/PostRepository.java | 7 - .../board/service/AdminCommunityService.java | 4 +- .../global/config/SecurityConfig.java | 5 + .../post/controller/PostController.java | 63 +++++++ .../youthpick/post/dto/PostCreateRequest.java | 17 ++ .../post/dto/PostDetailResponse.java | 35 ++++ .../post/dto/PostSummaryResponse.java | 31 ++++ .../youthpick/post/dto/PostUpdateRequest.java | 17 ++ .../{board => post}/entity/Attachment.java | 13 +- .../{board => post}/entity/Post.java | 33 ++-- .../{board => post}/entity/PostCategory.java | 2 +- .../post/exception/BoardErrorCode.java | 19 ++ .../post/exception/BoardException.java | 10 ++ .../post/repository/PostRepository.java | 18 ++ .../youthpick/post/service/PostService.java | 108 ++++++++++++ .../user/repository/UserRepository.java | 2 + .../AdminCommunityPostControllerTest.java | 2 +- .../AdminCommunityPostSpecificationsTest.java | 5 +- .../service/AdminCommunityServiceTest.java | 4 +- .../global/config/SecurityConfigTest.java | 13 ++ .../post/controller/PostControllerTest.java | 163 ++++++++++++++++++ .../bop/youthpick/post/entity/PostTest.java | 49 ++++++ .../post/service/PostServiceTest.java | 152 ++++++++++++++++ 28 files changed, 740 insertions(+), 45 deletions(-) delete mode 100644 src/main/java/com/bop/youthpick/board/repository/PostRepository.java create mode 100644 src/main/java/com/bop/youthpick/post/controller/PostController.java create mode 100644 src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java create mode 100644 src/main/java/com/bop/youthpick/post/dto/PostDetailResponse.java create mode 100644 src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java create mode 100644 src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java rename src/main/java/com/bop/youthpick/{board => post}/entity/Attachment.java (70%) rename src/main/java/com/bop/youthpick/{board => post}/entity/Post.java (70%) rename src/main/java/com/bop/youthpick/{board => post}/entity/PostCategory.java (61%) create mode 100644 src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java create mode 100644 src/main/java/com/bop/youthpick/post/exception/BoardException.java create mode 100644 src/main/java/com/bop/youthpick/post/repository/PostRepository.java create mode 100644 src/main/java/com/bop/youthpick/post/service/PostService.java create mode 100644 src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/post/entity/PostTest.java create mode 100644 src/test/java/com/bop/youthpick/post/service/PostServiceTest.java diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java b/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java index 36d6473..06008fc 100644 --- a/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java +++ b/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java @@ -1,6 +1,6 @@ package com.bop.youthpick.board.dto; -import com.bop.youthpick.board.entity.Attachment; +import com.bop.youthpick.post.entity.Attachment; import java.time.LocalDateTime; public record AdminAttachmentResponse( diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java b/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java index 3d184b6..3414d58 100644 --- a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java +++ b/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java @@ -1,7 +1,7 @@ package com.bop.youthpick.board.dto; -import com.bop.youthpick.board.entity.Post; -import com.bop.youthpick.board.entity.PostCategory; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.entity.PostCategory; import java.time.LocalDateTime; public record AdminCommunityPostResponse( diff --git a/src/main/java/com/bop/youthpick/board/entity/Comment.java b/src/main/java/com/bop/youthpick/board/entity/Comment.java index 6ee113f..20ed84e 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Comment.java +++ b/src/main/java/com/bop/youthpick/board/entity/Comment.java @@ -1,6 +1,7 @@ package com.bop.youthpick.board.entity; import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java b/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java index 34b2514..6f3d80c 100644 --- a/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java +++ b/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java @@ -1,7 +1,7 @@ package com.bop.youthpick.board.repository; -import com.bop.youthpick.board.entity.Post; -import com.bop.youthpick.board.entity.PostCategory; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.entity.PostCategory; import jakarta.persistence.criteria.Predicate; import java.time.LocalDate; import java.util.ArrayList; diff --git a/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java b/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java index c217983..b80da9c 100644 --- a/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java +++ b/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java @@ -1,6 +1,6 @@ package com.bop.youthpick.board.repository; -import com.bop.youthpick.board.entity.Attachment; +import com.bop.youthpick.post.entity.Attachment; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/bop/youthpick/board/repository/PostRepository.java b/src/main/java/com/bop/youthpick/board/repository/PostRepository.java deleted file mode 100644 index 03213aa..0000000 --- a/src/main/java/com/bop/youthpick/board/repository/PostRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.bop.youthpick.board.repository; - -import com.bop.youthpick.board.entity.Post; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; - -public interface PostRepository extends JpaRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java b/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java index 6efd968..62b2fb3 100644 --- a/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java +++ b/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java @@ -4,13 +4,13 @@ import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; import com.bop.youthpick.board.dto.AdminCommunityPostResponse; import com.bop.youthpick.board.entity.Comment; -import com.bop.youthpick.board.entity.Post; import com.bop.youthpick.board.exception.BoardErrorCode; import com.bop.youthpick.board.exception.BoardException; import com.bop.youthpick.board.repository.AdminCommunityPostSpecifications; import com.bop.youthpick.board.repository.AttachmentRepository; import com.bop.youthpick.board.repository.CommentRepository; -import com.bop.youthpick.board.repository.PostRepository; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.repository.PostRepository; import jakarta.persistence.criteria.JoinType; import java.time.LocalDate; import java.util.List; diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 9a50905..44c232f 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -72,6 +72,11 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/v1/policies/**", "/api/v1/health") .permitAll() + // 게시글 조회는 공개, 생성·수정·삭제는 회원 전용 + .requestMatchers(HttpMethod.GET, "/api/v1/posts/**") + .permitAll() + .requestMatchers("/api/v1/posts/**") + .authenticated() // 회원 전용 — 로그인 상태 조회/로그아웃, 회원 탈퇴(컨트롤러 미구현, 경로만 선점), // 마이페이지(관심정책/추천/읽음/프로필), 최근 본 정책, 챗봇 프로필 동의 .requestMatchers( diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java new file mode 100644 index 0000000..c63f68f --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -0,0 +1,63 @@ +package com.bop.youthpick.post.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.post.dto.PostCreateRequest; +import com.bop.youthpick.post.dto.PostDetailResponse; +import com.bop.youthpick.post.dto.PostSummaryResponse; +import com.bop.youthpick.post.dto.PostUpdateRequest; +import com.bop.youthpick.post.service.PostService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + @PostMapping + public ResponseEntity> create( + @CurrentUser Long userId, @Valid @RequestBody PostCreateRequest request) { + PostDetailResponse response = postService.create(userId, request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(response)); // 성공했을때 http status 201 + } + + @GetMapping + public ApiResponse> findAll( // 리스폰스엔티티로 감싸야함 수정필요 + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable) { + + Page page = postService.findAll(pageable); + return ApiResponse.ok(page.getContent(), page); + } + + @GetMapping("/{postId}") + public ApiResponse findById(@PathVariable Long postId) { + return ApiResponse.ok(postService.findById(postId)); + } + + @PatchMapping("/{postId}") + public ApiResponse update( + @CurrentUser Long userId, + @PathVariable Long postId, + @Valid @RequestBody PostUpdateRequest request) { + return ApiResponse.ok(postService.update(userId, postId, request)); + } + + @DeleteMapping("/{postId}") + public ApiResponse delete(@CurrentUser Long userId, @PathVariable Long postId) { + postService.delete(userId, postId); + return ApiResponse.ok(null); + } +} diff --git a/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java b/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..57bb008 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record PostCreateRequest( + @NotBlank(message = "카테고리는 필수입니다.") + @Pattern( + regexp = "QUESTION|REVIEW|FREE", + message = "카테고리는 QUESTION, REVIEW, FREE 중 하나여야 합니다.") + String category, + @NotBlank(message = "제목은 필수입니다.") @Size(max = 100, message = "제목은 100자 이하여야 합니다.") + String title, + @NotBlank(message = "내용은 필수입니다.") String content, + @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId) {} // 필수값이 아님. 잡담은 유무 다 가능 diff --git a/src/main/java/com/bop/youthpick/post/dto/PostDetailResponse.java b/src/main/java/com/bop/youthpick/post/dto/PostDetailResponse.java new file mode 100644 index 0000000..b986da7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/dto/PostDetailResponse.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.post.dto; + +import com.bop.youthpick.post.entity.Post; +import java.time.LocalDateTime; + +public record PostDetailResponse( + Long id, + Long authorId, + String authorNickname, + Long policyId, + String policyTitle, + String category, + String title, + String content, + int viewCount, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + + public static PostDetailResponse from(Post post) { + Long policyId = post.getPolicy() == null ? null : post.getPolicy().getId(); + String policyTitle = post.getPolicy() == null ? null : post.getPolicy().getTitle(); + return new PostDetailResponse( + post.getId(), + post.getUser().getId(), + post.getUser().getNickname(), + policyId, + policyTitle, + post.getCategory().name(), + post.getTitle(), + post.getContent(), + post.getViewCount(), + post.getCreatedAt(), + post.getUpdatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java b/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java new file mode 100644 index 0000000..1af38c6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java @@ -0,0 +1,31 @@ +package com.bop.youthpick.post.dto; + +import com.bop.youthpick.post.entity.Post; +import java.time.LocalDateTime; + +public record PostSummaryResponse( + Long id, + Long authorId, + String authorNickname, + Long policyId, + String policyTitle, + String category, + String title, + int viewCount, + LocalDateTime createdAt) { + + public static PostSummaryResponse from(Post post) { + Long policyId = post.getPolicy() == null ? null : post.getPolicy().getId(); + String policyTitle = post.getPolicy() == null ? null : post.getPolicy().getTitle(); + return new PostSummaryResponse( + post.getId(), + post.getUser().getId(), + post.getUser().getNickname(), + policyId, + policyTitle, + post.getCategory().name(), + post.getTitle(), + post.getViewCount(), + post.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java b/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..5ae5956 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record PostUpdateRequest( + @NotBlank(message = "카테고리는 필수입니다.") + @Pattern( + regexp = "QUESTION|REVIEW|FREE", + message = "카테고리는 QUESTION, REVIEW, FREE 중 하나여야 합니다.") + String category, + @NotBlank(message = "제목은 필수입니다.") @Size(max = 100, message = "제목은 100자 이하여야 합니다.") + String title, + @NotBlank(message = "내용은 필수입니다.") String content, + @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId) {} diff --git a/src/main/java/com/bop/youthpick/board/entity/Attachment.java b/src/main/java/com/bop/youthpick/post/entity/Attachment.java similarity index 70% rename from src/main/java/com/bop/youthpick/board/entity/Attachment.java rename to src/main/java/com/bop/youthpick/post/entity/Attachment.java index dd6e8a8..8d5e380 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Attachment.java +++ b/src/main/java/com/bop/youthpick/post/entity/Attachment.java @@ -1,16 +1,7 @@ -package com.bop.youthpick.board.entity; +package com.bop.youthpick.post.entity; import com.bop.youthpick.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Getter; diff --git a/src/main/java/com/bop/youthpick/board/entity/Post.java b/src/main/java/com/bop/youthpick/post/entity/Post.java similarity index 70% rename from src/main/java/com/bop/youthpick/board/entity/Post.java rename to src/main/java/com/bop/youthpick/post/entity/Post.java index 7236b04..ca271f9 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Post.java +++ b/src/main/java/com/bop/youthpick/post/entity/Post.java @@ -1,20 +1,9 @@ -package com.bop.youthpick.board.entity; +package com.bop.youthpick.post.entity; import com.bop.youthpick.global.entity.BaseEntity; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.user.entity.User; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Getter; @@ -55,6 +44,24 @@ public class Post extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + public static Post create( + User user, Policy policy, PostCategory category, String title, String content) { + Post post = new Post(); + post.user = user; + post.policy = policy; + post.category = category; + post.title = title; + post.content = content; + return post; + } + + public void update(Policy policy, PostCategory category, String title, String content) { + this.policy = policy; + this.category = category; + this.title = title; + this.content = content; + } + public void softDelete() { this.deletedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/bop/youthpick/board/entity/PostCategory.java b/src/main/java/com/bop/youthpick/post/entity/PostCategory.java similarity index 61% rename from src/main/java/com/bop/youthpick/board/entity/PostCategory.java rename to src/main/java/com/bop/youthpick/post/entity/PostCategory.java index b3a48c5..85ae2f7 100644 --- a/src/main/java/com/bop/youthpick/board/entity/PostCategory.java +++ b/src/main/java/com/bop/youthpick/post/entity/PostCategory.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.entity; +package com.bop.youthpick.post.entity; public enum PostCategory { QUESTION, diff --git a/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java b/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java new file mode 100644 index 0000000..7a0d955 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java @@ -0,0 +1,19 @@ +package com.bop.youthpick.post.exception; + +import com.bop.youthpick.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BoardErrorCode implements ErrorCode { + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "게시글을 찾을 수 없습니다."), + POST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "B002", "작성자만 게시글을 수정하거나 삭제할 수 있습니다."), + POLICY_REQUIRED(HttpStatus.BAD_REQUEST, "B003", "질문과 후기에는 정책 연결이 필요합니다."), + FREE_POST_POLICY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "자유글에는 정책을 연결할 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/bop/youthpick/post/exception/BoardException.java b/src/main/java/com/bop/youthpick/post/exception/BoardException.java new file mode 100644 index 0000000..a38629a --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/exception/BoardException.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.post.exception; + +import com.bop.youthpick.global.error.CustomException; + +public class BoardException extends CustomException { + + public BoardException(BoardErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/bop/youthpick/post/repository/PostRepository.java b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java new file mode 100644 index 0000000..daf54d1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java @@ -0,0 +1,18 @@ +package com.bop.youthpick.post.repository; + +import com.bop.youthpick.post.entity.Post; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface PostRepository extends JpaRepository, JpaSpecificationExecutor { + + @EntityGraph(attributePaths = {"user", "policy"}) // jpql을 써서 구현하는 방법도 있음 + Optional findByIdAndDeletedAtIsNull(Long id); // 쿼리메서드 + + @EntityGraph(attributePaths = {"user", "policy"}) + Page findAllByDeletedAtIsNull(Pageable pageable); +} diff --git a/src/main/java/com/bop/youthpick/post/service/PostService.java b/src/main/java/com/bop/youthpick/post/service/PostService.java new file mode 100644 index 0000000..48cf968 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -0,0 +1,108 @@ +package com.bop.youthpick.post.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.post.dto.PostCreateRequest; +import com.bop.youthpick.post.dto.PostDetailResponse; +import com.bop.youthpick.post.dto.PostSummaryResponse; +import com.bop.youthpick.post.dto.PostUpdateRequest; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.entity.PostCategory; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.PostRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + private final UserRepository userRepository; + private final PolicyRepository policyRepository; + + @Transactional + public PostDetailResponse create(Long userId, PostCreateRequest request) { + User user = findUser(userId); // userId는 그냥 맘대로 정한 변수명? 아니면 컬럼명을 카멜케이스로 쓴거? findUser는 쿼리메서드? + PostCategory category = PostCategory.valueOf(request.category()); + // valueOf()는 모든 enum이 제공받는 정적 메서드. 문자열과 이름이 일치하는 enum 값을 찾아줌 + + Policy policy = resolvePolicy(category, request.policyId()); + Post post = Post.create(user, policy, category, request.title(), request.content()); + // request record로 선언됐으면 거기 안에 있는 변수들을 메서드로 쓰면 자동으로 똑같이 반환해줌 + return PostDetailResponse.from(postRepository.save(post)); + // from도 누가준 선물인지 모르겠어.. 암튼 위에서 받은 Post객체를 레포지토리에 저장해서 디테일리스폰스 객체로 보내준다는뜻같음 + } + + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return postRepository.findAllByDeletedAtIsNull(pageable).map(PostSummaryResponse::from); + } + + @Transactional(readOnly = true) + public PostDetailResponse findById(Long postId) { + return PostDetailResponse.from(findPost(postId)); + } + + @Transactional + public PostDetailResponse update(Long userId, Long postId, PostUpdateRequest request) { + Post post = findPost(postId); + validateAuthor(post, userId); + PostCategory category = PostCategory.valueOf(request.category()); + Policy policy = resolvePolicy(category, request.policyId()); + post.update(policy, category, request.title(), request.content()); + return PostDetailResponse.from(post); + } // PostDetailRequest가 record라면 Java가 이 메서드들(category() 등)을 자동으로 만듦 + + @Transactional + public void delete(Long userId, Long postId) { + Post post = findPost(postId); + validateAuthor(post, userId); + post.softDelete(); + } + + private User findUser(Long userId) { + return userRepository + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + } + + private Post findPost(Long postId) { + return postRepository + .findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BoardException(BoardErrorCode.POST_NOT_FOUND)); + } + + private Policy resolvePolicy(PostCategory category, Long policyId) { + if (category == PostCategory.FREE) { + if (policyId != null) { + return policyRepository + .findById(policyId) + .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + } + return null; + } + if (policyId == null) { + throw new BoardException(BoardErrorCode.POLICY_REQUIRED); + } + return policyRepository + .findById(policyId) + .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + } + + private void validateAuthor(Post post, Long userId) { + if (!post.getUser().getId().equals(userId)) { + throw new BoardException(BoardErrorCode.POST_ACCESS_DENIED); + } + } +} diff --git a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java index d5b14d1..f06614e 100644 --- a/src/main/java/com/bop/youthpick/user/repository/UserRepository.java +++ b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java @@ -8,4 +8,6 @@ public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByProviderAndProviderId(String provider, String providerId); + + Optional findByIdAndDeletedAtIsNull(Long id); } diff --git a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java index 35e8657..128f15b 100644 --- a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java +++ b/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java @@ -12,10 +12,10 @@ import com.bop.youthpick.board.dto.AdminAttachmentResponse; import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; import com.bop.youthpick.board.dto.AdminCommunityPostResponse; -import com.bop.youthpick.board.entity.PostCategory; import com.bop.youthpick.board.exception.BoardErrorCode; import com.bop.youthpick.board.exception.BoardException; import com.bop.youthpick.board.service.AdminCommunityService; +import com.bop.youthpick.post.entity.PostCategory; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java b/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java index 01cdd14..d321cd6 100644 --- a/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java @@ -2,9 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.bop.youthpick.board.entity.Post; -import com.bop.youthpick.board.entity.PostCategory; import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.entity.PostCategory; +import com.bop.youthpick.post.repository.PostRepository; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.repository.UserRepository; import java.time.LocalDate; diff --git a/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java b/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java index 8fe9205..c99b78a 100644 --- a/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java +++ b/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java @@ -8,12 +8,12 @@ import static org.mockito.Mockito.when; import com.bop.youthpick.board.entity.Comment; -import com.bop.youthpick.board.entity.Post; import com.bop.youthpick.board.exception.BoardErrorCode; import com.bop.youthpick.board.exception.BoardException; import com.bop.youthpick.board.repository.AttachmentRepository; import com.bop.youthpick.board.repository.CommentRepository; -import com.bop.youthpick.board.repository.PostRepository; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.repository.PostRepository; import com.bop.youthpick.user.entity.User; import java.util.List; import java.util.Optional; diff --git a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java index bfee10f..b8aff05 100644 --- a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java +++ b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -83,6 +84,18 @@ class SecurityConfigTest { assertThat(status).isNotIn(401, 403); } + @Test + void 게시글_조회는_인증없이_접근할_수_있다() throws Exception { + int status = mockMvc.perform(get("/api/v1/posts")).andReturn().getResponse().getStatus(); + + assertThat(status).isNotIn(401, 403); + } + + @Test + void 게시글_작성은_인증없이_접근하면_401() throws Exception { + mockMvc.perform(post("/api/v1/posts")).andExpect(status().isUnauthorized()); + } + @Test void 회원_탈퇴_경로에_인증없이_접근하면_401() throws Exception { mockMvc.perform(delete("/api/v1/users")).andExpect(status().isUnauthorized()); diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java new file mode 100644 index 0000000..c18ed25 --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -0,0 +1,163 @@ +package com.bop.youthpick.post.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.error.GlobalExceptionHandler; +import com.bop.youthpick.post.dto.PostDetailResponse; +import com.bop.youthpick.post.dto.PostSummaryResponse; +import com.bop.youthpick.post.service.PostService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@ExtendWith(MockitoExtension.class) +class PostControllerTest { + + @Mock private PostService postService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + PostController controller = new PostController(postService); + mockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers( + new TestCurrentUserArgumentResolver(), + new PageableHandlerMethodArgumentResolver()) + .build(); + } + + @Test + void 게시글을_생성하면_201을_반환한다() throws Exception { + when(postService.create(eq(1L), any())).thenReturn(detail(3L, "FREE", "제목", "내용")); + + mockMvc.perform( + post("/api/v1/posts") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "category": "FREE", + "title": "제목", + "content": "내용" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id").value(3)) + .andExpect(jsonPath("$.data.title").value("제목")); + } + + @Test + void 제목이_비어있으면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + post("/api/v1/posts") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "category": "FREE", + "title": "", + "content": "내용" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 게시글_목록과_페이지_정보를_조회한다() throws Exception { + PostSummaryResponse summary = + new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "제목", 0, null); + when(postService.findAll(any())).thenReturn(new PageImpl<>(List.of(summary))); + + mockMvc.perform(get("/api/v1/posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(3)) + .andExpect(jsonPath("$.meta.totalCount").value(1)); + } + + @Test + void 게시글_상세를_조회한다() throws Exception { + when(postService.findById(3L)).thenReturn(detail(3L, "FREE", "제목", "내용")); + + mockMvc.perform(get("/api/v1/posts/{postId}", 3L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").value("내용")); + } + + @Test + void 게시글을_수정한다() throws Exception { + when(postService.update(eq(1L), eq(3L), any())) + .thenReturn(detail(3L, "FREE", "수정 후", "수정 후 내용")); + + mockMvc.perform( + patch("/api/v1/posts/{postId}", 3L) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "category": "FREE", + "title": "수정 후", + "content": "수정 후 내용" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("수정 후")); + } + + @Test + void 게시글을_삭제한다() throws Exception { + doNothing().when(postService).delete(1L, 3L); + + mockMvc.perform(delete("/api/v1/posts/{postId}", 3L)).andExpect(status().isOk()); + + verify(postService).delete(1L, 3L); + } + + private PostDetailResponse detail(Long id, String category, String title, String content) { + return new PostDetailResponse( + id, 1L, "작성자", null, null, category, title, content, 0, null, null); + } + + private static class TestCurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return 1L; + } + } +} diff --git a/src/test/java/com/bop/youthpick/post/entity/PostTest.java b/src/test/java/com/bop/youthpick/post/entity/PostTest.java new file mode 100644 index 0000000..ee32122 --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/entity/PostTest.java @@ -0,0 +1,49 @@ +package com.bop.youthpick.post.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.user.entity.User; +import org.junit.jupiter.api.Test; + +class PostTest { + + @Test + void 게시글을_생성한다() { + User user = mock(User.class); + Policy policy = mock(Policy.class); + + Post post = Post.create(user, policy, PostCategory.REVIEW, "후기", "정책 참여 후기입니다."); + + assertThat(post.getUser()).isSameAs(user); + assertThat(post.getPolicy()).isSameAs(policy); + assertThat(post.getCategory()).isEqualTo(PostCategory.REVIEW); + assertThat(post.getTitle()).isEqualTo("후기"); + assertThat(post.getContent()).isEqualTo("정책 참여 후기입니다."); + assertThat(post.getViewCount()).isZero(); + assertThat(post.getDeletedAt()).isNull(); + } + + @Test + void 게시글_내용을_수정한다() { + Post post = Post.create(mock(User.class), null, PostCategory.FREE, "수정 전", "수정 전 내용"); + Policy policy = mock(Policy.class); + + post.update(policy, PostCategory.QUESTION, "수정 후", "수정 후 내용"); + + assertThat(post.getPolicy()).isSameAs(policy); + assertThat(post.getCategory()).isEqualTo(PostCategory.QUESTION); + assertThat(post.getTitle()).isEqualTo("수정 후"); + assertThat(post.getContent()).isEqualTo("수정 후 내용"); + } + + @Test + void 게시글을_soft_delete한다() { + Post post = Post.create(mock(User.class), null, PostCategory.FREE, "제목", "내용"); + + post.softDelete(); + + assertThat(post.getDeletedAt()).isNotNull(); + } +} diff --git a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java new file mode 100644 index 0000000..c9ed4d0 --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -0,0 +1,152 @@ +package com.bop.youthpick.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.post.dto.PostCreateRequest; +import com.bop.youthpick.post.dto.PostDetailResponse; +import com.bop.youthpick.post.dto.PostSummaryResponse; +import com.bop.youthpick.post.dto.PostUpdateRequest; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.entity.PostCategory; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.PostRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +class PostServiceTest { + + @Mock private PostRepository postRepository; + @Mock private UserRepository userRepository; + @Mock private PolicyRepository policyRepository; + + private PostService postService; + + @BeforeEach + void setUp() { + postService = new PostService(postRepository, userRepository, policyRepository); + } + + @Test + void 자유글을_생성한다() { + User user = user(1L); + PostCreateRequest request = new PostCreateRequest("FREE", "제목", "내용", null); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + when(postRepository.save(any(Post.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PostDetailResponse response = postService.create(1L, request); + + assertThat(response.category()).isEqualTo("FREE"); + assertThat(response.title()).isEqualTo("제목"); + assertThat(response.policyId()).isNull(); + verify(policyRepository, never()).findById(any()); + } + + @Test + void 후기를_생성할_때_정책을_연결한다() { + User user = user(1L); + Policy policy = policy(10L, "청년 정책"); + PostCreateRequest request = new PostCreateRequest("REVIEW", "후기", "내용", 10L); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + when(policyRepository.findById(10L)).thenReturn(Optional.of(policy)); + when(postRepository.save(any(Post.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PostDetailResponse response = postService.create(1L, request); + + assertThat(response.category()).isEqualTo("REVIEW"); + assertThat(response.policyId()).isEqualTo(10L); + assertThat(response.policyTitle()).isEqualTo("청년 정책"); + } + + @Test + void 삭제되지_않은_게시글_목록을_조회한다() { + Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); + PageRequest pageable = PageRequest.of(0, 20); + when(postRepository.findAllByDeletedAtIsNull(pageable)) + .thenReturn(new PageImpl<>(java.util.List.of(post), pageable, 1)); + + Page result = postService.findAll(pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().title()).isEqualTo("제목"); + } + + @Test + void 게시글_상세를_조회한다() { + Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); + when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); + + PostDetailResponse response = postService.findById(3L); + + assertThat(response.title()).isEqualTo("제목"); + assertThat(response.content()).isEqualTo("내용"); + } + + @Test + void 작성자가_게시글을_수정한다() { + Post post = Post.create(user(1L), null, PostCategory.FREE, "수정 전", "수정 전 내용"); + PostUpdateRequest request = new PostUpdateRequest("FREE", "수정 후", "수정 후 내용", null); + when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); + + PostDetailResponse response = postService.update(1L, 3L, request); + + assertThat(response.title()).isEqualTo("수정 후"); + assertThat(response.content()).isEqualTo("수정 후 내용"); + } + + @Test + void 작성자가_게시글을_soft_delete한다() { + Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); + when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); + + postService.delete(1L, 3L); + + assertThat(post.getDeletedAt()).isNotNull(); + verify(postRepository, never()).delete(any(Post.class)); + } + + @Test + void 다른_사용자는_게시글을_수정할_수_없다() { + Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); + PostUpdateRequest request = new PostUpdateRequest("FREE", "수정", "수정", null); + when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); + + assertThatThrownBy(() -> postService.update(2L, 3L, request)) + .isInstanceOf(BoardException.class) + .extracting(exception -> ((BoardException) exception).getErrorCode()) + .isEqualTo(BoardErrorCode.POST_ACCESS_DENIED); + } + + private User user(Long id) { + User user = mock(User.class); + when(user.getId()).thenReturn(id); + return user; + } + + private Policy policy(Long id, String title) { + Policy policy = mock(Policy.class); + when(policy.getId()).thenReturn(id); + when(policy.getTitle()).thenReturn(title); + return policy; + } +} From efbc24ac0d788a2b49b1844a9f963724a7ba26d4 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Sun, 19 Jul 2026 16:18:42 +0900 Subject: [PATCH 34/93] =?UTF-8?q?refac:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=B5=9C=EC=83=81=EC=9C=84=20adm?= =?UTF-8?q?in=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit policy/board/log/sync/user 도메인에 흩어져 있던 Admin* 컨트롤러/서비스/ query-spec과 admin 전용 DTO(Admin 접두어가 없는 ApplicationLogResponse, SearchLogResponse, BatchJobLogResponse, PolicySyncJobSummaryResponse, LoginHistoryResponse, ApplicationChecklistItemResponse 포함)를 com.bop.youthpick.admin.{domain} 패키지로 이동한다. Entity, 공용 Repository, {Domain}ErrorCode는 각 도메인 패키지에 그대로 두고 admin 쪽에서 참조하는 방향만 허용한다. architecture.md에 admin 최상위 패키지 컨벤션을 반영했다. Closes #78 Co-authored-by: Claude Sonnet 5 --- .claude/rules/architecture.md | 7 +++++++ .../controller/AdminCommunityCommentController.java | 6 +++--- .../board/controller/AdminCommunityPostController.java | 10 +++++----- .../{ => admin}/board/dto/AdminAttachmentResponse.java | 2 +- .../board/dto/AdminCommunityCommentResponse.java | 2 +- .../board/dto/AdminCommunityPostResponse.java | 2 +- .../repository/AdminCommunityPostSpecifications.java | 2 +- .../board/service/AdminCommunityService.java | 10 +++++----- .../log/controller/AdminAppLogController.java | 6 +++--- .../log/controller/AdminSearchLogController.java | 6 +++--- .../{ => admin}/log/dto/ApplicationLogResponse.java | 2 +- .../{ => admin}/log/dto/SearchLogResponse.java | 2 +- .../log/repository/AdminAppLogSpecifications.java | 2 +- .../log/repository/AdminSearchLogSpecifications.java | 2 +- .../{ => admin}/log/service/AdminAppLogService.java | 6 +++--- .../{ => admin}/log/service/AdminSearchLogService.java | 6 +++--- .../controller/AdminPolicyApplicationController.java | 10 +++++----- .../policy/controller/AdminPolicyController.java | 10 +++++----- .../policy/controller/AdminRegionController.java | 4 ++-- .../policy/dto/AdminPolicyApplicationResponse.java | 2 +- .../dto/AdminPolicyApplicationStatusUpdateRequest.java | 2 +- .../{ => admin}/policy/dto/AdminPolicyResponse.java | 2 +- .../policy/dto/AdminPolicyUpdateRequest.java | 2 +- .../policy/dto/AdminPolicyVisibilityUpdateRequest.java | 2 +- .../policy/dto/ApplicationChecklistItemResponse.java | 2 +- .../AdminPolicyApplicationSpecifications.java | 2 +- .../policy/repository/AdminPolicySpecifications.java | 2 +- .../policy/service/AdminPolicyApplicationService.java | 8 ++++---- .../{ => admin}/policy/service/AdminPolicyService.java | 8 ++++---- .../{ => admin}/policy/service/AdminRegionService.java | 2 +- .../sync/controller/AdminBatchJobLogController.java | 6 +++--- .../sync/controller/AdminPolicySyncController.java | 2 +- .../sync/controller/AdminPolicySyncJobController.java | 6 +++--- .../{ => admin}/sync/dto/BatchJobLogResponse.java | 2 +- .../sync/dto/PolicySyncJobSummaryResponse.java | 2 +- .../repository/AdminBatchJobLogSpecifications.java | 2 +- .../sync/service/AdminBatchJobLogService.java | 6 +++--- .../sync/service/AdminPolicySyncJobService.java | 4 ++-- .../user/controller/AdminLoginHistoryController.java | 6 +++--- .../user/controller/AdminUserController.java | 10 +++++----- .../{ => admin}/user/dto/AdminUserProfileResponse.java | 2 +- .../{ => admin}/user/dto/AdminUserResponse.java | 2 +- .../user/dto/AdminUserRoleUpdateRequest.java | 2 +- .../{ => admin}/user/dto/LoginHistoryResponse.java | 2 +- .../repository/AdminLoginHistorySpecifications.java | 2 +- .../user/repository/AdminUserSpecifications.java | 2 +- .../user/service/AdminLoginHistoryService.java | 6 +++--- .../{ => admin}/user/service/AdminUserService.java | 8 ++++---- .../AdminCommunityCommentControllerTest.java | 6 +++--- .../controller/AdminCommunityPostControllerTest.java | 10 +++++----- .../AdminCommunityPostSpecificationsTest.java | 2 +- .../board/service/AdminCommunityServiceTest.java | 2 +- .../log/controller/AdminAppLogControllerTest.java | 6 +++--- .../log/controller/AdminSearchLogControllerTest.java | 6 +++--- .../log/repository/AdminAppLogSpecificationsTest.java | 3 ++- .../repository/AdminSearchLogSpecificationsTest.java | 3 ++- .../log/service/AdminAppLogServiceTest.java | 2 +- .../log/service/AdminSearchLogServiceTest.java | 2 +- .../AdminPolicyApplicationControllerTest.java | 8 ++++---- .../policy/controller/AdminPolicyControllerTest.java | 6 +++--- .../policy/controller/AdminRegionControllerTest.java | 4 ++-- .../AdminPolicyApplicationSpecificationsTest.java | 3 ++- .../repository/AdminPolicySpecificationsTest.java | 3 ++- .../service/AdminPolicyApplicationServiceTest.java | 2 +- .../policy/service/AdminPolicyServiceTest.java | 4 ++-- .../controller/AdminBatchJobLogControllerTest.java | 6 +++--- .../sync/controller/AdminPolicySyncControllerTest.java | 2 +- .../controller/AdminPolicySyncJobControllerTest.java | 6 +++--- .../repository/AdminBatchJobLogSpecificationsTest.java | 3 ++- .../sync/service/AdminBatchJobLogServiceTest.java | 2 +- .../sync/service/AdminPolicySyncJobServiceTest.java | 4 ++-- .../controller/AdminLoginHistoryControllerTest.java | 6 +++--- .../user/controller/AdminUserControllerTest.java | 8 ++++---- .../AdminLoginHistorySpecificationsTest.java | 4 +++- .../user/repository/AdminUserSpecificationsTest.java | 3 ++- .../user/service/AdminLoginHistoryServiceTest.java | 2 +- .../{ => admin}/user/service/AdminUserServiceTest.java | 2 +- 77 files changed, 169 insertions(+), 154 deletions(-) rename src/main/java/com/bop/youthpick/{ => admin}/board/controller/AdminCommunityCommentController.java (80%) rename src/main/java/com/bop/youthpick/{ => admin}/board/controller/AdminCommunityPostController.java (89%) rename src/main/java/com/bop/youthpick/{ => admin}/board/dto/AdminAttachmentResponse.java (92%) rename src/main/java/com/bop/youthpick/{ => admin}/board/dto/AdminCommunityCommentResponse.java (94%) rename src/main/java/com/bop/youthpick/{ => admin}/board/dto/AdminCommunityPostResponse.java (95%) rename src/main/java/com/bop/youthpick/{ => admin}/board/repository/AdminCommunityPostSpecifications.java (97%) rename src/main/java/com/bop/youthpick/{ => admin}/board/service/AdminCommunityService.java (91%) rename src/main/java/com/bop/youthpick/{ => admin}/log/controller/AdminAppLogController.java (91%) rename src/main/java/com/bop/youthpick/{ => admin}/log/controller/AdminSearchLogController.java (89%) rename src/main/java/com/bop/youthpick/{ => admin}/log/dto/ApplicationLogResponse.java (96%) rename src/main/java/com/bop/youthpick/{ => admin}/log/dto/SearchLogResponse.java (93%) rename src/main/java/com/bop/youthpick/{ => admin}/log/repository/AdminAppLogSpecifications.java (98%) rename src/main/java/com/bop/youthpick/{ => admin}/log/repository/AdminSearchLogSpecifications.java (97%) rename src/main/java/com/bop/youthpick/{ => admin}/log/service/AdminAppLogService.java (83%) rename src/main/java/com/bop/youthpick/{ => admin}/log/service/AdminSearchLogService.java (82%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/controller/AdminPolicyApplicationController.java (88%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/controller/AdminPolicyController.java (90%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/controller/AdminRegionController.java (85%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/dto/AdminPolicyApplicationResponse.java (95%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java (90%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/dto/AdminPolicyResponse.java (97%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/dto/AdminPolicyUpdateRequest.java (96%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/dto/AdminPolicyVisibilityUpdateRequest.java (90%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/dto/ApplicationChecklistItemResponse.java (93%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/repository/AdminPolicyApplicationSpecifications.java (99%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/repository/AdminPolicySpecifications.java (97%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/service/AdminPolicyApplicationService.java (90%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/service/AdminPolicyService.java (94%) rename src/main/java/com/bop/youthpick/{ => admin}/policy/service/AdminRegionService.java (92%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/controller/AdminBatchJobLogController.java (90%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/controller/AdminPolicySyncController.java (95%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/controller/AdminPolicySyncJobController.java (77%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/dto/BatchJobLogResponse.java (94%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/dto/PolicySyncJobSummaryResponse.java (92%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/repository/AdminBatchJobLogSpecifications.java (97%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/service/AdminBatchJobLogService.java (82%) rename src/main/java/com/bop/youthpick/{ => admin}/sync/service/AdminPolicySyncJobService.java (87%) rename src/main/java/com/bop/youthpick/{ => admin}/user/controller/AdminLoginHistoryController.java (88%) rename src/main/java/com/bop/youthpick/{ => admin}/user/controller/AdminUserController.java (89%) rename src/main/java/com/bop/youthpick/{ => admin}/user/dto/AdminUserProfileResponse.java (96%) rename src/main/java/com/bop/youthpick/{ => admin}/user/dto/AdminUserResponse.java (93%) rename src/main/java/com/bop/youthpick/{ => admin}/user/dto/AdminUserRoleUpdateRequest.java (88%) rename src/main/java/com/bop/youthpick/{ => admin}/user/dto/LoginHistoryResponse.java (91%) rename src/main/java/com/bop/youthpick/{ => admin}/user/repository/AdminLoginHistorySpecifications.java (97%) rename src/main/java/com/bop/youthpick/{ => admin}/user/repository/AdminUserSpecifications.java (96%) rename src/main/java/com/bop/youthpick/{ => admin}/user/service/AdminLoginHistoryService.java (82%) rename src/main/java/com/bop/youthpick/{ => admin}/user/service/AdminUserService.java (89%) rename src/test/java/com/bop/youthpick/{ => admin}/board/controller/AdminCommunityCommentControllerTest.java (91%) rename src/test/java/com/bop/youthpick/{ => admin}/board/controller/AdminCommunityPostControllerTest.java (94%) rename src/test/java/com/bop/youthpick/{ => admin}/board/repository/AdminCommunityPostSpecificationsTest.java (98%) rename src/test/java/com/bop/youthpick/{ => admin}/board/service/AdminCommunityServiceTest.java (99%) rename src/test/java/com/bop/youthpick/{ => admin}/log/controller/AdminAppLogControllerTest.java (95%) rename src/test/java/com/bop/youthpick/{ => admin}/log/controller/AdminSearchLogControllerTest.java (92%) rename src/test/java/com/bop/youthpick/{ => admin}/log/repository/AdminAppLogSpecificationsTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/log/repository/AdminSearchLogSpecificationsTest.java (96%) rename src/test/java/com/bop/youthpick/{ => admin}/log/service/AdminAppLogServiceTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/log/service/AdminSearchLogServiceTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/policy/controller/AdminPolicyApplicationControllerTest.java (95%) rename src/test/java/com/bop/youthpick/{ => admin}/policy/controller/AdminPolicyControllerTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/policy/controller/AdminRegionControllerTest.java (93%) rename src/test/java/com/bop/youthpick/{ => admin}/policy/repository/AdminPolicyApplicationSpecificationsTest.java (98%) rename src/test/java/com/bop/youthpick/{ => admin}/policy/repository/AdminPolicySpecificationsTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/policy/service/AdminPolicyApplicationServiceTest.java (99%) rename src/test/java/com/bop/youthpick/{ => admin}/policy/service/AdminPolicyServiceTest.java (98%) rename src/test/java/com/bop/youthpick/{ => admin}/sync/controller/AdminBatchJobLogControllerTest.java (93%) rename src/test/java/com/bop/youthpick/{ => admin}/sync/controller/AdminPolicySyncControllerTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/sync/controller/AdminPolicySyncJobControllerTest.java (89%) rename src/test/java/com/bop/youthpick/{ => admin}/sync/repository/AdminBatchJobLogSpecificationsTest.java (95%) rename src/test/java/com/bop/youthpick/{ => admin}/sync/service/AdminBatchJobLogServiceTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/sync/service/AdminPolicySyncJobServiceTest.java (92%) rename src/test/java/com/bop/youthpick/{ => admin}/user/controller/AdminLoginHistoryControllerTest.java (91%) rename src/test/java/com/bop/youthpick/{ => admin}/user/controller/AdminUserControllerTest.java (96%) rename src/test/java/com/bop/youthpick/{ => admin}/user/repository/AdminLoginHistorySpecificationsTest.java (94%) rename src/test/java/com/bop/youthpick/{ => admin}/user/repository/AdminUserSpecificationsTest.java (96%) rename src/test/java/com/bop/youthpick/{ => admin}/user/service/AdminLoginHistoryServiceTest.java (97%) rename src/test/java/com/bop/youthpick/{ => admin}/user/service/AdminUserServiceTest.java (99%) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index ba20b1a..798236f 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -13,6 +13,12 @@ com.bop.youthpick │ ├── common // ApiResponse 등 공통 응답 봉투 │ ├── config // SecurityConfig, RestAuthenticationEntryPoint 등 │ └── error // ErrorCode(인터페이스), CustomException, ErrorResponse, GlobalExceptionHandler +├── admin // 관리자 전용 코드 — 도메인별 하위 패키지로만 구성 +│ └── {domain} // admin.policy, admin.board, admin.log, admin.sync, admin.user ... +│ ├── controller // Admin* 컨트롤러 (/api/v1/admin/** 경로) +│ ├── service // Admin* 서비스 +│ ├── repository // Admin*Specifications 등 admin 전용 query-spec +│ └── dto // admin 응답/요청 전용 DTO └── {domain} // user, policy, auth, favorite(즐겨찾기), diagnosis(자가진단) ... ├── controller // HTTP Controller와 API 입출력 조립 ├── service // 비즈니스 흐름, scheduler, lock, 외부 API client @@ -23,6 +29,7 @@ com.bop.youthpick ``` - `global`에는 여러 도메인이 공유하는 공통 응답·설정·예외 처리만 둔다. +- 관리자(Admin) 전용 컨트롤러/서비스/query-spec/전용 DTO는 각 도메인이 아니라 최상위 `admin.{domain}` 패키지에 둔다(예: `admin.policy.controller.AdminPolicyController`). 여러 도메인에서 공유하는 Entity·일반 Repository·`{Domain}ErrorCode`는 그대로 각 도메인 패키지에 남기고, admin 쪽에서 그 도메인 패키지를 참조한다 — 반대 방향(도메인 → admin) 의존은 만들지 않는다. - 도메인에 아직 필요 없는 하위 패키지는 만들지 않는다. - Controller는 얇게: HTTP 파싱, Bean Validation, Service 호출, 응답 DTO 변환만. 비즈니스 판단·중복 검사·외부 API 호출·Entity 상태 변경은 Service에 둔다. diff --git a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java similarity index 80% rename from src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java rename to src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java index 854b1f3..a0dbf7d 100644 --- a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityCommentController.java +++ b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java @@ -1,7 +1,7 @@ -package com.bop.youthpick.board.controller; +package com.bop.youthpick.admin.board.controller; -import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; -import com.bop.youthpick.board.service.AdminCommunityService; +import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.admin.board.service.AdminCommunityService; import com.bop.youthpick.global.common.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; diff --git a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java similarity index 89% rename from src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java rename to src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java index 8a9b1cc..9db29e6 100644 --- a/src/main/java/com/bop/youthpick/board/controller/AdminCommunityPostController.java +++ b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java @@ -1,9 +1,9 @@ -package com.bop.youthpick.board.controller; +package com.bop.youthpick.admin.board.controller; -import com.bop.youthpick.board.dto.AdminAttachmentResponse; -import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; -import com.bop.youthpick.board.dto.AdminCommunityPostResponse; -import com.bop.youthpick.board.service.AdminCommunityService; +import com.bop.youthpick.admin.board.dto.AdminAttachmentResponse; +import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.admin.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.admin.board.service.AdminCommunityService; import com.bop.youthpick.global.common.ApiResponse; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java similarity index 92% rename from src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java rename to src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java index 06008fc..d941e28 100644 --- a/src/main/java/com/bop/youthpick/board/dto/AdminAttachmentResponse.java +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.dto; +package com.bop.youthpick.admin.board.dto; import com.bop.youthpick.post.entity.Attachment; import java.time.LocalDateTime; diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityCommentResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java similarity index 94% rename from src/main/java/com/bop/youthpick/board/dto/AdminCommunityCommentResponse.java rename to src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java index 2434930..c5605dd 100644 --- a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityCommentResponse.java +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.dto; +package com.bop.youthpick.admin.board.dto; import com.bop.youthpick.board.entity.Comment; import java.time.LocalDateTime; diff --git a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityPostResponse.java similarity index 95% rename from src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java rename to src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityPostResponse.java index 3414d58..241c138 100644 --- a/src/main/java/com/bop/youthpick/board/dto/AdminCommunityPostResponse.java +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityPostResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.dto; +package com.bop.youthpick.admin.board.dto; import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.post.entity.PostCategory; diff --git a/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java b/src/main/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecifications.java similarity index 97% rename from src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java rename to src/main/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecifications.java index 6f3d80c..3176e67 100644 --- a/src/main/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.repository; +package com.bop.youthpick.admin.board.repository; import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.post.entity.PostCategory; diff --git a/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java similarity index 91% rename from src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java rename to src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java index 62b2fb3..0bc5165 100644 --- a/src/main/java/com/bop/youthpick/board/service/AdminCommunityService.java +++ b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java @@ -1,12 +1,12 @@ -package com.bop.youthpick.board.service; +package com.bop.youthpick.admin.board.service; -import com.bop.youthpick.board.dto.AdminAttachmentResponse; -import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; -import com.bop.youthpick.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.admin.board.dto.AdminAttachmentResponse; +import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.admin.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.admin.board.repository.AdminCommunityPostSpecifications; import com.bop.youthpick.board.entity.Comment; import com.bop.youthpick.board.exception.BoardErrorCode; import com.bop.youthpick.board.exception.BoardException; -import com.bop.youthpick.board.repository.AdminCommunityPostSpecifications; import com.bop.youthpick.board.repository.AttachmentRepository; import com.bop.youthpick.board.repository.CommentRepository; import com.bop.youthpick.post.entity.Post; diff --git a/src/main/java/com/bop/youthpick/log/controller/AdminAppLogController.java b/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java similarity index 91% rename from src/main/java/com/bop/youthpick/log/controller/AdminAppLogController.java rename to src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java index c6930e3..f7cc19c 100644 --- a/src/main/java/com/bop/youthpick/log/controller/AdminAppLogController.java +++ b/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java @@ -1,8 +1,8 @@ -package com.bop.youthpick.log.controller; +package com.bop.youthpick.admin.log.controller; +import com.bop.youthpick.admin.log.dto.ApplicationLogResponse; +import com.bop.youthpick.admin.log.service.AdminAppLogService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.log.dto.ApplicationLogResponse; -import com.bop.youthpick.log.service.AdminAppLogService; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; import java.util.List; diff --git a/src/main/java/com/bop/youthpick/log/controller/AdminSearchLogController.java b/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java similarity index 89% rename from src/main/java/com/bop/youthpick/log/controller/AdminSearchLogController.java rename to src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java index f1b5504..5500df7 100644 --- a/src/main/java/com/bop/youthpick/log/controller/AdminSearchLogController.java +++ b/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java @@ -1,8 +1,8 @@ -package com.bop.youthpick.log.controller; +package com.bop.youthpick.admin.log.controller; +import com.bop.youthpick.admin.log.dto.SearchLogResponse; +import com.bop.youthpick.admin.log.service.AdminSearchLogService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.log.dto.SearchLogResponse; -import com.bop.youthpick.log.service.AdminSearchLogService; import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java b/src/main/java/com/bop/youthpick/admin/log/dto/ApplicationLogResponse.java similarity index 96% rename from src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java rename to src/main/java/com/bop/youthpick/admin/log/dto/ApplicationLogResponse.java index d944f6d..1355bef 100644 --- a/src/main/java/com/bop/youthpick/log/dto/ApplicationLogResponse.java +++ b/src/main/java/com/bop/youthpick/admin/log/dto/ApplicationLogResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.dto; +package com.bop.youthpick.admin.log.dto; import com.bop.youthpick.log.entity.ApplicationLog; import java.time.LocalDateTime; diff --git a/src/main/java/com/bop/youthpick/log/dto/SearchLogResponse.java b/src/main/java/com/bop/youthpick/admin/log/dto/SearchLogResponse.java similarity index 93% rename from src/main/java/com/bop/youthpick/log/dto/SearchLogResponse.java rename to src/main/java/com/bop/youthpick/admin/log/dto/SearchLogResponse.java index bb0982a..9cdeb35 100644 --- a/src/main/java/com/bop/youthpick/log/dto/SearchLogResponse.java +++ b/src/main/java/com/bop/youthpick/admin/log/dto/SearchLogResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.dto; +package com.bop.youthpick.admin.log.dto; import com.bop.youthpick.log.entity.SearchLog; import java.time.LocalDateTime; diff --git a/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java b/src/main/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecifications.java similarity index 98% rename from src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java rename to src/main/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecifications.java index d870269..cd95052 100644 --- a/src/main/java/com/bop/youthpick/log/repository/AdminAppLogSpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.repository; +package com.bop.youthpick.admin.log.repository; import com.bop.youthpick.log.entity.ApplicationLog; import jakarta.persistence.criteria.Predicate; diff --git a/src/main/java/com/bop/youthpick/log/repository/AdminSearchLogSpecifications.java b/src/main/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecifications.java similarity index 97% rename from src/main/java/com/bop/youthpick/log/repository/AdminSearchLogSpecifications.java rename to src/main/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecifications.java index 05dc4f9..85f520f 100644 --- a/src/main/java/com/bop/youthpick/log/repository/AdminSearchLogSpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.repository; +package com.bop.youthpick.admin.log.repository; import com.bop.youthpick.log.entity.SearchLog; import jakarta.persistence.criteria.Predicate; diff --git a/src/main/java/com/bop/youthpick/log/service/AdminAppLogService.java b/src/main/java/com/bop/youthpick/admin/log/service/AdminAppLogService.java similarity index 83% rename from src/main/java/com/bop/youthpick/log/service/AdminAppLogService.java rename to src/main/java/com/bop/youthpick/admin/log/service/AdminAppLogService.java index 6047f2d..f1ef0c2 100644 --- a/src/main/java/com/bop/youthpick/log/service/AdminAppLogService.java +++ b/src/main/java/com/bop/youthpick/admin/log/service/AdminAppLogService.java @@ -1,7 +1,7 @@ -package com.bop.youthpick.log.service; +package com.bop.youthpick.admin.log.service; -import com.bop.youthpick.log.dto.ApplicationLogResponse; -import com.bop.youthpick.log.repository.AdminAppLogSpecifications; +import com.bop.youthpick.admin.log.dto.ApplicationLogResponse; +import com.bop.youthpick.admin.log.repository.AdminAppLogSpecifications; import com.bop.youthpick.log.repository.AppLogRepository; import java.time.LocalDate; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/bop/youthpick/log/service/AdminSearchLogService.java b/src/main/java/com/bop/youthpick/admin/log/service/AdminSearchLogService.java similarity index 82% rename from src/main/java/com/bop/youthpick/log/service/AdminSearchLogService.java rename to src/main/java/com/bop/youthpick/admin/log/service/AdminSearchLogService.java index be9ddef..068f0fc 100644 --- a/src/main/java/com/bop/youthpick/log/service/AdminSearchLogService.java +++ b/src/main/java/com/bop/youthpick/admin/log/service/AdminSearchLogService.java @@ -1,7 +1,7 @@ -package com.bop.youthpick.log.service; +package com.bop.youthpick.admin.log.service; -import com.bop.youthpick.log.dto.SearchLogResponse; -import com.bop.youthpick.log.repository.AdminSearchLogSpecifications; +import com.bop.youthpick.admin.log.dto.SearchLogResponse; +import com.bop.youthpick.admin.log.repository.AdminSearchLogSpecifications; import com.bop.youthpick.log.repository.SearchLogRepository; import java.time.LocalDate; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationController.java b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java similarity index 88% rename from src/main/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationController.java rename to src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java index c0ef8d7..94c9659 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationController.java +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java @@ -1,10 +1,10 @@ -package com.bop.youthpick.policy.controller; +package com.bop.youthpick.admin.policy.controller; +import com.bop.youthpick.admin.policy.dto.AdminPolicyApplicationResponse; +import com.bop.youthpick.admin.policy.dto.AdminPolicyApplicationStatusUpdateRequest; +import com.bop.youthpick.admin.policy.dto.ApplicationChecklistItemResponse; +import com.bop.youthpick.admin.policy.service.AdminPolicyApplicationService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.policy.dto.AdminPolicyApplicationResponse; -import com.bop.youthpick.policy.dto.AdminPolicyApplicationStatusUpdateRequest; -import com.bop.youthpick.policy.dto.ApplicationChecklistItemResponse; -import com.bop.youthpick.policy.service.AdminPolicyApplicationService; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; diff --git a/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyController.java b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java similarity index 90% rename from src/main/java/com/bop/youthpick/policy/controller/AdminPolicyController.java rename to src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java index b3fdb00..12e5205 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/AdminPolicyController.java +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java @@ -1,10 +1,10 @@ -package com.bop.youthpick.policy.controller; +package com.bop.youthpick.admin.policy.controller; +import com.bop.youthpick.admin.policy.dto.AdminPolicyResponse; +import com.bop.youthpick.admin.policy.dto.AdminPolicyUpdateRequest; +import com.bop.youthpick.admin.policy.dto.AdminPolicyVisibilityUpdateRequest; +import com.bop.youthpick.admin.policy.service.AdminPolicyService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.policy.dto.AdminPolicyResponse; -import com.bop.youthpick.policy.dto.AdminPolicyUpdateRequest; -import com.bop.youthpick.policy.dto.AdminPolicyVisibilityUpdateRequest; -import com.bop.youthpick.policy.service.AdminPolicyService; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; diff --git a/src/main/java/com/bop/youthpick/policy/controller/AdminRegionController.java b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java similarity index 85% rename from src/main/java/com/bop/youthpick/policy/controller/AdminRegionController.java rename to src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java index b2be286..9a5229f 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/AdminRegionController.java +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java @@ -1,8 +1,8 @@ -package com.bop.youthpick.policy.controller; +package com.bop.youthpick.admin.policy.controller; +import com.bop.youthpick.admin.policy.service.AdminRegionService; import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.RegionResponse; -import com.bop.youthpick.policy.service.AdminRegionService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationResponse.java similarity index 95% rename from src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationResponse.java rename to src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationResponse.java index aa29bca..ac4f729 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationResponse.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.dto; +package com.bop.youthpick.admin.policy.dto; import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.PolicyApplication; diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java similarity index 90% rename from src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java rename to src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java index 684420e..22be04d 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.dto; +package com.bop.youthpick.admin.policy.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java similarity index 97% rename from src/main/java/com/bop/youthpick/policy/dto/AdminPolicyResponse.java rename to src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java index 4ffdd9c..18d9537 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyResponse.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.dto; +package com.bop.youthpick.admin.policy.dto; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyUpdateRequest.java similarity index 96% rename from src/main/java/com/bop/youthpick/policy/dto/AdminPolicyUpdateRequest.java rename to src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyUpdateRequest.java index 524f4f1..eeb211b 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyUpdateRequest.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyUpdateRequest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.dto; +package com.bop.youthpick.admin.policy.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyVisibilityUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyVisibilityUpdateRequest.java similarity index 90% rename from src/main/java/com/bop/youthpick/policy/dto/AdminPolicyVisibilityUpdateRequest.java rename to src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyVisibilityUpdateRequest.java index e2d69d6..d330ef3 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/AdminPolicyVisibilityUpdateRequest.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyVisibilityUpdateRequest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.dto; +package com.bop.youthpick.admin.policy.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; diff --git a/src/main/java/com/bop/youthpick/policy/dto/ApplicationChecklistItemResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java similarity index 93% rename from src/main/java/com/bop/youthpick/policy/dto/ApplicationChecklistItemResponse.java rename to src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java index 18ad772..dcfba71 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/ApplicationChecklistItemResponse.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.dto; +package com.bop.youthpick.admin.policy.dto; import com.bop.youthpick.policy.entity.ApplicationChecklist; import java.time.LocalDateTime; diff --git a/src/main/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecifications.java b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecifications.java similarity index 99% rename from src/main/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecifications.java rename to src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecifications.java index 0f870eb..d3847ce 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.repository; +package com.bop.youthpick.admin.policy.repository; import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.entity.ApplicationStatus; diff --git a/src/main/java/com/bop/youthpick/policy/repository/AdminPolicySpecifications.java b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java similarity index 97% rename from src/main/java/com/bop/youthpick/policy/repository/AdminPolicySpecifications.java rename to src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java index ec009f5..2089d7c 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/AdminPolicySpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.repository; +package com.bop.youthpick.admin.policy.repository; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; diff --git a/src/main/java/com/bop/youthpick/policy/service/AdminPolicyApplicationService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java similarity index 90% rename from src/main/java/com/bop/youthpick/policy/service/AdminPolicyApplicationService.java rename to src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java index 9c6b415..13bad11 100644 --- a/src/main/java/com/bop/youthpick/policy/service/AdminPolicyApplicationService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java @@ -1,12 +1,12 @@ -package com.bop.youthpick.policy.service; +package com.bop.youthpick.admin.policy.service; +import com.bop.youthpick.admin.policy.dto.AdminPolicyApplicationResponse; +import com.bop.youthpick.admin.policy.dto.ApplicationChecklistItemResponse; +import com.bop.youthpick.admin.policy.repository.AdminPolicyApplicationSpecifications; import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.policy.dto.AdminPolicyApplicationResponse; -import com.bop.youthpick.policy.dto.ApplicationChecklistItemResponse; import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.PolicyApplication; import com.bop.youthpick.policy.exception.PolicyErrorCode; -import com.bop.youthpick.policy.repository.AdminPolicyApplicationSpecifications; import com.bop.youthpick.policy.repository.ApplicationChecklistRepository; import com.bop.youthpick.policy.repository.PolicyApplicationRepository; import java.time.LocalDate; diff --git a/src/main/java/com/bop/youthpick/policy/service/AdminPolicyService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java similarity index 94% rename from src/main/java/com/bop/youthpick/policy/service/AdminPolicyService.java rename to src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java index e161d55..dfdfc69 100644 --- a/src/main/java/com/bop/youthpick/policy/service/AdminPolicyService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java @@ -1,14 +1,14 @@ -package com.bop.youthpick.policy.service; +package com.bop.youthpick.admin.policy.service; +import com.bop.youthpick.admin.policy.dto.AdminPolicyResponse; +import com.bop.youthpick.admin.policy.dto.AdminPolicyUpdateRequest; +import com.bop.youthpick.admin.policy.repository.AdminPolicySpecifications; import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.policy.dto.AdminPolicyResponse; -import com.bop.youthpick.policy.dto.AdminPolicyUpdateRequest; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyRegion; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.policy.exception.PolicyErrorCode; -import com.bop.youthpick.policy.repository.AdminPolicySpecifications; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; import com.bop.youthpick.policy.repository.RegionRepository; diff --git a/src/main/java/com/bop/youthpick/policy/service/AdminRegionService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java similarity index 92% rename from src/main/java/com/bop/youthpick/policy/service/AdminRegionService.java rename to src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java index e7dd266..d6f47e2 100644 --- a/src/main/java/com/bop/youthpick/policy/service/AdminRegionService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.service; +package com.bop.youthpick.admin.policy.service; import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.repository.RegionRepository; diff --git a/src/main/java/com/bop/youthpick/sync/controller/AdminBatchJobLogController.java b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java similarity index 90% rename from src/main/java/com/bop/youthpick/sync/controller/AdminBatchJobLogController.java rename to src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java index 48e2dc9..11d4693 100644 --- a/src/main/java/com/bop/youthpick/sync/controller/AdminBatchJobLogController.java +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java @@ -1,8 +1,8 @@ -package com.bop.youthpick.sync.controller; +package com.bop.youthpick.admin.sync.controller; +import com.bop.youthpick.admin.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.admin.sync.service.AdminBatchJobLogService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.sync.dto.BatchJobLogResponse; -import com.bop.youthpick.sync.service.AdminBatchJobLogService; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; import java.util.List; diff --git a/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncController.java b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java similarity index 95% rename from src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncController.java rename to src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java index 30289d1..742e753 100644 --- a/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncController.java +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.sync.controller; +package com.bop.youthpick.admin.sync.controller; import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.sync.service.PolicySyncService; diff --git a/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobController.java b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java similarity index 77% rename from src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobController.java rename to src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java index 0c5cca1..4d65aaa 100644 --- a/src/main/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobController.java +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java @@ -1,8 +1,8 @@ -package com.bop.youthpick.sync.controller; +package com.bop.youthpick.admin.sync.controller; +import com.bop.youthpick.admin.sync.dto.PolicySyncJobSummaryResponse; +import com.bop.youthpick.admin.sync.service.AdminPolicySyncJobService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; -import com.bop.youthpick.sync.service.AdminPolicySyncJobService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/bop/youthpick/sync/dto/BatchJobLogResponse.java b/src/main/java/com/bop/youthpick/admin/sync/dto/BatchJobLogResponse.java similarity index 94% rename from src/main/java/com/bop/youthpick/sync/dto/BatchJobLogResponse.java rename to src/main/java/com/bop/youthpick/admin/sync/dto/BatchJobLogResponse.java index daaada1..c3d23d1 100644 --- a/src/main/java/com/bop/youthpick/sync/dto/BatchJobLogResponse.java +++ b/src/main/java/com/bop/youthpick/admin/sync/dto/BatchJobLogResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.sync.dto; +package com.bop.youthpick.admin.sync.dto; import com.bop.youthpick.sync.entity.BatchStatus; import com.bop.youthpick.sync.entity.PolicyBatchHistory; diff --git a/src/main/java/com/bop/youthpick/sync/dto/PolicySyncJobSummaryResponse.java b/src/main/java/com/bop/youthpick/admin/sync/dto/PolicySyncJobSummaryResponse.java similarity index 92% rename from src/main/java/com/bop/youthpick/sync/dto/PolicySyncJobSummaryResponse.java rename to src/main/java/com/bop/youthpick/admin/sync/dto/PolicySyncJobSummaryResponse.java index 04032f6..af4e7b4 100644 --- a/src/main/java/com/bop/youthpick/sync/dto/PolicySyncJobSummaryResponse.java +++ b/src/main/java/com/bop/youthpick/admin/sync/dto/PolicySyncJobSummaryResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.sync.dto; +package com.bop.youthpick.admin.sync.dto; /** parseErrorCount/dbFailCount는 정책 수집 배치 파이프라인이 아직 없어 항상 0이다. 파이프라인이 추가되면 실제 집계로 교체한다. */ public record PolicySyncJobSummaryResponse( diff --git a/src/main/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecifications.java b/src/main/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecifications.java similarity index 97% rename from src/main/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecifications.java rename to src/main/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecifications.java index 619eb0a..b32a192 100644 --- a/src/main/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.sync.repository; +package com.bop.youthpick.admin.sync.repository; import com.bop.youthpick.sync.entity.BatchStatus; import com.bop.youthpick.sync.entity.PolicyBatchHistory; diff --git a/src/main/java/com/bop/youthpick/sync/service/AdminBatchJobLogService.java b/src/main/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogService.java similarity index 82% rename from src/main/java/com/bop/youthpick/sync/service/AdminBatchJobLogService.java rename to src/main/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogService.java index 6611a8a..c1576d7 100644 --- a/src/main/java/com/bop/youthpick/sync/service/AdminBatchJobLogService.java +++ b/src/main/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogService.java @@ -1,7 +1,7 @@ -package com.bop.youthpick.sync.service; +package com.bop.youthpick.admin.sync.service; -import com.bop.youthpick.sync.dto.BatchJobLogResponse; -import com.bop.youthpick.sync.repository.AdminBatchJobLogSpecifications; +import com.bop.youthpick.admin.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.admin.sync.repository.AdminBatchJobLogSpecifications; import com.bop.youthpick.sync.repository.PolicyBatchHistoryRepository; import java.time.LocalDate; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/bop/youthpick/sync/service/AdminPolicySyncJobService.java b/src/main/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobService.java similarity index 87% rename from src/main/java/com/bop/youthpick/sync/service/AdminPolicySyncJobService.java rename to src/main/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobService.java index 3ba9f19..d902876 100644 --- a/src/main/java/com/bop/youthpick/sync/service/AdminPolicySyncJobService.java +++ b/src/main/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobService.java @@ -1,8 +1,8 @@ -package com.bop.youthpick.sync.service; +package com.bop.youthpick.admin.sync.service; +import com.bop.youthpick.admin.sync.dto.PolicySyncJobSummaryResponse; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.repository.PolicyRepository; -import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/bop/youthpick/user/controller/AdminLoginHistoryController.java b/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java similarity index 88% rename from src/main/java/com/bop/youthpick/user/controller/AdminLoginHistoryController.java rename to src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java index 2f920ad..b022d48 100644 --- a/src/main/java/com/bop/youthpick/user/controller/AdminLoginHistoryController.java +++ b/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java @@ -1,8 +1,8 @@ -package com.bop.youthpick.user.controller; +package com.bop.youthpick.admin.user.controller; +import com.bop.youthpick.admin.user.dto.LoginHistoryResponse; +import com.bop.youthpick.admin.user.service.AdminLoginHistoryService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.user.dto.LoginHistoryResponse; -import com.bop.youthpick.user.service.AdminLoginHistoryService; import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/bop/youthpick/user/controller/AdminUserController.java b/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java similarity index 89% rename from src/main/java/com/bop/youthpick/user/controller/AdminUserController.java rename to src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java index b57d822..ad30ce7 100644 --- a/src/main/java/com/bop/youthpick/user/controller/AdminUserController.java +++ b/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java @@ -1,10 +1,10 @@ -package com.bop.youthpick.user.controller; +package com.bop.youthpick.admin.user.controller; +import com.bop.youthpick.admin.user.dto.AdminUserProfileResponse; +import com.bop.youthpick.admin.user.dto.AdminUserResponse; +import com.bop.youthpick.admin.user.dto.AdminUserRoleUpdateRequest; +import com.bop.youthpick.admin.user.service.AdminUserService; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.user.dto.AdminUserProfileResponse; -import com.bop.youthpick.user.dto.AdminUserResponse; -import com.bop.youthpick.user.dto.AdminUserRoleUpdateRequest; -import com.bop.youthpick.user.service.AdminUserService; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import java.util.List; diff --git a/src/main/java/com/bop/youthpick/user/dto/AdminUserProfileResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java similarity index 96% rename from src/main/java/com/bop/youthpick/user/dto/AdminUserProfileResponse.java rename to src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java index 84ae190..95adea5 100644 --- a/src/main/java/com/bop/youthpick/user/dto/AdminUserProfileResponse.java +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.dto; +package com.bop.youthpick.admin.user.dto; import com.bop.youthpick.user.entity.UserProfile; import java.util.Arrays; diff --git a/src/main/java/com/bop/youthpick/user/dto/AdminUserResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserResponse.java similarity index 93% rename from src/main/java/com/bop/youthpick/user/dto/AdminUserResponse.java rename to src/main/java/com/bop/youthpick/admin/user/dto/AdminUserResponse.java index 10180c7..02d159c 100644 --- a/src/main/java/com/bop/youthpick/user/dto/AdminUserResponse.java +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.dto; +package com.bop.youthpick.admin.user.dto; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.entity.User; diff --git a/src/main/java/com/bop/youthpick/user/dto/AdminUserRoleUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserRoleUpdateRequest.java similarity index 88% rename from src/main/java/com/bop/youthpick/user/dto/AdminUserRoleUpdateRequest.java rename to src/main/java/com/bop/youthpick/admin/user/dto/AdminUserRoleUpdateRequest.java index 00e8eaa..677ef42 100644 --- a/src/main/java/com/bop/youthpick/user/dto/AdminUserRoleUpdateRequest.java +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserRoleUpdateRequest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.dto; +package com.bop.youthpick.admin.user.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; diff --git a/src/main/java/com/bop/youthpick/user/dto/LoginHistoryResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/LoginHistoryResponse.java similarity index 91% rename from src/main/java/com/bop/youthpick/user/dto/LoginHistoryResponse.java rename to src/main/java/com/bop/youthpick/admin/user/dto/LoginHistoryResponse.java index 0250c69..1a9b259 100644 --- a/src/main/java/com/bop/youthpick/user/dto/LoginHistoryResponse.java +++ b/src/main/java/com/bop/youthpick/admin/user/dto/LoginHistoryResponse.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.dto; +package com.bop.youthpick.admin.user.dto; import com.bop.youthpick.user.entity.LoginHistory; import java.time.LocalDateTime; diff --git a/src/main/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecifications.java b/src/main/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecifications.java similarity index 97% rename from src/main/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecifications.java rename to src/main/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecifications.java index 127cd9f..2eb1886 100644 --- a/src/main/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.repository; +package com.bop.youthpick.admin.user.repository; import com.bop.youthpick.user.entity.LoginHistory; import jakarta.persistence.criteria.Predicate; diff --git a/src/main/java/com/bop/youthpick/user/repository/AdminUserSpecifications.java b/src/main/java/com/bop/youthpick/admin/user/repository/AdminUserSpecifications.java similarity index 96% rename from src/main/java/com/bop/youthpick/user/repository/AdminUserSpecifications.java rename to src/main/java/com/bop/youthpick/admin/user/repository/AdminUserSpecifications.java index a273394..f155f24 100644 --- a/src/main/java/com/bop/youthpick/user/repository/AdminUserSpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/user/repository/AdminUserSpecifications.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.repository; +package com.bop.youthpick.admin.user.repository; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.entity.User; diff --git a/src/main/java/com/bop/youthpick/user/service/AdminLoginHistoryService.java b/src/main/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryService.java similarity index 82% rename from src/main/java/com/bop/youthpick/user/service/AdminLoginHistoryService.java rename to src/main/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryService.java index c297ad9..600c219 100644 --- a/src/main/java/com/bop/youthpick/user/service/AdminLoginHistoryService.java +++ b/src/main/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryService.java @@ -1,7 +1,7 @@ -package com.bop.youthpick.user.service; +package com.bop.youthpick.admin.user.service; -import com.bop.youthpick.user.dto.LoginHistoryResponse; -import com.bop.youthpick.user.repository.AdminLoginHistorySpecifications; +import com.bop.youthpick.admin.user.dto.LoginHistoryResponse; +import com.bop.youthpick.admin.user.repository.AdminLoginHistorySpecifications; import com.bop.youthpick.user.repository.LoginHistoryRepository; import java.time.LocalDate; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/bop/youthpick/user/service/AdminUserService.java b/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java similarity index 89% rename from src/main/java/com/bop/youthpick/user/service/AdminUserService.java rename to src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java index fee8cdb..c88a896 100644 --- a/src/main/java/com/bop/youthpick/user/service/AdminUserService.java +++ b/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java @@ -1,12 +1,12 @@ -package com.bop.youthpick.user.service; +package com.bop.youthpick.admin.user.service; -import com.bop.youthpick.user.dto.AdminUserProfileResponse; -import com.bop.youthpick.user.dto.AdminUserResponse; +import com.bop.youthpick.admin.user.dto.AdminUserProfileResponse; +import com.bop.youthpick.admin.user.dto.AdminUserResponse; +import com.bop.youthpick.admin.user.repository.AdminUserSpecifications; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.exception.UserError; import com.bop.youthpick.user.exception.UserException; -import com.bop.youthpick.user.repository.AdminUserSpecifications; import com.bop.youthpick.user.repository.UserProfileRepository; import com.bop.youthpick.user.repository.UserRepository; import lombok.RequiredArgsConstructor; diff --git a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java similarity index 91% rename from src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java rename to src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java index 89da413..2c0006d 100644 --- a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityCommentControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java @@ -1,14 +1,14 @@ -package com.bop.youthpick.board.controller; +package com.bop.youthpick.admin.board.controller; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.admin.board.service.AdminCommunityService; import com.bop.youthpick.board.exception.BoardErrorCode; import com.bop.youthpick.board.exception.BoardException; -import com.bop.youthpick.board.service.AdminCommunityService; import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java similarity index 94% rename from src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java rename to src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java index 128f15b..1944504 100644 --- a/src/test/java/com/bop/youthpick/board/controller/AdminCommunityPostControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.controller; +package com.bop.youthpick.admin.board.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -9,12 +9,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.board.dto.AdminAttachmentResponse; -import com.bop.youthpick.board.dto.AdminCommunityCommentResponse; -import com.bop.youthpick.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.admin.board.dto.AdminAttachmentResponse; +import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.admin.board.dto.AdminCommunityPostResponse; +import com.bop.youthpick.admin.board.service.AdminCommunityService; import com.bop.youthpick.board.exception.BoardErrorCode; import com.bop.youthpick.board.exception.BoardException; -import com.bop.youthpick.board.service.AdminCommunityService; import com.bop.youthpick.post.entity.PostCategory; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecificationsTest.java similarity index 98% rename from src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecificationsTest.java index d321cd6..2dfc9fe 100644 --- a/src/test/java/com/bop/youthpick/board/repository/AdminCommunityPostSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecificationsTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.repository; +package com.bop.youthpick.admin.board.repository; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java similarity index 99% rename from src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java rename to src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java index c99b78a..c84c4bc 100644 --- a/src/test/java/com/bop/youthpick/board/service/AdminCommunityServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.service; +package com.bop.youthpick.admin.board.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/src/test/java/com/bop/youthpick/log/controller/AdminAppLogControllerTest.java b/src/test/java/com/bop/youthpick/admin/log/controller/AdminAppLogControllerTest.java similarity index 95% rename from src/test/java/com/bop/youthpick/log/controller/AdminAppLogControllerTest.java rename to src/test/java/com/bop/youthpick/admin/log/controller/AdminAppLogControllerTest.java index e8d06e4..5511da7 100644 --- a/src/test/java/com/bop/youthpick/log/controller/AdminAppLogControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/log/controller/AdminAppLogControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.controller; +package com.bop.youthpick.admin.log.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -8,8 +8,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.log.dto.ApplicationLogResponse; -import com.bop.youthpick.log.service.AdminAppLogService; +import com.bop.youthpick.admin.log.dto.ApplicationLogResponse; +import com.bop.youthpick.admin.log.service.AdminAppLogService; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/log/controller/AdminSearchLogControllerTest.java b/src/test/java/com/bop/youthpick/admin/log/controller/AdminSearchLogControllerTest.java similarity index 92% rename from src/test/java/com/bop/youthpick/log/controller/AdminSearchLogControllerTest.java rename to src/test/java/com/bop/youthpick/admin/log/controller/AdminSearchLogControllerTest.java index d94b774..21f9c11 100644 --- a/src/test/java/com/bop/youthpick/log/controller/AdminSearchLogControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/log/controller/AdminSearchLogControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.controller; +package com.bop.youthpick.admin.log.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.isNull; @@ -7,8 +7,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.log.dto.SearchLogResponse; -import com.bop.youthpick.log.service.AdminSearchLogService; +import com.bop.youthpick.admin.log.dto.SearchLogResponse; +import com.bop.youthpick.admin.log.service.AdminSearchLogService; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecificationsTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecificationsTest.java index 6b13a8a..16a1121 100644 --- a/src/test/java/com/bop/youthpick/log/repository/AdminAppLogSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecificationsTest.java @@ -1,9 +1,10 @@ -package com.bop.youthpick.log.repository; +package com.bop.youthpick.admin.log.repository; import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; import com.bop.youthpick.log.entity.ApplicationLog; +import com.bop.youthpick.log.repository.AppLogRepository; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/log/repository/AdminSearchLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecificationsTest.java similarity index 96% rename from src/test/java/com/bop/youthpick/log/repository/AdminSearchLogSpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecificationsTest.java index 6c66baf..c5683c1 100644 --- a/src/test/java/com/bop/youthpick/log/repository/AdminSearchLogSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecificationsTest.java @@ -1,9 +1,10 @@ -package com.bop.youthpick.log.repository; +package com.bop.youthpick.admin.log.repository; import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; import com.bop.youthpick.log.entity.SearchLog; +import com.bop.youthpick.log.repository.SearchLogRepository; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java b/src/test/java/com/bop/youthpick/admin/log/service/AdminAppLogServiceTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java rename to src/test/java/com/bop/youthpick/admin/log/service/AdminAppLogServiceTest.java index c773145..b0d6e07 100644 --- a/src/test/java/com/bop/youthpick/log/service/AdminAppLogServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/log/service/AdminAppLogServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.service; +package com.bop.youthpick.admin.log.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/src/test/java/com/bop/youthpick/log/service/AdminSearchLogServiceTest.java b/src/test/java/com/bop/youthpick/admin/log/service/AdminSearchLogServiceTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/log/service/AdminSearchLogServiceTest.java rename to src/test/java/com/bop/youthpick/admin/log/service/AdminSearchLogServiceTest.java index 942a24f..7f05b57 100644 --- a/src/test/java/com/bop/youthpick/log/service/AdminSearchLogServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/log/service/AdminSearchLogServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.log.service; +package com.bop.youthpick.admin.log.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationControllerTest.java b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationControllerTest.java similarity index 95% rename from src/test/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationControllerTest.java rename to src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationControllerTest.java index db0727d..ad5ad56 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyApplicationControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.controller; +package com.bop.youthpick.admin.policy.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -9,12 +9,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.bop.youthpick.admin.policy.dto.AdminPolicyApplicationResponse; +import com.bop.youthpick.admin.policy.dto.ApplicationChecklistItemResponse; +import com.bop.youthpick.admin.policy.service.AdminPolicyApplicationService; import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.policy.dto.AdminPolicyApplicationResponse; -import com.bop.youthpick.policy.dto.ApplicationChecklistItemResponse; import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.exception.PolicyErrorCode; -import com.bop.youthpick.policy.service.AdminPolicyApplicationService; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyControllerTest.java b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/policy/controller/AdminPolicyControllerTest.java rename to src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java index f462412..1deee31 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/AdminPolicyControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.controller; +package com.bop.youthpick.admin.policy.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -11,11 +11,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.bop.youthpick.admin.policy.dto.AdminPolicyResponse; +import com.bop.youthpick.admin.policy.service.AdminPolicyService; import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.policy.dto.AdminPolicyResponse; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.exception.PolicyErrorCode; -import com.bop.youthpick.policy.service.AdminPolicyService; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/policy/controller/AdminRegionControllerTest.java b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminRegionControllerTest.java similarity index 93% rename from src/test/java/com/bop/youthpick/policy/controller/AdminRegionControllerTest.java rename to src/test/java/com/bop/youthpick/admin/policy/controller/AdminRegionControllerTest.java index 1c22e5d..696dfc1 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/AdminRegionControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminRegionControllerTest.java @@ -1,12 +1,12 @@ -package com.bop.youthpick.policy.controller; +package com.bop.youthpick.admin.policy.controller; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.bop.youthpick.admin.policy.service.AdminRegionService; import com.bop.youthpick.policy.dto.RegionResponse; -import com.bop.youthpick.policy.service.AdminRegionService; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecificationsTest.java similarity index 98% rename from src/test/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecificationsTest.java index 59efda2..4490b4a 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/AdminPolicyApplicationSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecificationsTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.repository; +package com.bop.youthpick.admin.policy.repository; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -10,6 +10,7 @@ import com.bop.youthpick.policy.entity.PolicyApplication; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.repository.UserRepository; import java.time.LocalDate; diff --git a/src/test/java/com/bop/youthpick/policy/repository/AdminPolicySpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/policy/repository/AdminPolicySpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java index e0c2a66..ac3bdaf 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/AdminPolicySpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java @@ -1,10 +1,11 @@ -package com.bop.youthpick.policy.repository; +package com.bop.youthpick.admin.policy.repository; import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.repository.PolicyRepository; import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/com/bop/youthpick/policy/service/AdminPolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java similarity index 99% rename from src/test/java/com/bop/youthpick/policy/service/AdminPolicyApplicationServiceTest.java rename to src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java index d9345f0..2551954 100644 --- a/src/test/java/com/bop/youthpick/policy/service/AdminPolicyApplicationServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.service; +package com.bop.youthpick.admin.policy.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/src/test/java/com/bop/youthpick/policy/service/AdminPolicyServiceTest.java b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java similarity index 98% rename from src/test/java/com/bop/youthpick/policy/service/AdminPolicyServiceTest.java rename to src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java index 2dd62e0..0661bdd 100644 --- a/src/test/java/com/bop/youthpick/policy/service/AdminPolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.policy.service; +package com.bop.youthpick.admin.policy.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -8,8 +8,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.bop.youthpick.admin.policy.dto.AdminPolicyUpdateRequest; import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.policy.dto.AdminPolicyUpdateRequest; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyRegion; import com.bop.youthpick.policy.entity.PolicyVisibility; diff --git a/src/test/java/com/bop/youthpick/sync/controller/AdminBatchJobLogControllerTest.java b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogControllerTest.java similarity index 93% rename from src/test/java/com/bop/youthpick/sync/controller/AdminBatchJobLogControllerTest.java rename to src/test/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogControllerTest.java index 944b8d6..8b1c699 100644 --- a/src/test/java/com/bop/youthpick/sync/controller/AdminBatchJobLogControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.sync.controller; +package com.bop.youthpick.admin.sync.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.isNull; @@ -7,9 +7,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.admin.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.admin.sync.service.AdminBatchJobLogService; import com.bop.youthpick.sync.entity.BatchStatus; -import com.bop.youthpick.sync.service.AdminBatchJobLogService; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncControllerTest.java b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncControllerTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncControllerTest.java rename to src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncControllerTest.java index 3ea74f7..cd437d1 100644 --- a/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.sync.controller; +package com.bop.youthpick.admin.sync.controller; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; diff --git a/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobControllerTest.java b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobControllerTest.java similarity index 89% rename from src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobControllerTest.java rename to src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobControllerTest.java index 130e667..4e94cbe 100644 --- a/src/test/java/com/bop/youthpick/sync/controller/AdminPolicySyncJobControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobControllerTest.java @@ -1,12 +1,12 @@ -package com.bop.youthpick.sync.controller; +package com.bop.youthpick.admin.sync.controller; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; -import com.bop.youthpick.sync.service.AdminPolicySyncJobService; +import com.bop.youthpick.admin.sync.dto.PolicySyncJobSummaryResponse; +import com.bop.youthpick.admin.sync.service.AdminPolicySyncJobService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; diff --git a/src/test/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecificationsTest.java similarity index 95% rename from src/test/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecificationsTest.java index 5ad2d22..3374c96 100644 --- a/src/test/java/com/bop/youthpick/sync/repository/AdminBatchJobLogSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecificationsTest.java @@ -1,10 +1,11 @@ -package com.bop.youthpick.sync.repository; +package com.bop.youthpick.admin.sync.repository; import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.sync.entity.BatchMode; import com.bop.youthpick.sync.entity.BatchStatus; import com.bop.youthpick.sync.entity.PolicyBatchHistory; +import com.bop.youthpick.sync.repository.PolicyBatchHistoryRepository; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/sync/service/AdminBatchJobLogServiceTest.java b/src/test/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogServiceTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/sync/service/AdminBatchJobLogServiceTest.java rename to src/test/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogServiceTest.java index f7a1912..97fbfbe 100644 --- a/src/test/java/com/bop/youthpick/sync/service/AdminBatchJobLogServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.sync.service; +package com.bop.youthpick.admin.sync.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/src/test/java/com/bop/youthpick/sync/service/AdminPolicySyncJobServiceTest.java b/src/test/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobServiceTest.java similarity index 92% rename from src/test/java/com/bop/youthpick/sync/service/AdminPolicySyncJobServiceTest.java rename to src/test/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobServiceTest.java index 7ead836..80cab5c 100644 --- a/src/test/java/com/bop/youthpick/sync/service/AdminPolicySyncJobServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobServiceTest.java @@ -1,11 +1,11 @@ -package com.bop.youthpick.sync.service; +package com.bop.youthpick.admin.sync.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import com.bop.youthpick.admin.sync.dto.PolicySyncJobSummaryResponse; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.repository.PolicyRepository; -import com.bop.youthpick.sync.dto.PolicySyncJobSummaryResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/src/test/java/com/bop/youthpick/user/controller/AdminLoginHistoryControllerTest.java b/src/test/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryControllerTest.java similarity index 91% rename from src/test/java/com/bop/youthpick/user/controller/AdminLoginHistoryControllerTest.java rename to src/test/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryControllerTest.java index a48c5f4..7a8564f 100644 --- a/src/test/java/com/bop/youthpick/user/controller/AdminLoginHistoryControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.controller; +package com.bop.youthpick.admin.user.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.isNull; @@ -7,8 +7,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.user.dto.LoginHistoryResponse; -import com.bop.youthpick.user.service.AdminLoginHistoryService; +import com.bop.youthpick.admin.user.dto.LoginHistoryResponse; +import com.bop.youthpick.admin.user.service.AdminLoginHistoryService; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/user/controller/AdminUserControllerTest.java b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java similarity index 96% rename from src/test/java/com/bop/youthpick/user/controller/AdminUserControllerTest.java rename to src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java index 13729de..8d328fb 100644 --- a/src/test/java/com/bop/youthpick/user/controller/AdminUserControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.controller; +package com.bop.youthpick.admin.user.controller; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -11,12 +11,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.user.dto.AdminUserProfileResponse; -import com.bop.youthpick.user.dto.AdminUserResponse; +import com.bop.youthpick.admin.user.dto.AdminUserProfileResponse; +import com.bop.youthpick.admin.user.dto.AdminUserResponse; +import com.bop.youthpick.admin.user.service.AdminUserService; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.exception.UserError; import com.bop.youthpick.user.exception.UserException; -import com.bop.youthpick.user.service.AdminUserService; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecificationsTest.java similarity index 94% rename from src/test/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecificationsTest.java index d5bace8..72cdd00 100644 --- a/src/test/java/com/bop/youthpick/user/repository/AdminLoginHistorySpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecificationsTest.java @@ -1,10 +1,12 @@ -package com.bop.youthpick.user.repository; +package com.bop.youthpick.admin.user.repository; import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; import com.bop.youthpick.user.entity.LoginHistory; import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.LoginHistoryRepository; +import com.bop.youthpick.user.repository.UserRepository; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/user/repository/AdminUserSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/user/repository/AdminUserSpecificationsTest.java similarity index 96% rename from src/test/java/com/bop/youthpick/user/repository/AdminUserSpecificationsTest.java rename to src/test/java/com/bop/youthpick/admin/user/repository/AdminUserSpecificationsTest.java index 168c6d4..59d3e90 100644 --- a/src/test/java/com/bop/youthpick/user/repository/AdminUserSpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/repository/AdminUserSpecificationsTest.java @@ -1,10 +1,11 @@ -package com.bop.youthpick.user.repository; +package com.bop.youthpick.admin.user.repository; import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/user/service/AdminLoginHistoryServiceTest.java b/src/test/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryServiceTest.java similarity index 97% rename from src/test/java/com/bop/youthpick/user/service/AdminLoginHistoryServiceTest.java rename to src/test/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryServiceTest.java index 8e3908e..bd7ef05 100644 --- a/src/test/java/com/bop/youthpick/user/service/AdminLoginHistoryServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.service; +package com.bop.youthpick.admin.user.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; diff --git a/src/test/java/com/bop/youthpick/user/service/AdminUserServiceTest.java b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java similarity index 99% rename from src/test/java/com/bop/youthpick/user/service/AdminUserServiceTest.java rename to src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java index d400ff8..d08388d 100644 --- a/src/test/java/com/bop/youthpick/user/service/AdminUserServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.user.service; +package com.bop.youthpick.admin.user.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; From 38f471e8821ed15b0c93b5fc913b435634f2c846 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Mon, 20 Jul 2026 10:18:11 +0900 Subject: [PATCH 35/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B3=B5=EA=B0=9C=20API=20(GE?= =?UTF-8?q?T=20/api/v1/policies)=20(#79)=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 정책 목록 조회 공개 API 추가 (GET /api/v1/policies) (#79) - PolicyCardResponse: 프론트 카드가 쓰는 필드만 내리는 목록 전용 DTO - PolicyService.getCards: VISIBLE·미삭제만, 최신순(id DESC) 서버 고정 정렬 - 지역 라벨 조립: 없음→null, 시도1→시도명, 여러 시도→'첫 시도 외 N', 전 시도 커버→'전국'. 시도 수는 regions 테이블 distinct 카운트로 판정 - 전국 정책은 지역이 최대 256행이라 fetch join 배치 조회로 N+1 방지 - PolicyServiceTest에 목록 조회·라벨 4분기 검증 테스트 추가 Closes #79 Co-Authored-By: Claude Opus 4.8 (1M context) * fix: 정책 목록에서 신청 마감 지난 정책 제외 (#79) applicationEndDate가 오늘 이전인 정책은 목록에서 제외한다(마감일 없는 상시 정책은 포함). 실측 표본 60건 중 22건이 마감 지난 정책이었음. --- .../policy/controller/PolicyController.java | 13 ++++ .../policy/dto/PolicyCardResponse.java | 28 +++++++++ .../repository/PolicyRegionRepository.java | 4 ++ .../policy/repository/PolicyRepository.java | 13 ++++ .../policy/repository/RegionRepository.java | 8 ++- .../policy/service/PolicyService.java | 62 +++++++++++++++++++ .../policy/service/PolicyServiceTest.java | 59 +++++++++++++++++- 7 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java index 89477e3..0102d01 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java @@ -2,9 +2,14 @@ import com.bop.youthpick.auth.service.CurrentUser; import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.PolicyCardResponse; import com.bop.youthpick.policy.dto.PolicyDetailResponse; import com.bop.youthpick.policy.service.PolicyService; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,6 +22,14 @@ public class PolicyController { private final PolicyService policyService; + /** 정책 목록(카드) 조회 (비회원 허용). 최신순 서버 고정 정렬, meta에 page/totalCount/totalPages. */ + @GetMapping + public ApiResponse> getCards( + @PageableDefault(size = 20) Pageable pageable) { + Page page = policyService.getCards(pageable); + return ApiResponse.ok(page.getContent(), page); + } + /** 정책 상세 조회 (비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다. */ @GetMapping("/{policyId}") public ApiResponse getDetail( diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java new file mode 100644 index 0000000..4ebfde2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Policy; +import java.time.LocalDate; + +/** 정책 목록(카드) 응답. 상세 필드는 {@link PolicyDetailResponse}로 분리 — 프론트 카드가 쓰는 필드만 내린다. */ +public record PolicyCardResponse( + Long id, + String title, + String category, + String description, + Integer minAge, + Integer maxAge, + LocalDate applicationEndDate, + String regionLabel) { + + public static PolicyCardResponse from(Policy policy, String regionLabel) { + return new PolicyCardResponse( + policy.getId(), + policy.getTitle(), + policy.getCategory(), + policy.getDescription(), + policy.getMinAge(), + policy.getMaxAge(), + policy.getApplicationEndDate(), + regionLabel); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java index a9d903d..7e9f926 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java @@ -14,6 +14,10 @@ public interface PolicyRegionRepository extends JpaRepository findByPolicyIdIn(List policyIds); + /** 목록 카드의 지역 라벨 조립용 — 전국 정책은 지역이 최대 256행이라 lazy 초기화(N+1)를 fetch join으로 막는다. */ + @Query("select pr from PolicyRegion pr join fetch pr.region where pr.policy.id in :policyIds") + List findWithRegionByPolicyIdIn(@Param("policyIds") List policyIds); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("delete from PolicyRegion pr where pr.policy.id = :policyId") void deleteByPolicyId(@Param("policyId") Long policyId); diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index 6239fdc..67c8415 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -3,12 +3,16 @@ import com.bop.youthpick.policy.dto.PolicySyncSnapshot; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; +import java.time.LocalDate; import java.util.Collection; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PolicyRepository extends JpaRepository, JpaSpecificationExecutor { @@ -24,4 +28,13 @@ public interface PolicyRepository long countByVisibilityAndDeletedAtIsNull(PolicyVisibility visibility); Optional findByIdAndVisibilityAndDeletedAtIsNull(Long id, PolicyVisibility visibility); + + /** 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함. */ + @Query( + "select p from Policy p where p.visibility = :visibility and p.deletedAt is null" + + " and (p.applicationEndDate is null or p.applicationEndDate >= :today)") + Page findCards( + @Param("visibility") PolicyVisibility visibility, + @Param("today") LocalDate today, + Pageable pageable); } diff --git a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java index 3551f68..7151ca3 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java @@ -2,5 +2,11 @@ import com.bop.youthpick.policy.entity.Region; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface RegionRepository extends JpaRepository {} +public interface RegionRepository extends JpaRepository { + + /** 시도 개수 — 정책이 전 시도를 커버하면 지역 라벨을 '전국'으로 접는 판정에 쓴다. */ + @Query("select count(distinct r.sidoName) from Region r") + long countDistinctSidoNames(); +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index e2ed093..eb11bb1 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -1,6 +1,7 @@ package com.bop.youthpick.policy.service; import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyCardResponse; import com.bop.youthpick.policy.dto.PolicyDetailResponse; import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.entity.Policy; @@ -8,10 +9,18 @@ import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,8 +34,61 @@ public class PolicyService { private final PolicyRepository policyRepository; private final PolicyRegionRepository policyRegionRepository; + private final RegionRepository regionRepository; private final PolicyRecentViewService policyRecentViewService; + /** + * 정책 목록(카드) 조회 (비회원 허용). 삭제·숨김·신청 마감 지난 정책은 제외하고 최신순(id 내림차순, 서버 고정 정렬)으로 내린다. 지역 라벨은 페이지 단위 배치 + * 조회(fetch join)로 조립해 N+1을 피한다. + */ + @Transactional(readOnly = true) + public Page getCards(Pageable pageable) { + Pageable sorted = + PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(Sort.Direction.DESC, "id")); + Page page = + policyRepository.findCards(PolicyVisibility.VISIBLE, LocalDate.now(), sorted); + + List policyIds = page.getContent().stream().map(Policy::getId).toList(); + Map> sidoNamesByPolicyId = + policyIds.isEmpty() + ? Map.of() + : policyRegionRepository.findWithRegionByPolicyIdIn(policyIds).stream() + .collect( + Collectors.groupingBy( + pr -> pr.getPolicy().getId(), + Collectors.mapping( + pr -> pr.getRegion().getSidoName(), + Collectors.toList()))); + long totalSidoCount = policyIds.isEmpty() ? 0 : regionRepository.countDistinctSidoNames(); + + return page.map( + policy -> + PolicyCardResponse.from( + policy, + toRegionLabel( + sidoNamesByPolicyId.getOrDefault(policy.getId(), List.of()), + totalSidoCount))); + } + + /** 지역 없음 → null, 전 시도 커버 → '전국', 시도 1개 → 시도명, 여러 시도 → '가나다 첫 시도 외 N'. */ + @Nullable + private static String toRegionLabel(List sidoNames, long totalSidoCount) { + List distinct = sidoNames.stream().distinct().sorted().toList(); + if (distinct.isEmpty()) { + return null; + } + if (totalSidoCount > 0 && distinct.size() >= totalSidoCount) { + return "전국"; + } + if (distinct.size() == 1) { + return distinct.get(0); + } + return distinct.get(0) + " 외 " + (distinct.size() - 1); + } + /** * 정책 상세 조회. 삭제(soft delete)·숨김 정책은 존재하지 않는 것으로 취급한다. 로그인 사용자({@code userId != null})의 조회는 최근 본 * 정책으로 기록하되, 기록은 부가 동작이라 실패해도 조회는 정상 응답한다. diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index b0b75b7..b01ac61 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -2,13 +2,16 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyCardResponse; import com.bop.youthpick.policy.dto.PolicyDetailResponse; import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.entity.Policy; @@ -18,15 +21,23 @@ import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.BeanUtils; import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -34,6 +45,7 @@ class PolicyServiceTest { @Mock private PolicyRepository policyRepository; @Mock private PolicyRegionRepository policyRegionRepository; + @Mock private RegionRepository regionRepository; @Mock private PolicyRecentViewService policyRecentViewService; private PolicyService policyService; @@ -42,7 +54,10 @@ class PolicyServiceTest { void setUp() { policyService = new PolicyService( - policyRepository, policyRegionRepository, policyRecentViewService); + policyRepository, + policyRegionRepository, + regionRepository, + policyRecentViewService); } @Test @@ -112,6 +127,48 @@ void setUp() { verify(policyRecentViewService, never()).record(null, policy); } + @Test + void 목록은_VISIBLE_미삭제만_id_내림차순으로_조회하고_카드와_지역라벨을_조립한다() { + Policy single = newPolicy(1L, "한 시도 정책"); + ReflectionTestUtils.setField(single, "category", "주거"); + Policy noRegion = newPolicy(2L, "지역 없음 정책"); + Policy multi = newPolicy(3L, "두 시도 정책"); + Policy nationwide = newPolicy(4L, "전국 정책"); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + pageableCaptor.capture())) + .thenReturn( + new PageImpl<>( + List.of(single, noRegion, multi, nationwide), + PageRequest.of(0, 20), + 4)); + // 시도 전체 3개 기준 — single 1개, multi 2개(가나다 첫 시도 '부산광역시'), nationwide 3개(전국) + when(policyRegionRepository.findWithRegionByPolicyIdIn(List.of(1L, 2L, 3L, 4L))) + .thenReturn( + List.of( + newPolicyRegion(single, "11680", "서울특별시", "강남구"), + newPolicyRegion(multi, "11680", "서울특별시", "강남구"), + newPolicyRegion(multi, "26110", "부산광역시", "중구"), + newPolicyRegion(nationwide, "11680", "서울특별시", "강남구"), + newPolicyRegion(nationwide, "26110", "부산광역시", "중구"), + newPolicyRegion(nationwide, "27110", "대구광역시", "중구"))); + when(regionRepository.countDistinctSidoNames()).thenReturn(3L); + + Page page = policyService.getCards(PageRequest.of(0, 20)); + + assertThat(pageableCaptor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.DESC, "id")); + PolicyCardResponse card = page.getContent().get(0); + assertThat(card.id()).isEqualTo(1L); + assertThat(card.title()).isEqualTo("한 시도 정책"); + assertThat(card.category()).isEqualTo("주거"); + assertThat(page.getContent()) + .extracting(PolicyCardResponse::regionLabel) + .containsExactly("서울특별시", null, "부산광역시 외 1", "전국"); + } + private Policy newPolicy(Long id, String title) { Policy policy = BeanUtils.instantiateClass(Policy.class); ReflectionTestUtils.setField(policy, "id", id); From d15fe44464453d345c971bc89ec709f307de5e79 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Mon, 20 Jul 2026 10:33:00 +0900 Subject: [PATCH 36/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20keyword/region/age=20=EA=B2=80=EC=83=89=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20(#86)=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 정책 목록 조회 공개 API 추가 (GET /api/v1/policies) (#79) - PolicyCardResponse: 프론트 카드가 쓰는 필드만 내리는 목록 전용 DTO - PolicyService.getCards: VISIBLE·미삭제만, 최신순(id DESC) 서버 고정 정렬 - 지역 라벨 조립: 없음→null, 시도1→시도명, 여러 시도→'첫 시도 외 N', 전 시도 커버→'전국'. 시도 수는 regions 테이블 distinct 카운트로 판정 - 전국 정책은 지역이 최대 256행이라 fetch join 배치 조회로 N+1 방지 - PolicyServiceTest에 목록 조회·라벨 4분기 검증 테스트 추가 Closes #79 Co-Authored-By: Claude Opus 4.8 (1M context) * fix: 정책 목록에서 신청 마감 지난 정책 제외 (#79) applicationEndDate가 오늘 이전인 정책은 목록에서 제외한다(마감일 없는 상시 정책은 포함). 실측 표본 60건 중 22건이 마감 지난 정책이었음. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 배치 category(lclsfNm) 정규화 — 표준 5분류로 통일 (#80) - sync PolicyMapper.normalizeCategory: 저장 전 대분류를 표준 5분류 (일자리/주거/교육·직업훈련/금융·복지·문화/참여·기반, 구분자 U+00B7)로 통일. 콤마 다중값('일자리,일자리')은 첫 값, 반각점(U+FF65)·전각점(U+30FB) 변형 흡수, 구명칭('참여권리' 등) 매핑. 미지의 값은 경고 로그 + 원본 유지 - V8 마이그레이션: 기존 저장 데이터(2,638건 실측 기준 변형 다수)를 같은 규칙으로 UPDATE Closes #80 Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 목록 API에 category 필터 추가 — 정규화된 표준 5분류 exact match (#80) 홈 카테고리 바로가기 → 검색 화면 분류 필터가 쓰는 GET /api/v1/policies?category={표준 5분류} 선택 파라미터. 정규화(#80) 없이는 exact match가 변형 값을 놓치므로 이 브랜치에 함께 둔다. keyword·region 등 나머지 검색 필터는 #36에서 진행. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: 목록 API에 keyword/region/age 검색 필터 추가 (#86) - keyword: title·keywords·description·supportContent·organizationName 5개 필드 LIKE 부분일치, %/_/! 이스케이프 - region: 시도명 EXISTS 필터, '전국'이면 전 시도를 커버하는 정책만 - ageMin/ageMax: 요청 구간과 정책 자격 구간의 겹침(overlap) 판정, min/maxAge 0·NULL은 제한없음 - 모든 필터 선택(optional), 기존 category 필터·최신순 고정 정렬과 조합 가능 --- .../policy/controller/PolicyController.java | 15 +++- .../policy/repository/PolicyRepository.java | 30 +++++++- .../policy/service/PolicyService.java | 55 +++++++++++-- .../youthpick/sync/service/PolicyMapper.java | 32 +++++++- .../V8__normalize_policy_category.sql | 19 +++++ .../service/AdminPolicyServiceTest.java | 1 + .../policy/service/PolicyServiceTest.java | 77 ++++++++++++++++++- .../sync/service/PolicyMapperTest.java | 11 +++ 8 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/db/migration/V8__normalize_policy_category.sql diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java index 0102d01..205ef34 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java @@ -13,6 +13,7 @@ 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; @RestController @@ -22,11 +23,21 @@ public class PolicyController { private final PolicyService policyService; - /** 정책 목록(카드) 조회 (비회원 허용). 최신순 서버 고정 정렬, meta에 page/totalCount/totalPages. */ + /** + * 정책 목록(카드) 조회 (비회원 허용). 최신순 서버 고정 정렬. category(표준 5분류) exact match, keyword는 부분일치 검색, region은 + * 시도명('전국'이면 전 시도 커버 정책만), ageMin/ageMax는 자격 구간과의 겹침 필터 — 모두 선택. meta에 + * page/totalCount/totalPages. + */ @GetMapping public ApiResponse> getCards( + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String region, + @RequestParam(required = false) Integer ageMin, + @RequestParam(required = false) Integer ageMax, @PageableDefault(size = 20) Pageable pageable) { - Page page = policyService.getCards(pageable); + Page page = + policyService.getCards(category, keyword, region, ageMin, ageMax, pageable); return ApiResponse.ok(page.getContent(), page); } diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index 67c8415..09bf225 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -29,12 +29,38 @@ public interface PolicyRepository Optional findByIdAndVisibilityAndDeletedAtIsNull(Long id, PolicyVisibility visibility); - /** 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함. */ + /** + * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함. category는 표준 + * 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE 부분일치(escape '!'). region은 시도명 + * EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음), totalSidoCount가 채워지면(region=전국) 전 시도를 커버하는 정책만. age는 요청 + * 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. + */ @Query( "select p from Policy p where p.visibility = :visibility and p.deletedAt is null" - + " and (p.applicationEndDate is null or p.applicationEndDate >= :today)") + + " and (p.applicationEndDate is null or p.applicationEndDate >= :today)" + + " and (:category is null or p.category = :category)" + + " and (:keyword is null" + + " or p.title like :keyword escape '!'" + + " or p.keywords like :keyword escape '!'" + + " or p.description like :keyword escape '!'" + + " or p.supportContent like :keyword escape '!'" + + " or p.organizationName like :keyword escape '!')" + + " and (:sidoName is null or exists (" + + " select pr from PolicyRegion pr" + + " where pr.policy = p and pr.region.sidoName = :sidoName))" + + " and (:totalSidoCount is null or (" + + " select count(distinct pr2.region.sidoName) from PolicyRegion pr2" + + " where pr2.policy = p) >= :totalSidoCount)" + + " and (:ageMin is null or p.maxAge is null or p.maxAge = 0 or p.maxAge >= :ageMin)" + + " and (:ageMax is null or p.minAge is null or p.minAge = 0 or p.minAge <= :ageMax)") Page findCards( @Param("visibility") PolicyVisibility visibility, @Param("today") LocalDate today, + @Param("category") String category, + @Param("keyword") String keyword, + @Param("sidoName") String sidoName, + @Param("totalSidoCount") Long totalSidoCount, + @Param("ageMin") Integer ageMin, + @Param("ageMax") Integer ageMax, Pageable pageable); } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index eb11bb1..cb2c63f 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -37,19 +37,45 @@ public class PolicyService { private final RegionRepository regionRepository; private final PolicyRecentViewService policyRecentViewService; + private static final String NATIONWIDE_REGION = "전국"; + /** - * 정책 목록(카드) 조회 (비회원 허용). 삭제·숨김·신청 마감 지난 정책은 제외하고 최신순(id 내림차순, 서버 고정 정렬)으로 내린다. 지역 라벨은 페이지 단위 배치 - * 조회(fetch join)로 조립해 N+1을 피한다. + * 정책 목록(카드) 조회 (비회원 허용). 삭제·숨김·신청 마감 지난 정책은 제외하고 최신순(id 내림차순, 서버 고정 정렬)으로 내린다. category(표준 5분류) + * exact match, keyword는 5개 필드 LIKE 부분일치, region은 시도명 필터('전국'이면 전 시도를 커버하는 정책만), age는 [ageMin, + * ageMax] 구간과 정책 자격 구간의 겹침(overlap)만 필터한다. 지역 라벨은 페이지 단위 배치 조회(fetch join)로 조립해 N+1을 피한다. */ @Transactional(readOnly = true) - public Page getCards(Pageable pageable) { + public Page getCards( + @Nullable String category, + @Nullable String keyword, + @Nullable String region, + @Nullable Integer ageMin, + @Nullable Integer ageMax, + Pageable pageable) { Pageable sorted = PageRequest.of( pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.DESC, "id")); + String categoryFilter = trimToNull(category); + String keywordPattern = toLikePattern(keyword); + String regionFilter = trimToNull(region); + boolean nationwideOnly = NATIONWIDE_REGION.equals(regionFilter); + String sidoName = nationwideOnly ? null : regionFilter; + long totalSidoCount = regionRepository.countDistinctSidoNames(); + Long nationwideThreshold = nationwideOnly ? totalSidoCount : null; + Page page = - policyRepository.findCards(PolicyVisibility.VISIBLE, LocalDate.now(), sorted); + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + categoryFilter, + keywordPattern, + sidoName, + nationwideThreshold, + ageMin, + ageMax, + sorted); List policyIds = page.getContent().stream().map(Policy::getId).toList(); Map> sidoNamesByPolicyId = @@ -62,7 +88,6 @@ public Page getCards(Pageable pageable) { Collectors.mapping( pr -> pr.getRegion().getSidoName(), Collectors.toList()))); - long totalSidoCount = policyIds.isEmpty() ? 0 : regionRepository.countDistinctSidoNames(); return page.map( policy -> @@ -73,6 +98,26 @@ public Page getCards(Pageable pageable) { totalSidoCount))); } + @Nullable + private static String trimToNull(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** LIKE 특수문자(%/_)와 이스케이프 문자(!)를 이스케이프하고 부분일치 패턴으로 감싼다. */ + @Nullable + private static String toLikePattern(@Nullable String keyword) { + String normalized = trimToNull(keyword); + if (normalized == null) { + return null; + } + String escaped = normalized.replace("!", "!!").replace("%", "!%").replace("_", "!_"); + return "%" + escaped + "%"; + } + /** 지역 없음 → null, 전 시도 커버 → '전국', 시도 1개 → 시도명, 여러 시도 → '가나다 첫 시도 외 N'. */ @Nullable private static String toRegionLabel(List sidoNames, long totalSidoCount) { diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java index 29fe417..f621a02 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java @@ -37,6 +37,21 @@ public class PolicyMapper { /** aplyPrdSeCd 상시 코드 — 신청기간 파싱 대상 아님 */ private static final String ALWAYS_OPEN_CODE = "0057002"; + /** + * 대분류(lclsfNm) 표준 5분류 매핑 — 키는 가운뎃점(·・・) 제거 기준 (#80). 원본에는 반각점(U+FF65) 구분자, 콤마 다중값('일자리,일자리'), + * 구명칭('참여권리')이 섞여 있다. 기존 데이터는 V8 마이그레이션이 같은 규칙으로 정리한다. + */ + private static final Map CATEGORY_BY_STRIPPED = + Map.ofEntries( + Map.entry("일자리", "일자리"), + Map.entry("주거", "주거"), + Map.entry("교육", "교육·직업훈련"), + Map.entry("교육직업훈련", "교육·직업훈련"), + Map.entry("복지문화", "금융·복지·문화"), + Map.entry("금융복지문화", "금융·복지·문화"), + Map.entry("참여권리", "참여·기반"), + Map.entry("참여기반", "참여·기반")); + private static final DateTimeFormatter YMD = DateTimeFormatter.BASIC_ISO_DATE; private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -53,7 +68,7 @@ public Policy toEntity(YouthPolicyItem item) { trimToNull(item.plcyExplnCn()), trimToNull(item.plcySprtCn()), trimToNull(item.plcyKywdNm()), - trimToNull(item.lclsfNm()), + normalizeCategory(plcyNo, item.lclsfNm()), trimToNull(item.mclsfNm()), organizationName(item), toInt(plcyNo, "sprtTrgtMinAge", item.sprtTrgtMinAge()), @@ -126,6 +141,21 @@ public List resolveRegions( return regions; } + /** 대분류 정규화: 콤마 다중값은 첫 값, 가운뎃점 변형 통일 후 표준 5분류로. 표준 외 값은 경고 + 원본 유지(조용히 사라지는 것 방지). */ + String normalizeCategory(String plcyNo, String lclsfNm) { + String raw = trimToNull(lclsfNm); + if (raw == null) { + return null; + } + String first = raw.split(",")[0].trim(); + String canonical = CATEGORY_BY_STRIPPED.get(first.replaceAll("[·・・]", "")); + if (canonical == null) { + log.warn("정책 {} 미지의 대분류 '{}' — 원본 유지 (분류 매핑표 갱신 필요)", plcyNo, first); + return first; + } + return canonical; + } + private record ApplyPeriod(LocalDate start, LocalDate end) {} /** "yyyyMMdd ~ yyyyMMdd" 파싱. 상시(0057002)·빈값은 스킵(경고 없음), 형식 이상은 경고 + null. */ diff --git a/src/main/resources/db/migration/V8__normalize_policy_category.sql b/src/main/resources/db/migration/V8__normalize_policy_category.sql new file mode 100644 index 0000000..691d414 --- /dev/null +++ b/src/main/resources/db/migration/V8__normalize_policy_category.sql @@ -0,0 +1,19 @@ +-- 정책 대분류(category) 정규화 (#80) +-- 원본(온통청년 lclsfNm)에 콤마 다중값('일자리,일자리'), 반각 가운뎃점(U+FF65 '・'), 구명칭('참여권리')이 +-- 섞여 있어 표준 5분류(일자리 / 주거 / 교육·직업훈련 / 금융·복지·문화 / 참여·기반, 구분자 U+00B7)로 통일한다. +-- 신규 수집분은 sync PolicyMapper.normalizeCategory가 같은 규칙으로 저장 전에 정규화한다. + +-- 1) 콤마 다중값은 첫 값만 남긴다 +UPDATE policies +SET category = TRIM(SUBSTRING_INDEX(category, ',', 1)) +WHERE category LIKE '%,%'; + +-- 2) 가운뎃점 변형(U+FF65 '・', U+30FB '・')을 표준 U+00B7('·')로 통일 +UPDATE policies +SET category = REPLACE(REPLACE(category, '・', '·'), '・', '·') +WHERE category IS NOT NULL; + +-- 3) 구명칭·무점 표기를 표준 명칭으로 +UPDATE policies SET category = '교육·직업훈련' WHERE category IN ('교육', '교육직업훈련'); +UPDATE policies SET category = '금융·복지·문화' WHERE category IN ('복지문화', '복지·문화', '금융복지문화'); +UPDATE policies SET category = '참여·기반' WHERE category IN ('참여권리', '참여·권리', '참여기반'); diff --git a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java index 0661bdd..2c5c154 100644 --- a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java @@ -58,6 +58,7 @@ class AdminPolicyServiceTest { @BeforeEach void setUp() { + adminPolicyService = new AdminPolicyService(policyRepository, policyRegionRepository, regionRepository); } diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index b01ac61..38788ca 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -138,6 +139,12 @@ void setUp() { when(policyRepository.findCards( eq(PolicyVisibility.VISIBLE), any(LocalDate.class), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), pageableCaptor.capture())) .thenReturn( new PageImpl<>( @@ -156,7 +163,8 @@ void setUp() { newPolicyRegion(nationwide, "27110", "대구광역시", "중구"))); when(regionRepository.countDistinctSidoNames()).thenReturn(3L); - Page page = policyService.getCards(PageRequest.of(0, 20)); + Page page = + policyService.getCards(null, null, null, null, null, PageRequest.of(0, 20)); assertThat(pageableCaptor.getValue().getSort()) .isEqualTo(Sort.by(Sort.Direction.DESC, "id")); @@ -169,6 +177,73 @@ void setUp() { .containsExactly("서울특별시", null, "부산광역시 외 1", "전국"); } + @Test + void 검색어의_LIKE_특수문자를_이스케이프해_전달한다() { + ArgumentCaptor keywordCaptor = ArgumentCaptor.forClass(String.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + keywordCaptor.capture(), + isNull(), + isNull(), + isNull(), + isNull(), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + when(regionRepository.countDistinctSidoNames()).thenReturn(16L); + + policyService.getCards(null, "50% 지원_special!", null, null, null, PageRequest.of(0, 20)); + + assertThat(keywordCaptor.getValue()).isEqualTo("%50!% 지원!_special!!%"); + } + + @Test + void region이_전국이면_시도명_대신_전_시도_커버_조건으로_필터한다() { + ArgumentCaptor sidoNameCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor totalSidoCountCaptor = ArgumentCaptor.forClass(Long.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + sidoNameCaptor.capture(), + totalSidoCountCaptor.capture(), + isNull(), + isNull(), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + when(regionRepository.countDistinctSidoNames()).thenReturn(16L); + + policyService.getCards(null, null, "전국", null, null, PageRequest.of(0, 20)); + + assertThat(sidoNameCaptor.getValue()).isNull(); + assertThat(totalSidoCountCaptor.getValue()).isEqualTo(16L); + } + + @Test + void age_구간을_그대로_ageMin_ageMax_파라미터로_전달한다() { + ArgumentCaptor ageMinCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor ageMaxCaptor = ArgumentCaptor.forClass(Integer.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + isNull(), + isNull(), + ageMinCaptor.capture(), + ageMaxCaptor.capture(), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + when(regionRepository.countDistinctSidoNames()).thenReturn(16L); + + policyService.getCards(null, null, null, 19, 34, PageRequest.of(0, 20)); + + assertThat(ageMinCaptor.getValue()).isEqualTo(19); + assertThat(ageMaxCaptor.getValue()).isEqualTo(34); + } + private Policy newPolicy(Long id, String title) { Policy policy = BeanUtils.instantiateClass(Policy.class); ReflectionTestUtils.setField(policy, "id", id); diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java index be54fb2..0cc7d2c 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java @@ -44,6 +44,17 @@ private YouthPolicyItem item(String json) throws IOException { return objectMapper.readValue(json, YouthPolicyItem.class); } + @Test + @DisplayName("대분류 정규화: 반각점·콤마다중·구명칭은 표준 5분류로, 미지의 값은 원본 유지, 빈값은 null (#80)") + void normalizesCategoryVariants() { + assertThat(mapper.normalizeCategory("P1", "일자리")).isEqualTo("일자리"); + assertThat(mapper.normalizeCategory("P1", "교육・직업훈련")).isEqualTo("교육·직업훈련"); + assertThat(mapper.normalizeCategory("P1", "금융・복지・문화,금융・복지・문화")).isEqualTo("금융·복지·문화"); + assertThat(mapper.normalizeCategory("P1", "참여권리")).isEqualTo("참여·기반"); + assertThat(mapper.normalizeCategory("P1", "신규분류")).isEqualTo("신규분류"); + assertThat(mapper.normalizeCategory("P1", " ")).isNull(); + } + @Test @DisplayName("A: 정상 정책은 45개 필드 전부가 규칙대로 매핑된다 (파라미터 순서 실수 감지)") void mapsAllFieldsFromNormalItem() throws IOException { From 7d89e00623fc60ef124795a8155a5d906e3a873d Mon Sep 17 00:00:00 2001 From: canfactor Date: Mon, 20 Jul 2026 12:05:02 +0900 Subject: [PATCH 37/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC(PolicyApplication)=20+=20=EC=B2=B4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8(ApplicationChecklist)=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 정책 관리(PolicyApplication) + 체크리스트(ApplicationChecklist) 구현 엔티티만 있던 신청관리(PolicyApplication)와 체크리스트 (ApplicationChecklist)에 Controller/Service/Repository/DTO를 추가한다. - PolicyApplication: register/changeStatus/reactivate/delete 상태변경 메서드, UNIQUE(user, policy) 충돌 시 soft-delete 행 재활성화 지원 - ApplicationChecklist: check/uncheck/delete 상태변경 메서드 - PolicyErrorCode에 MANAGEMENT_NOT_FOUND(P004), CHECKLIST_NOT_FOUND(P005) 추가 - POST/GET/PATCH/DELETE /api/managements, /api/checkpoints 엔드포인트 - 단위/MockMvc 테스트 추가 Closes #24 * fix: CodeRabbit 지적사항 반영 (Pageable, validation, 동시성) - getManagements/getByManagement를 List 대신 Pageable/Page로 변경 (api-design.md 목록 API 규칙 준수) - RegisterManagementRequest.memo에 @Size(max=500) validation 추가 - PolicyManagementService.register 동시 등록 시 UNIQUE 제약 위반을 POLICY_ALREADY_EXISTS로 변환 (OnboardingService와 동일한 패턴) - 소유권/인가 검증은 프로젝트 전체에 인증이 아직 없어(OnboardingController와 동일한 상태) TODO 주석으로 남기고 이번 PR 범위에서는 보류 * refactor: 체크리스트 관련 클래스명을 PolicyManagementCheckpoint* 로 통일 - CheckPointController -> PolicyManagementCheckpointController - CheckPointService -> PolicyManagementCheckpointService - ApplicationChecklistRepository -> PolicyApplicationChecklistRepository - CreateCheckPointRequest -> CreateCheckpointRequest, CheckPointMessageResponse -> CheckpointMessageResponse (Checkpoint 표기 통일) * test: CheckPoint uncheck/delete의 CHECKLIST_NOT_FOUND 케이스 보강 check에만 있던 대상 없음 실패 케이스를 uncheck/delete에도 추가한다. (CodeRabbit nitpick 반영, PR #25) * chore: spotless 포맷 위반 수정 CI(Format Check)가 지적한 줄바꿈 스타일 위반을 spotlessApply로 정리한다. 동작 변경 없음. * test: 관리/체크포인트 컨트롤러 테스트에 meta 필드 및 service 호출 검증 보강 - getManagements/getByManagement: meta.page, meta.totalPages 검증 추가 - changeStatus/delete/check/uncheck: verify로 service 메서드 호출 검증 추가 (CodeRabbit nitpick 반영, PR #25) * fix: dev의 ApiResponse.ok(data, Page) 시그니처 변경에 맞춰 컨트롤러 정리 dev에 병합된 ApiResponse meta(Map -> PageMeta record) 변경에 맞춰 PolicyManagementController/PolicyManagementCheckpointController의 목록 조회가 Map.of(...) 대신 ApiResponse.ok(data, page)를 사용하도록 수정한다. 동작(응답 JSON 필드)은 동일하다. * fix: 코드 리뷰 지적사항 반영 (changeStatus 검증, 체크리스트 보호, N+1, 재등록 정리) - changeStatus를 enum 직접 바인딩 대신 String + @Pattern + @Validated로 변경하여 잘못된 status 값이 500이 아닌 400/C001로 응답하도록 수정 (register와 동일한 패턴으로 통일) - PolicyManagementCheckpointService: 부모 PolicyApplication이 soft-delete된 체크리스트는 check/uncheck/delete 시 CHECKLIST_NOT_FOUND를 던지도록 수정, getByManagement도 대상 신청관리가 없으면 MANAGEMENT_NOT_FOUND를 던지도록 보강 - PolicyApplicationRepository.findByUser_IdAndDeletedAtIsNull에 @EntityGraph(attributePaths = "policy") 추가하여 목록 조회 N+1 해소 - PolicyManagementService.register: soft-delete된 신청을 재활성화할 때 이전 체크리스트를 벌크 UPDATE(@Modifying)로 소프트딜리트하여 새 사이클을 깨끗하게 시작하도록 수정. flushAutomatically로 재활성화 변경사항이 먼저 flush되도록 보장(로컬 실행으로 검증) - 테스트 클래스 CheckPointServiceTest/CheckPointControllerTest를 PolicyManagementCheckpointServiceTest/PolicyManagementCheckpointControllerTest로 리네이밍(7c3f08f 프로덕션 클래스명 리네이밍에 맞춤), 관련 케이스 보강 Co-Authored-By: Claude Sonnet 5 * chore: 실제 파일이 채워진 entity 디렉토리의 잔재 .gitkeep 제거 Co-Authored-By: Claude Sonnet 5 * refactor: 체크포인트 DTO 클래스명을 PolicyManagementCheckpoint* 로 통일 CheckpointMessageResponse/CreateCheckpointRequest만 PolicyManagement 접두어가 빠져있어 컨트롤러/서비스 네이밍(PolicyManagementCheckpoint*)과 일관성이 없었다. Co-Authored-By: Claude Sonnet 5 * refactor: PolicyManagement/Checkpoint 네이밍을 ERD 기준(PolicyApplication/Checklist)으로 통일 - PolicyManagement* -> PolicyApplication* : DB 테이블(policy_applications) 및 Entity(PolicyApplication)와 일치시킴 - *Checkpoint* -> *Checklist* : DB 테이블(policy_application_checklists) 및 Entity(ApplicationChecklist)와 일치시킴 - API 경로 /api/checkpoints -> /api/checklists (같이 변경됨, 리뷰 전이라 영향 없음) - ApplicationChecklistResponse의 policyManagementId 필드도 policyApplicationId로 변경 Co-Authored-By: Claude Sonnet 5 * refactor: Entity/DTO/에러코드의 management 표기를 ERD 기준(application)으로 통일 - ApplicationChecklist -> PolicyApplicationChecklist (Entity), ApplicationChecklistResponse -> PolicyApplicationChecklistResponse (DTO): policy_application_checklists 테이블과 일치 - RegisterManagementRequest -> RegisterPolicyApplicationRequest - PolicyErrorCode.MANAGEMENT_NOT_FOUND -> POLICY_APPLICATION_NOT_FOUND - API 경로 /api/managements -> /api/applications, /api/checklists/management/{id} -> /api/checklists/application/{id} - getManagements/getByManagement -> getApplications/getByApplication - managementId 파라미터 -> applicationId Co-Authored-By: Claude Sonnet 5 * fix: 리네이밍 과정에서 생긴 필드명 중복 접두어(policyPolicyApplication...) 수정 sed 기반 일괄 치환(ApplicationChecklist -> PolicyApplicationChecklist) 과정에서 policyApplicationChecklistRepository 필드명이 policyPolicyApplicationChecklistRepository로 겹쳐 치환된 것을 발견하여 수정 (code-reviewer 재검토로 발견). Co-Authored-By: Claude Sonnet 5 * fix: 정책 신청/체크리스트 API에 소유권 검증(IDOR) 추가 dev에 JWT 인증(#22)이 도입되어 계속 보류해왔던 소유권 검증을 반영한다. - RegisterPolicyApplicationRequest에서 userId 필드 제거 (JWT에서 받도록) - 컨트롤러 전 엔드포인트를 @RequestParam/@PathVariable userId 대신 @CurrentUser Long userId로 전환 - PolicyApplicationService.changeStatus/delete, PolicyApplicationChecklistService의 add/check/uncheck/delete/getByApplication에 소유자 검증(verifyOwner) 추가. 불일치 시 AuthErrorCode.FORBIDDEN(403) - SecurityConfig에 /api/applications/**, /api/checklists/** 인증 필수 경로 등록 (기존엔 anyRequest().permitAll()에 걸려 인증 자체가 없었음) - 관련 테스트 전체를 새 시그니처로 수정하고, 소유자 불일치(FORBIDDEN) 케이스 추가 - security-review 스킬로 점검 완료 (critical/high/medium 없음) Co-Authored-By: Claude Sonnet 5 * feat: 정책 신청 관리 memo/마감일 수정 API 추가 - PolicyApplication에 updateDetails(memo, endAt) 추가 (status는 그대로 유지) - UpdatePolicyApplicationRequest 신설 (memo @Size(max=500), endAt) - PolicyApplicationService.updateDetails: changeStatus/delete와 동일하게 findActive + verifyOwner(소유권 검증)를 거친 뒤 수정 - PATCH /api/applications/{id}에서 memo/마감일을 함께 수정 - GlobalExceptionHandler.handleException이 예상 못한 예외를 로그에 전혀 남기지 않던 문제를 함께 수정 (log.error 추가, 응답 바디/상태코드는 그대로) - 관련 컨트롤러/서비스 테스트에 성공·검증실패·대상없음·타인소유 케이스 추가 Co-Authored-By: Claude Sonnet 5 * refac: memo/마감일 수정 API를 필드별 PATCH로 분리하고 memo 제한 2000자로 확대 - 프론트(front-end 레포) tracker 기능 확인 결과, 상태/마감일/메모 저장이 각각 독립된 액션으로 동작함 -> 통합 PATCH(생략 필드 null 처리)는 한쪽만 바꿀 때 다른 필드를 지워버리는 위험이 있어 필드별로 재설계 - PATCH /api/applications/{id} (memo+endAt 통합) 제거 - PATCH /api/applications/{id}/memo 신규: query param memo, @Size(max=2000) (생략 시 빈 문자열) - PATCH /api/applications/{id}/end-at 신규: query param endAt (생략 시 null로 마감일 초기화) - UpdatePolicyApplicationRequest DTO 제거 (더 이상 불필요) - RegisterPolicyApplicationRequest.memo 제한 500 -> 2000자 (프론트 TrackerMemoEditor의 maxLength=2000에 맞춤) - 관련 컨트롤러/서비스 테스트 재작성 (경계값 2000/2001자, 소유권, 대상없음 케이스 포함) 실서버 기동 후 curl로 등록 -> memo단독수정(endAt유지) -> end-at단독수정(memo유지) -> end-at생략(null초기화) -> 2000/2001자 경계값 -> 타인소유(403) -> 대상없음(404) -> 기존 /status 회귀까지 전부 확인함 Co-Authored-By: Claude Sonnet 5 * feat: ApplicationStatus에 PREPARING(준비중) 상태 추가 - 프론트(front-end 레포) tracker/admin 기능이 관심/준비중/신청완료/종료 4단계 상태 모델을 이미 두 화면(사용자 tracker, 관리자 뷰어)에서 일관되게 사용 중인 것을 확인 (Issue #46) - 기존 INTERESTED/APPLIED/COMPLETED 이름은 그대로 두고 PREPARING만 순수 추가 (리네이밍 없음, DB는 @Enumerated(STRING)이라 마이그레이션 불필요) - register/changeStatus의 @Pattern 정규식에 PREPARING 추가 - 등록/상태변경 테스트에 PREPARING 케이스 추가 실서버 기동 후 curl로 PREPARING 등록 -> APPLIED 변경 -> PREPARING 재변경 -> 잘못된 값 여전히 거부(회귀)까지 확인함 Co-Authored-By: Claude Sonnet 5 * fix: 페이지네이션을 1-based로 통일 (프론트 페이지 번호 기준과 정렬) - 프론트(front-end 레포) Pagination 컴포넌트/usePagination/admin 목록이 전부 1-based(page=1이 첫 페이지)로 동작하는 반면, Spring Data Pageable 기본값은 0-based라 그대로 연동하면 요청한 페이지보다 한 칸 밀린 데이터를 받게 되는 문제를 확인 - application.yml(main+test)에 spring.data.web.pageable.one-indexed- parameters=true 추가 -> 요청 파라미터 page를 1-based로 변환 - PageMeta.from()도 page.getNumber()+1로 응답을 1-based로 통일 (요청만 1-based로 바꾸고 응답은 그대로 두면 meta.page가 여전히 0-based라 프론트가 "현재 페이지" 표시에 그대로 쓸 때 어긋남) - ApiResponseTest/PolicyApplicationControllerTest/ PolicyApplicationChecklistControllerTest의 meta.page 기대값 갱신 실서버로 신청 3건 등록 후 page=1&size=2 -> 앞 2건(meta.page=1), page=2&size=2 -> 나머지 1건(meta.page=2), page=3 -> 빈 목록(meta.page=3) 까지 curl로 확인함 Co-Authored-By: Claude Sonnet 5 * fix: 잘못된 입력값이 500으로 새는 경로들을 400/C001로 정정 code-reviewer 서브에이전트로 이번 세션 diff를 엄격 검토한 결과 발견: - PATCH /{id}/end-at, /{id}/status에 잘못된 값/형식을 보내거나 필수 파라미터를 생략하면 GlobalExceptionHandler가 못 잡아서 500(S001)으로 응답하던 문제 -> MethodArgumentTypeMismatchException, MissingServletRequestParameterException 핸들러 추가로 400/C001 처리 - POST /api/applications의 JSON body에서 endAt이 잘못된 형식이면 HttpMessageNotReadableException이 마찬가지로 500으로 새던 문제 (세션 초반 겪었던 것과 동일 계열의 예외) -> 핸들러 추가로 400/C001 처리 - PATCH /{id}/memo에서 memo 파라미터를 생략하면 조용히 빈 문자열로 초기화되던 문제 -> defaultValue 제거, status와 동일하게 필수 파라미터로 전환 (비우려면 명시적으로 memo= 빈 문자열을 보내야 함) - 관련 회귀 테스트 5개 추가 (memo 생략/빈문자열, end-at 형식오류, register endAt 형식오류, status 파라미터 생략) 실서버 기동 후 위 케이스 전부 curl로 400/C001 정상 전환 확인함 Co-Authored-By: Claude Sonnet 5 * docs: PREPARING 반영한 스키마 코멘트 마이그레이션 추가, end-at 생략 동작 문서화 2차 엄격 리뷰(다른 각도로 재검토) 결과 반영: - V1의 policy_applications.status 컬럼 코멘트가 PREPARING 추가(1df0002) 이후에도 예전 3단계 값만 나열해 스키마 문서가 실제와 어긋나 있던 것을 V3 마이그레이션으로 갱신 (CHECK 제약 없어 데이터 마이그레이션 불필요) - end-at을 memo처럼 필수 파라미터로 통일하려 했으나, LocalDateTime 같은 non-String @RequestParam은 Spring이 "생략"과 "빈 문자열"을 바인딩 단계에서 이미 null로 합쳐버려 필수로 만들면 "마감일 지우기" 자체가 막힌다는 것을 직접 실험으로 확인 -> 되돌리고 왜 memo와 다르게 두는지 주석으로 명시 - page=0/음수 페이지가 에러 없이 첫 페이지로 보정되는 동작을 회귀 테스트로 고정 실서버로 등록 -> end-at 생략/빈문자열 둘 다 초기화 -> memo 생략은 여전히 거부(400) -> path variable 비숫자(400) -> page=0 보정까지 통합 재검증 완료 Co-Authored-By: Claude Sonnet 5 * test: 체크리스트 API 테스트 커버리지 보강 (경계값/삭제된 상위 신청관리) 4차 엄격 리뷰 결과 반영 — 오늘 처음으로 체크리스트 기능 전체를 origin/dev 기준 diff로 검토(기존 로컬 dev가 오래돼 놓치고 있었음). critical/high 문제는 없었고 테스트 커버리지 누락 2건만 발견: - getByApplication/check/delete에 path variable이 숫자가 아닐 때 400/C001인지 확인하는 테스트 추가 - uncheck/delete에 "상위 신청관리가 소프트 삭제된 경우 CHECKLIST_NOT_FOUND" 케이스 추가 (기존엔 check에만 있었음) Co-Authored-By: Claude Sonnet 5 * fix: 체크리스트 조회 순서를 id 오름차순으로 명시적으로 고정 findByApplication_IdAndDeletedAtIsNull이 정렬 조건 없이 조회해서 등록 순서가 우연히(MySQL PK 조회 특성상) 유지되는 것일 뿐 SQL 스펙상 보장되지 않았음. 메서드명에 OrderByIdAsc를 추가해 정렬을 코드 레벨에서 명시적으로 보장한다. * feat: 로컬 개발용 샘플 정책 시드 데이터 추가 (V4 마이그레이션) 프론트 mock 정책 카탈로그(policyMockData.ts)의 id를 실제 policies.id와 맞춰 관심 등록(policy_applications)이 로컬에서 끝까지 동작하도록, policies 테이블에 동일한 제목의 샘플 14건을 시드한다. 정식 온통청년 동기화 배치가 구현되기 전까지의 임시 로컬 테스트용 데이터다. * chore: docker-compose에 Adminer 서비스 추가 로컬 DB 조회용 웹 UI(Adminer, http://localhost:8081)를 docker-compose에 추가한다. * feat: 신청관리 체크리스트 메모 수정, 마감일 정책 검증, API 경로 확정 - 체크리스트 항목 내용(메모) 수정 API 추가 (PATCH /api/v1/policy-application-checklists/{id}) - 개인 마감일이 정책 자체의 신청 마감일을 넘지 않도록 검증 (P006)하고, 마감일 미지정 시 정책 마감일을 기본값으로 사용하도록 보강 - 메모가 공백/빈 문자열이면 null로 정규화해서 저장 - 신청관리/체크리스트 컨트롤러 경로를 명세(docs/api-spec.md)와 일치하도록 확정 (/api/applications, /api/checklists → /api/v1/policy-applications, /api/v1/policy-application-checklists) - 위 변경에 대한 컨트롤러/서비스 테스트 보강 * Revert "chore: docker-compose에 Adminer 서비스 추가" This reverts commit 529e5c951c69ce8514f06b67107e4d67267059ba. * refac: 정책 신청관리 리뷰 주석 정리 및 소유권 검증/상태값 처리 개선 - policy-application, checklist 관련 파일에 계층 간 연결고리를 명시하는 리뷰 주석 추가 - PolicyApplicationService/PolicyApplicationChecklistService에 중복돼 있던 소유권 검증 로직을 PolicyApplication.verifyOwner()로 통합 - ApplicationStatus 파싱 실패 시 500 대신 400을 반환하도록 PolicyErrorCode.INVALID_APPLICATION_STATUS(P007)와 PolicyApplicationController.parseStatus() 추가 Co-Authored-By: Claude Sonnet 5 * chore: spotless 포맷 위반 수정 (merge conflict 해결 과정에서 손으로 편집한 파일) CI의 Format Check(spotless) 실패 수정. --------- Co-authored-by: Claude Sonnet 5 --- .gitignore | 6 + docs/api-spec.md | 145 ++++- .../dto/ApplicationChecklistItemResponse.java | 4 +- .../AdminPolicyApplicationService.java | 7 +- .../bop/youthpick/global/common/PageMeta.java | 9 +- .../global/config/SecurityConfig.java | 5 + .../global/error/GlobalExceptionHandler.java | 49 +- .../bop/youthpick/policy/controller/.gitkeep | 0 .../PolicyApplicationChecklistController.java | 112 ++++ .../PolicyApplicationController.java | 153 +++++ .../com/bop/youthpick/policy/dto/.gitkeep | 0 ...cyApplicationChecklistMessageResponse.java | 7 + .../PolicyApplicationChecklistResponse.java | 28 + ...licyApplicationCreateChecklistRequest.java | 17 + .../dto/PolicyApplicationRegisterRequest.java | 33 + .../policy/dto/PolicyApplicationResponse.java | 42 ++ ...licyApplicationUpdateChecklistRequest.java | 14 + .../com/bop/youthpick/policy/entity/.gitkeep | 0 .../policy/entity/ApplicationChecklist.java | 49 -- .../policy/entity/ApplicationStatus.java | 14 +- .../policy/entity/PolicyApplication.java | 72 ++- .../entity/PolicyApplicationChecklist.java | 88 +++ .../policy/exception/PolicyErrorCode.java | 22 +- .../ApplicationChecklistRepository.java | 10 - .../PolicyApplicationChecklistRepository.java | 36 ++ .../PolicyApplicationRepository.java | 21 +- .../com/bop/youthpick/policy/service/.gitkeep | 0 .../PolicyApplicationChecklistService.java | 112 ++++ .../service/PolicyApplicationService.java | 202 ++++++ .../com/bop/youthpick/user/entity/.gitkeep | 0 src/main/resources/application.yml | 6 + ...ate_policy_applications_status_comment.sql | 10 + .../V4__seed_local_sample_policies.sql | 23 + .../AdminPolicyApplicationServiceTest.java | 14 +- .../global/common/ApiResponseTest.java | 4 +- ...icyApplicationChecklistControllerTest.java | 255 ++++++++ .../PolicyApplicationControllerTest.java | 422 +++++++++++++ ...cyApplicationChecklistRepositoryTest.java} | 34 +- ...PolicyApplicationChecklistServiceTest.java | 354 +++++++++++ .../service/PolicyApplicationServiceTest.java | 592 ++++++++++++++++++ src/test/resources/application.yml | 3 + 41 files changed, 2855 insertions(+), 119 deletions(-) delete mode 100644 src/main/java/com/bop/youthpick/policy/controller/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java create mode 100644 src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java delete mode 100644 src/main/java/com/bop/youthpick/policy/dto/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistMessageResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationUpdateChecklistRequest.java delete mode 100644 src/main/java/com/bop/youthpick/policy/entity/.gitkeep delete mode 100644 src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java delete mode 100644 src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java delete mode 100644 src/main/java/com/bop/youthpick/policy/service/.gitkeep create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java delete mode 100644 src/main/java/com/bop/youthpick/user/entity/.gitkeep create mode 100644 src/main/resources/db/migration/V3__update_policy_applications_status_comment.sql create mode 100644 src/main/resources/db/migration/V4__seed_local_sample_policies.sql create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java rename src/test/java/com/bop/youthpick/policy/repository/{ApplicationChecklistRepositoryTest.java => PolicyApplicationChecklistRepositoryTest.java} (71%) create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java diff --git a/.gitignore b/.gitignore index f399d1c..b074398 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ out/ ### Claude Code local ### CLAUDE.local.md .claude/settings.local.json + +### Local run logs ### +*.log + +### Local-only seed data (see comment in file) ### +data-local.sql diff --git a/docs/api-spec.md b/docs/api-spec.md index 5e786fd..a942182 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -13,6 +13,7 @@ Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 A | 사용자별 엔티티 경로 | `/me`, `/users/{userId}` 없이 엔티티 리소스명 사용 | | 프로필 엔티티 | `/api/v1/user-profiles` | | 정책 신청관리 엔티티 | `/api/v1/policy-applications` | +| 정책 신청관리 체크리스트 엔티티 | `/api/v1/policy-application-checklists` | | 정책 배치 이력 엔티티 | `/api/v1/policy-batch-histories` | | 챗봇 API | FastAPI 직접 호출 경로이므로 Spring Boot 구현 대상과 분리 | @@ -33,10 +34,18 @@ Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 A | 정책 검색 | 검색어 제안 | `GET` | `/api/v1/policies/search-suggestions` | 비회원 | query: keyword 선택 | | 정책 검색 | 정책 상세 조회 | `GET` | `/api/v1/policies/{policyId}` | 비회원 | path: policyId | | 맞춤 추천 | 맞춤정책 조회 | `GET` | `/api/v1/recommended-policies` | 회원 | query: region 선택, category 선택, keyword 선택 | +| 신청관리 | 신청 등록 | `POST` | `/api/v1/policy-applications` | 회원 | body: policyId, status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED), memo 선택, endAt 선택 | +| 신청관리 | 신청 목록 조회 | `GET` | `/api/v1/policy-applications` | 회원 | query: page 기본 1, size 기본 20 | +| 신청관리 | 상태 변경 | `PATCH` | `/api/v1/policy-applications/{id}/status` | 회원 | path: id. query: status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED) | +| 신청관리 | 메모 수정 | `PATCH` | `/api/v1/policy-applications/{id}/memo` | 회원 | path: id. query: memo (필수, 최대 2000자) | +| 신청관리 | 마감일 수정 | `PATCH` | `/api/v1/policy-applications/{id}/end-at` | 회원 | path: id. query: endAt 선택(ISO-8601 date-time, 생략 시 마감일 초기화) | +| 신청관리 | 신청 삭제 | `DELETE` | `/api/v1/policy-applications/{id}` | 회원 | path: id | +| 신청관리 | 체크리스트 추가 | `POST` | `/api/v1/policy-application-checklists` | 회원 | body: applicationId, message | +| 신청관리 | 체크리스트 조회 | `GET` | `/api/v1/policy-application-checklists/application/{applicationId}` | 회원 | path: applicationId. query: page 기본 1, size 기본 20 | +| 신청관리 | 체크리스트 체크 | `PATCH` | `/api/v1/policy-application-checklists/{id}/check` | 회원 | path: id | +| 신청관리 | 체크리스트 체크 해제 | `PATCH` | `/api/v1/policy-application-checklists/{id}/uncheck` | 회원 | path: id | +| 신청관리 | 체크리스트 삭제 | `DELETE` | `/api/v1/policy-application-checklists/{id}` | 회원 | path: id | | 최근 본 정책 | 최근 본 정책 목록 | `GET` | `/api/v1/policy-recent-views` | 회원 | query: page 기본 0, size 기본 20 | -| 관심 정책 | 관심정책 목록 | `GET` | `/api/v1/policy-applications` | 회원 | 없음 | -| 관심 정책 | 관심정책 등록 | `PUT` | `/api/v1/policy-applications/{policyId}` | 회원 | path: policyId | -| 관심 정책 | 관심정책 해제 | `DELETE` | `/api/v1/policy-applications/{policyId}` | 회원 | path: policyId | | 읽음 상태 | 읽음 상태 목록 조회 | `GET` | `/api/v1/policy-read-states` | 회원 | query: policyIds 반복 | | 읽음 상태 | 정책 읽음 처리 | `PUT` | `/api/v1/policy-read-states/{policyId}` | 회원 | path: policyId | | 정책 비교 | 정책 비교 생성 | `POST` | `/api/v1/policy-comparisons` | 비회원 | body: policyIds[] | @@ -199,53 +208,143 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 | 권한 | 회원 | | 파라미터 | query: region 선택, category 선택, keyword 선택 | -## 최근 본 정책 +## 신청관리 -### 최근 본 정책 목록 +기존 즐겨찾기(관심 정책) 개념을 흡수한 정책 신청관리 엔티티다. 상태(`ApplicationStatus`: `INTERESTED`/`PREPARING`/`APPLIED`/`COMPLETED`)·메모·마감일과 하위 체크리스트를 함께 관리한다. -로그인 사용자가 최근에 상세 조회한 정책 목록을 마지막 조회 시각 내림차순으로 조회한다. 같은 정책을 다시 보면 기록이 늘어나지 않고 조회 시각만 갱신된다. 삭제·숨김 처리된 정책은 목록에서 제외된다. +### 신청 등록 + +정책을 신청관리 목록에 등록한다. 이미 등록된(soft-delete 되지 않은) 항목이면 `P002` 충돌 에러를 반환하고, soft-delete된 항목이면 재활성화하면서 기존 체크리스트를 초기화한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/policy-applications` | +| 권한 | 회원 | +| 파라미터 | body: policyId, status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED), memo 선택, endAt 선택 | + +### 신청 목록 조회 + +로그인 사용자의 신청관리 목록을 조회한다. | 항목 | 내용 | |---|---| | 메서드 | `GET` | -| 경로 | `/api/v1/policy-recent-views` | +| 경로 | `/api/v1/policy-applications` | | 권한 | 회원 | -| 파라미터 | query: page 기본 0, size 기본 20 | +| 파라미터 | query: page 기본 1, size 기본 20 | + +### 상태 변경 + +신청관리 항목의 상태를 변경한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PATCH` | +| 경로 | `/api/v1/policy-applications/{id}/status` | +| 권한 | 회원 | +| 파라미터 | path: id. query: status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED) | + +### 메모 수정 + +신청관리 항목의 개인 메모를 수정한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PATCH` | +| 경로 | `/api/v1/policy-applications/{id}/memo` | +| 권한 | 회원 | +| 파라미터 | path: id. query: memo (필수, 최대 2000자) | + +### 마감일 수정 + +신청관리 항목의 마감일(endAt)을 수정한다. endAt을 생략하면 필수값 오류가 아니라 마감일 초기화(clear)로 처리한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PATCH` | +| 경로 | `/api/v1/policy-applications/{id}/end-at` | +| 권한 | 회원 | +| 파라미터 | path: id. query: endAt 선택(ISO-8601 date-time, 생략 시 마감일 초기화) | + +### 신청 삭제 + +신청관리 항목을 삭제(soft-delete)한다. -## 관심 정책 +| 항목 | 내용 | +|---|---| +| 메서드 | `DELETE` | +| 경로 | `/api/v1/policy-applications/{id}` | +| 권한 | 회원 | +| 파라미터 | path: id | + +### 체크리스트 추가 + +신청관리 항목에 체크리스트 항목을 추가한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `POST` | +| 경로 | `/api/v1/policy-application-checklists` | +| 권한 | 회원 | +| 파라미터 | body: applicationId, message (필수, 최대 500자) | -### 관심정책 목록 +### 체크리스트 조회 -로그인 사용자가 저장한 관심 정책 목록을 조회한다. +특정 신청관리 항목의 체크리스트 목록을 조회한다. | 항목 | 내용 | |---|---| | 메서드 | `GET` | -| 경로 | `/api/v1/policy-applications` | +| 경로 | `/api/v1/policy-application-checklists/application/{applicationId}` | | 권한 | 회원 | -| 파라미터 | 없음 | +| 파라미터 | path: applicationId. query: page 기본 1, size 기본 20 | -### 관심정책 등록 +### 체크리스트 체크 -특정 정책을 로그인 사용자의 관심 정책으로 저장한다. +체크리스트 항목을 체크 완료 상태로 표시한다. | 항목 | 내용 | |---|---| -| 메서드 | `PUT` | -| 경로 | `/api/v1/policy-applications/{policyId}` | +| 메서드 | `PATCH` | +| 경로 | `/api/v1/policy-application-checklists/{id}/check` | | 권한 | 회원 | -| 파라미터 | path: policyId | +| 파라미터 | path: id | -### 관심정책 해제 +### 체크리스트 체크 해제 -특정 정책을 로그인 사용자의 관심 정책 목록에서 제거한다. +체크리스트 항목의 체크를 해제한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PATCH` | +| 경로 | `/api/v1/policy-application-checklists/{id}/uncheck` | +| 권한 | 회원 | +| 파라미터 | path: id | + +### 체크리스트 삭제 + +체크리스트 항목을 삭제(soft-delete)한다. | 항목 | 내용 | |---|---| | 메서드 | `DELETE` | -| 경로 | `/api/v1/policy-applications/{policyId}` | +| 경로 | `/api/v1/policy-application-checklists/{id}` | | 권한 | 회원 | -| 파라미터 | path: policyId | +| 파라미터 | path: id | + +## 최근 본 정책 + +### 최근 본 정책 목록 + +로그인 사용자가 최근에 상세 조회한 정책 목록을 마지막 조회 시각 내림차순으로 조회한다. 같은 정책을 다시 보면 기록이 늘어나지 않고 조회 시각만 갱신된다. 삭제·숨김 처리된 정책은 목록에서 제외된다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policy-recent-views` | +| 권한 | 회원 | +| 파라미터 | query: page 기본 0, size 기본 20 | ## 읽음 상태 @@ -381,6 +480,6 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 사용자별 데이터는 JWT에서 얻은 사용자 식별자를 서비스 계층에서 사용한다. URL 경로는 `user-profiles`, `policy-applications`, `policy-read-states`처럼 엔티티 리소스를 직접 드러내고, 컨트롤러 내부에서 인증 principal과 매핑한다. -`policy_applications`는 기존 즐겨찾기 개념을 흡수한 신청관리 엔티티다. 관심정책 등록과 해제는 별도 `favorites` 리소스보다 `policy-applications`의 생성/삭제 또는 상태 변경으로 구현하는 편이 현재 스키마와 맞다. +`policy_applications`는 기존 즐겨찾기 개념을 흡수한 신청관리 엔티티다. 관심정책 등록·해제는 별도 `favorites` 리소스가 아니라 `policy-applications`의 생성/삭제와 상태 변경(`status`)으로 구현되어 있다. 신청관리 하위 체크리스트는 `policy_application_checklists` 엔티티로 별도 관리하며, 신청관리 삭제·재등록(soft-delete → reactivate) 시 체크리스트도 함께 초기화된다. `policy-batch-histories`는 정책 수집 작업 이력을 나타낸다. 수집 실행은 같은 컬렉션에 작업 요청을 생성하는 `POST /api/v1/policy-batch-histories`로 해석한다. diff --git a/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java index dcfba71..687c0db 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java @@ -1,6 +1,6 @@ package com.bop.youthpick.admin.policy.dto; -import com.bop.youthpick.policy.entity.ApplicationChecklist; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; import java.time.LocalDateTime; public record ApplicationChecklistItemResponse( @@ -9,7 +9,7 @@ public record ApplicationChecklistItemResponse( boolean checked, String description, LocalDateTime createdAt) { - public static ApplicationChecklistItemResponse from(ApplicationChecklist item) { + public static ApplicationChecklistItemResponse from(PolicyApplicationChecklist item) { return new ApplicationChecklistItemResponse( item.getId(), item.getApplication().getId(), diff --git a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java index 13bad11..f6119dc 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java @@ -7,7 +7,7 @@ import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.PolicyApplication; import com.bop.youthpick.policy.exception.PolicyErrorCode; -import com.bop.youthpick.policy.repository.ApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; import com.bop.youthpick.policy.repository.PolicyApplicationRepository; import java.time.LocalDate; import java.util.List; @@ -22,7 +22,7 @@ public class AdminPolicyApplicationService { private final PolicyApplicationRepository policyApplicationRepository; - private final ApplicationChecklistRepository applicationChecklistRepository; + private final PolicyApplicationChecklistRepository applicationChecklistRepository; @Transactional(readOnly = true) public Page search( @@ -44,7 +44,8 @@ public Page search( public List getChecklist(Long applicationId) { findApplication(applicationId); return applicationChecklistRepository - .findByApplicationIdOrderByIdAsc(applicationId) + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) .stream() .map(ApplicationChecklistItemResponse::from) .toList(); diff --git a/src/main/java/com/bop/youthpick/global/common/PageMeta.java b/src/main/java/com/bop/youthpick/global/common/PageMeta.java index c3db325..53b9a4a 100644 --- a/src/main/java/com/bop/youthpick/global/common/PageMeta.java +++ b/src/main/java/com/bop/youthpick/global/common/PageMeta.java @@ -2,9 +2,14 @@ import org.springframework.data.domain.Page; -/** 페이지네이션 부가정보. Map 대신 타입을 명시해 Swagger(OpenAPI) 문서에 필드 스키마가 그대로 드러나게 한다. */ +/** + * 페이지네이션 부가정보. Map 대신 타입을 명시해 Swagger(OpenAPI) 문서에 필드 스키마가 그대로 드러나게 한다. + * + *

{@code page}는 1부터 시작한다(1-based). {@link Page#getNumber()}는 Spring Data 내부 규약대로 0-based라 여기서 +1 + * 해서 응답 규약을 요청 파라미터(`spring.data.web.pageable.one-indexed-parameters=true`)와 맞춘다. + */ public record PageMeta(int page, long totalCount, int totalPages) { public static PageMeta from(Page page) { - return new PageMeta(page.getNumber(), page.getTotalElements(), page.getTotalPages()); + return new PageMeta(page.getNumber() + 1, page.getTotalElements(), page.getTotalPages()); } } diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 44c232f..d9a95a1 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -87,6 +87,11 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/v1/policy-recent-views", "/api/v1/policy-chat/profile-consent") .authenticated() + // 회원 전용 — 정책 신청관리(관심정책 흡수) + 체크리스트. + .requestMatchers( + "/api/v1/policy-applications/**", + "/api/v1/policy-application-checklists/**") + .authenticated() // 명세에 없는 나머지 경로(구현 중인 다른 도메인 등)는 개발 편의상 열어 둔다. .anyRequest() .permitAll()); diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index c5d8fc2..65be1e4 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -5,24 +5,25 @@ import java.util.List; import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; /** * 전역 예외 처리. 모든 컨트롤러 예외를 여기서 {@link ErrorResponse}로 변환한다. 컨트롤러는 try-catch로 에러 응답을 직접 만들지 않는다(여기로 * 위임). */ +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); - @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException exception) { @@ -64,6 +65,44 @@ public ResponseEntity handleConstraintViolation( .body(ErrorResponse.of(errorCode, details)); } + // 필수 @RequestParam이 누락된 경우 + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameter( + MissingServletRequestParameterException exception) { + ErrorResponse.FieldErrorDetail detail = + new ErrorResponse.FieldErrorDetail( + exception.getParameterName(), + "", + exception.getParameterName() + "는 필수입니다."); + + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, List.of(detail))); + } + + // @RequestParam 값이 대상 타입(LocalDateTime 등)으로 변환되지 않는 경우 + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatch( + MethodArgumentTypeMismatchException exception) { + ErrorResponse.FieldErrorDetail detail = + new ErrorResponse.FieldErrorDetail( + exception.getName(), + exception.getValue() == null ? "" : exception.getValue().toString(), + "유효하지 않은 형식입니다."); + + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, List.of(detail))); + } + + // 요청 body의 JSON을 파싱하지 못한 경우 (필드 타입 불일치, 깨진 JSON 등) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException exception) { + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); + } + // 우리가 정의하는 비즈니스 에러 @ExceptionHandler(CustomException.class) public ResponseEntity handleCustomException(CustomException exception) { @@ -74,7 +113,7 @@ public ResponseEntity handleCustomException(CustomException excep // 개발자가 예상치 못한 에러 — application_logs 테이블에 남도록 ERROR로 로깅한다. @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { - log.error("처리되지 않은 예외가 발생했습니다.", exception); + log.error("예상치 못한 서버 오류", exception); ErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } diff --git a/src/main/java/com/bop/youthpick/policy/controller/.gitkeep b/src/main/java/com/bop/youthpick/policy/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java new file mode 100644 index 0000000..8561636 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java @@ -0,0 +1,112 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.PolicyApplicationChecklistMessageResponse; +import com.bop.youthpick.policy.dto.PolicyApplicationChecklistResponse; +import com.bop.youthpick.policy.dto.PolicyApplicationCreateChecklistRequest; +import com.bop.youthpick.policy.dto.PolicyApplicationUpdateChecklistRequest; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import com.bop.youthpick.policy.service.PolicyApplicationChecklistService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 정책 신청관리(PolicyApplication) 하위의 준비 체크리스트(제출서류 등) HTTP 진입점. 모든 엔드포인트는 결국 + * PolicyApplicationChecklistService의 findActiveApplication()/findActive()를 거쳐, 대상 체크리스트가 (1)삭제되지 + * 않았고 (2)그 부모 {@link com.bop.youthpick.policy.entity.PolicyApplication}도 삭제되지 않았고 (3)요청자(userId) + * 소유인지를 확인한 뒤에만 동작한다 — 이 컨트롤러 자체엔 소유권/존재 검증이 없다. + * + *

이 클래스엔 {@code @Validated}가 없다 — PolicyApplicationController와 달리 여기 모든 검증은 + * {@code @Valid @RequestBody} DTO(Create/UpdateChecklistRequest)로만 이루어지고, {@code @RequestParam}에 직접 + * 붙는 제약(예: {@code @Pattern})이 없기 때문이다({@code @Validated}는 그 경우에만 필요하다). + */ +@RestController +@RequestMapping("/api/v1/policy-application-checklists") +@RequiredArgsConstructor +public class PolicyApplicationChecklistController { + + private final PolicyApplicationChecklistService checklistService; + + // POST /api/v1/policy-application-checklists — 특정 신청관리(applicationId)에 체크리스트 항목 추가. + // request.message()가 PolicyApplicationChecklistService.add()의 message 인자로 그대로 넘어가고, + // 서비스 내부에서 PolicyApplicationChecklist.create()가 이걸 content 필드에 담는다 — DTO는 "message", + // 엔티티/리포지토리는 "content"로 이름이 다르니 헷갈리지 않도록 주의(아래 DTO 파일 주석 참고). + @PostMapping + public ResponseEntity> add( + @CurrentUser Long userId, + @Valid @RequestBody PolicyApplicationCreateChecklistRequest request) { + PolicyApplicationChecklist checklist = + checklistService.add(request.applicationId(), userId, request.message()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(PolicyApplicationChecklistResponse.from(checklist))); + } + + // GET /api/v1/policy-application-checklists/application/{applicationId} — 특정 신청관리에 달린 체크리스트 목록. + // PolicyApplicationChecklistService.getByApplication()이 먼저 findActiveApplication()으로 부모 신청관리의 + // 존재/소유권을 확인한 뒤(쿼리 1회), 체크리스트 목록을 id 오름차순(등록 순서)으로 조회한다(쿼리 1회) — 총 2쿼리. + @GetMapping("/application/{applicationId}") + public ApiResponse> getByApplication( + @CurrentUser Long userId, + @PathVariable Long applicationId, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + checklistService.getByApplication(applicationId, userId, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + // PATCH /api/v1/policy-application-checklists/{id} — 체크리스트 내용(content) 수정. + @PatchMapping("/{id}") + public ApiResponse update( + @CurrentUser Long userId, + @PathVariable Long id, + @Valid @RequestBody PolicyApplicationUpdateChecklistRequest request) { + PolicyApplicationChecklist checklist = + checklistService.update(id, userId, request.message()); + return ApiResponse.ok(PolicyApplicationChecklistResponse.from(checklist)); + } + + // PATCH /api/v1/policy-application-checklists/{id}/check — 체크 표시. 몸값(body) 없이 상태만 토글하므로 + // 반환 데이터가 없고, PolicyApplicationChecklistMessageResponse로 성공 메시지만 내려준다 + // (PolicyApplicationController.delete()가 ApiResponse.ok(null)로 데이터 없음을 표현하는 것과 다른 스타일 — 여긴 메시지 + // body를 쓴다). + @PatchMapping("/{id}/check") + public ApiResponse check( + @CurrentUser Long userId, @PathVariable Long id) { + checklistService.check(id, userId); + return ApiResponse.ok(new PolicyApplicationChecklistMessageResponse("체크 완료")); + } + + // PATCH /api/v1/policy-application-checklists/{id}/uncheck — check()의 반대. + @PatchMapping("/{id}/uncheck") + public ApiResponse uncheck( + @CurrentUser Long userId, @PathVariable Long id) { + checklistService.uncheck(id, userId); + return ApiResponse.ok(new PolicyApplicationChecklistMessageResponse("체크 해제 완료")); + } + + // DELETE /api/v1/policy-application-checklists/{id} — 체크리스트 항목 하나만 소프트 삭제. + // 부모 신청관리 전체가 삭제/재등록될 때 체크리스트를 한 번에 정리하는 경로는 여기가 아니라 + // PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()이고, 그건 이 컨트롤러가 아니라 + // PolicyApplicationService.register()의 reactivate 분기에서 호출된다(다른 도메인 흐름과의 연결점). + @DeleteMapping("/{id}") + public ApiResponse delete( + @CurrentUser Long userId, @PathVariable Long id) { + checklistService.delete(id, userId); + return ApiResponse.ok(new PolicyApplicationChecklistMessageResponse("체크리스트 삭제 완료")); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java new file mode 100644 index 0000000..d3b322f --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java @@ -0,0 +1,153 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyApplicationRegisterRequest; +import com.bop.youthpick.policy.dto.PolicyApplicationResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.service.PolicyApplicationService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * "정책 신청관리(관심→준비중→신청→완료)" HTTP 진입점. 비즈니스 판단은 전부 {@link PolicyApplicationService}에 위임하고, 여기선 요청 + * 파싱(Bean Validation) → 서비스 호출 → {@link PolicyApplicationResponse}로 응답 변환만 한다(api-design.md의 + * "Controller는 얇게" 규칙). + */ +@Validated +@RestController +@RequestMapping("/api/v1/policy-applications") +@RequiredArgsConstructor +public class PolicyApplicationController { + + private final PolicyApplicationService policyApplicationService; + + // POST /api/v1/policy-applications — "관심 등록" 액션의 진입점. + // @Valid가 PolicyApplicationRegisterRequest의 Bean Validation(@NotNull, @Pattern 등)을 먼저 통과시킨 뒤에만 + // 이 메서드 본문이 실행된다. + // status 문자열 → ApplicationStatus enum 변환은 parseStatus()를 거친다 — 평소엔 DTO의 + // @Pattern(PolicyApplicationRegisterRequest.java:12)이 걸러준 값만 들어오지만, 그 화이트리스트가 + // changeStatus()의 @Pattern과 별도로 중복 관리되는 탓에(ApplicationStatus.java 클래스 주석 참고) 어긋날 경우를 + // 대비해 parseStatus()가 500 대신 깔끔한 400(P007)으로 막아준다. + // 신규 등록 vs soft-delete 재활성화 vs 중복 예외 판단은 PolicyApplicationService.register()가 전담한다. + @PostMapping + public ResponseEntity> register( + @CurrentUser Long userId, + @Valid @RequestBody PolicyApplicationRegisterRequest request) { + PolicyApplication application = + policyApplicationService.register( + userId, + request.policyId(), + parseStatus(request.status()), + request.memo(), + request.endAt()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(PolicyApplicationResponse.from(application))); + } + + // GET /api/v1/policy-applications — 내 정책 신청 목록 페이지 조회. + // page/size/totalPages를 컨트롤러가 직접 계산하지 않는다 — @PageableDefault(size=20)로 Spring Data가 Pageable을 + // 만들고, + // PolicyApplicationService.getApplications()가 이미 PolicyApplicationResponse로 변환된 Page를 돌려주므로 + // 여기선 그 Page를 (content, meta) 형태로 ApiResponse에 담기만 한다(api-design.md의 Pageable 규칙). + @GetMapping + public ApiResponse> getApplications( + @CurrentUser Long userId, @PageableDefault(size = 20) Pageable pageable) { + Page page = + policyApplicationService.getApplications(userId, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + // PATCH /api/v1/policy-applications/{id}/status — 상태만 단독으로 바꾸는 엔드포인트. + // register()의 DTO와 같은 4개 상태값 화이트리스트를 여기서도 별도로 유지한다(위 register() 주석 참고 — 드리프트 위험 동일, + // parseStatus()가 같은 방식으로 방어한다). + // 소유권 검증은 여기가 아니라 PolicyApplicationService.changeStatus() 안의 application.verifyOwner()에서 한다 — + // id로 조회한 신청이 진짜 이 userId 소유인지는 서비스 계층 책임이다. + @PatchMapping("/{id}/status") + public ApiResponse changeStatus( + @CurrentUser Long userId, + @PathVariable Long id, + @RequestParam + @NotBlank(message = "상태는 필수입니다.") + @Pattern( + regexp = "INTERESTED|PREPARING|APPLIED|COMPLETED", + message = "유효하지 않은 상태값입니다.") + String status) { + PolicyApplication application = + policyApplicationService.changeStatus(id, userId, parseStatus(status)); + return ApiResponse.ok(PolicyApplicationResponse.from(application)); + } + + // PATCH /api/v1/policy-applications/{id}/memo — 메모만 단독 수정. + // 컨트롤러는 @Size로 길이만 검증하고, 빈 문자열/공백을 null로 통일하는 정규화는 + // PolicyApplicationService.blankToNull()(서비스 계층)이 담당한다 — 여기서 값을 가공하지 않는다. + @PatchMapping("/{id}/memo") + public ApiResponse updateMemo( + @CurrentUser Long userId, + @PathVariable Long id, + @RequestParam @Size(max = 2000, message = "메모는 2000자를 초과할 수 없습니다.") String memo) { + PolicyApplication application = policyApplicationService.updateMemo(id, userId, memo); + return ApiResponse.ok(PolicyApplicationResponse.from(application)); + } + + /** + * memo와 달리 endAt은 생략 시 필수 에러가 아니라 마감일 초기화(clear)로 동작한다. LocalDateTime처럼 String이 아닌 타입은 Spring이 + * "파라미터 생략"과 "빈 문자열(endAt=)"을 바인딩 단계에서 이미 null로 합쳐 버려 required=true로는 이 둘을 구분할 수 없다(둘 다 "필수값 + * 없음" 에러가 됨) — 그래서 memo처럼 필수로 강제하지 않고 의도적으로 생략=초기화로 둔다. 정책 마감일을 넘는지 검증하는 로직은 + * PolicyApplicationService.updateEndAt() → resolveEndAt()/validateWithinPolicyDeadline()에 있다. + */ + @PatchMapping("/{id}/end-at") + public ApiResponse updateEndAt( + @CurrentUser Long userId, + @PathVariable Long id, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime endAt) { + PolicyApplication application = policyApplicationService.updateEndAt(id, userId, endAt); + return ApiResponse.ok(PolicyApplicationResponse.from(application)); + } + + // DELETE /api/v1/policy-applications/{id} — 소프트 삭제(관심 해제). + // PolicyApplicationService.delete() → PolicyApplication.delete()가 deletedAt만 세팅한다(물리 삭제 아님, 재등록 + // 시 reactivate()로 되살아남). + // 반환할 데이터가 없어 ApiResponse.ok(null) — data는 null, meta 없음. + @DeleteMapping("/{id}") + public ApiResponse delete(@CurrentUser Long userId, @PathVariable Long id) { + policyApplicationService.delete(id, userId); + return ApiResponse.ok(null); + } + + // register()/changeStatus() 공통 — ApplicationStatus.valueOf()를 직접 부르지 않고 이걸 거친다. + // 평소엔 @Pattern이 이미 걸러준 값만 들어와서 아무 차이가 없지만, 두 @Pattern과 enum 상수가 어긋나는 드리프트가 + // 생기면 IllegalArgumentException을 여기서 CustomException(P007)으로 바꿔, 처리 못 한 예외로 새서 500이 되는 걸 막는다. + private ApplicationStatus parseStatus(String status) { + try { + return ApplicationStatus.valueOf(status); + } catch (IllegalArgumentException e) { + throw new CustomException(PolicyErrorCode.INVALID_APPLICATION_STATUS); + } + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/.gitkeep b/src/main/java/com/bop/youthpick/policy/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistMessageResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistMessageResponse.java new file mode 100644 index 0000000..38e337a --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistMessageResponse.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.policy.dto; + +/** + * 반환할 데이터가 없는 체크리스트 액션(check/uncheck/delete)의 응답 껍데기 — PolicyApplicationChecklistController가 문자열 + * 리터럴("체크 완료" 등)을 직접 담아 만든다. 값을 만드는 서비스 로직은 없고, 순전히 컨트롤러가 사람이 읽을 성공 메시지를 응답 body에 넣기 위한 래퍼일 뿐이다. + */ +public record PolicyApplicationChecklistMessageResponse(String message) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java new file mode 100644 index 0000000..6a9f837 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import java.time.LocalDateTime; + +/** + * PolicyApplicationChecklistController의 add()/getByApplication()/update()가 공통으로 쓰는 응답 DTO. 필드명은 + * message지만 엔티티에서 실제로 읽어오는 건 {@link PolicyApplicationChecklist#getContent()}다 — 요청 DTO들과 이름을 맞추려고 + * API 표면에서만 message로 부르는 것뿐, 엔티티/DB 컬럼명(content)과는 다르다. + */ +public record PolicyApplicationChecklistResponse( + Long id, + Long policyApplicationId, + String message, + boolean checked, + LocalDateTime createdAt) { + + // PolicyApplicationChecklistController.add()/update()와 + // PolicyApplicationChecklistService.getByApplication()(Page.map)에서 호출된다. + public static PolicyApplicationChecklistResponse from(PolicyApplicationChecklist checklist) { + return new PolicyApplicationChecklistResponse( + checklist.getId(), + checklist.getApplication().getId(), + checklist.getContent(), + checklist.isChecked(), + checklist.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java new file mode 100644 index 0000000..779c980 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * PolicyApplicationChecklistController.add()가 바인딩하는 요청 DTO. applicationId는 + * PolicyApplicationChecklistService.findActiveApplication()이 PolicyApplicationRepository로 조회해 + * 존재/소유권을 확인하는 데 쓰이고, message는 PolicyApplicationChecklist.create()에서 content 필드에 담긴다 (register 쪽 + * 500자 제한은 PolicyApplicationRegisterRequest의 메모 2000자 제한보다 짧다 — 체크리스트 항목 하나의 길이 제약이라 더 짧게 잡혀 있다). + */ +public record PolicyApplicationCreateChecklistRequest( + @NotNull(message = "신청관리 ID는 필수입니다.") Long applicationId, + @NotBlank(message = "체크리스트 내용은 필수입니다.") + @Size(max = 500, message = "체크리스트 내용은 500자 이하여야 합니다.") + String message) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java new file mode 100644 index 0000000..e05c08f --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java @@ -0,0 +1,33 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +/** + * PolicyApplicationController.register()가 {@code @Valid @RequestBody}로 바인딩하는 요청 DTO. 검증을 통과한 필드들이 + * 그대로 PolicyApplicationService.register()의 인자로 넘어간다(policyId, status(문자열→enum 변환은 컨트롤러의 + * parseStatus()에서), memo, endAt 순서 그대로). + */ +public record PolicyApplicationRegisterRequest( + // PolicyApplicationService.register()에서 policyRepository.findById(policyId)로 조회된다 — + // 없으면 PolicyErrorCode.POLICY_NOT_FOUND. + @NotNull(message = "정책 ID는 필수입니다.") Long policyId, + // enum이 아니라 문자열 + @Pattern인 이유: enum을 직접 바인딩하면 Jackson 역직렬화 단계에서 실패해 C001로 못 + // 내려간다(api-design.md). + // 이 화이트리스트 문자열은 ApplicationStatus enum 상수 이름과 정확히 일치해야 하고(ApplicationStatus.java 참고), + // PolicyApplicationController.changeStatus()의 @Pattern과도 중복 관리된다. 이 화이트리스트와 enum이 + // 어긋나도 컨트롤러의 parseStatus()가 IllegalArgumentException을 + // PolicyErrorCode.INVALID_APPLICATION_STATUS(P007)로 바꿔주므로 500까지 새지는 않는다. + @NotBlank(message = "상태는 필수입니다.") + @Pattern( + regexp = "INTERESTED|PREPARING|APPLIED|COMPLETED", + message = "유효하지 않은 상태값입니다.") + String status, + // PolicyApplicationService.blankToNull()이 공백/빈 문자열을 null로 통일한다 — 여기 @Size는 길이만 검증하고 트림은 하지 + // 않는다. + @Size(max = 2000, message = "메모는 2000자를 초과할 수 없습니다.") String memo, + // 생략(null) 시 PolicyApplicationService.resolveEndAt()이 정책 자체의 신청 마감일로 기본값을 채운다. + LocalDateTime endAt) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java new file mode 100644 index 0000000..e1f2627 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java @@ -0,0 +1,42 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.PolicyApplication; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * PolicyApplicationController의 모든 엔드포인트가 응답으로 쓰는 DTO — {@link PolicyApplication} 엔티티를 컨트롤러가 직접 반환하지 + * 않기 위한 변환 계층이다(api-design.md의 "Entity를 API 응답으로 직접 반환하지 않는다" 규칙). + * policyTitle/policyCategory/policyApplicationEndDate는 PolicyApplication 자신의 필드가 아니라 연관된 {@code + * Policy} 엔티티에서 가져온 값 — 프론트가 신청 목록 카드에 정책 제목/카테고리/마감일을 같이 보여줄 수 있게 한 번에 평탄화(flatten)한 것이다. + */ +public record PolicyApplicationResponse( + Long id, + Long policyId, + String policyTitle, + String policyCategory, + LocalDate policyApplicationEndDate, + ApplicationStatus status, + String memo, + LocalDateTime endAt, + LocalDateTime createdAt) { + + // PolicyApplicationController의 register/changeStatus/updateMemo/updateEndAt과 + // PolicyApplicationService.getApplications()(Page.map)에서 호출된다. + // application.getPolicy()는 PolicyApplicationRepository.findByUser_IdAndDeletedAtIsNull()의 + // @EntityGraph(attributePaths = "policy") 덕분에 지연 로딩(N+1) 없이 이미 로드돼 있다. + public static PolicyApplicationResponse from(PolicyApplication application) { + var policy = application.getPolicy(); + return new PolicyApplicationResponse( + application.getId(), + policy.getId(), + policy.getTitle(), + policy.getCategory(), + policy.getApplicationEndDate(), + application.getStatus(), + application.getMemo(), + application.getEndAt(), + application.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationUpdateChecklistRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationUpdateChecklistRequest.java new file mode 100644 index 0000000..c1f814c --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationUpdateChecklistRequest.java @@ -0,0 +1,14 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * PolicyApplicationChecklistController.update()가 바인딩하는 요청 DTO. Create 요청과 달리 applicationId가 없다 — 어느 + * 신청관리 소속인지는 이미 URL의 {id}(체크리스트 자신의 PK)로 정해지고, PolicyApplicationChecklistService.findActive()가 그 + * id로 부모까지 함께 조회하기 때문이다. message는 PolicyApplicationChecklist.updateContent()로 그대로 전달된다. + */ +public record PolicyApplicationUpdateChecklistRequest( + @NotBlank(message = "체크리스트 내용은 필수입니다.") + @Size(max = 500, message = "체크리스트 내용은 500자 이하여야 합니다.") + String message) {} diff --git a/src/main/java/com/bop/youthpick/policy/entity/.gitkeep b/src/main/java/com/bop/youthpick/policy/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java deleted file mode 100644 index fab1249..0000000 --- a/src/main/java/com/bop/youthpick/policy/entity/ApplicationChecklist.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.bop.youthpick.policy.entity; - -import com.bop.youthpick.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Index; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.SQLRestriction; - -/** 신청관리별 준비 체크리스트 (제출서류 등). */ -@Entity -@Table( - name = "policy_application_checklists", - indexes = - @Index( - name = "idx_policy_application_checklists_app", - columnList = "application_id")) -@SQLRestriction("deleted_at IS NULL") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ApplicationChecklist extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "application_id", nullable = false) - private PolicyApplication application; - - @Column(columnDefinition = "TEXT", nullable = false) - private String content; - - @Column(name = "is_checked", nullable = false) - private boolean checked; - - @Column(name = "deleted_at") - private LocalDateTime deletedAt; -} diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java index b01273c..9a24ee4 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java @@ -1,8 +1,20 @@ package com.bop.youthpick.policy.entity; -/** 정책 신청관리 상태. INTERESTED(관심)가 구 즐겨찾기 역할. */ +/** + * 정책 신청관리 상태. INTERESTED(관심)가 구 즐겨찾기 역할. PREPARING(준비중)은 관심과 신청 사이 서류 준비 단계. + * + *

이 enum 상수 이름 4개는 코드 곳곳에 문자열로 하드코딩되어 있어 같이 맞춰야 한다: {@link PolicyApplication}의 + * {@code @Enumerated(EnumType.STRING)} 컬럼(DB엔 이 이름 그대로 저장됨), {@code + * PolicyApplicationRegisterRequest}의 {@code @Pattern(regexp = + * "INTERESTED|PREPARING|APPLIED|COMPLETED")}, {@code PolicyApplicationController.changeStatus()}의 + * 동일한 {@code @Pattern}. 상수를 추가/이름 변경할 때 이 세 곳을 빠뜨리면 유효한 값인데도 400으로 막힐 수 있다 — 다만 반대 + * 방향({@code @Pattern}이 못 걸러 {@code valueOf()}가 던지는 {@code IllegalArgumentException})은 {@code + * PolicyApplicationController.parseStatus()}가 {@code CustomException(INVALID_APPLICATION_STATUS)}로 + * 바꿔줘서 500으로 새지는 않는다. + */ public enum ApplicationStatus { INTERESTED, + PREPARING, APPLIED, COMPLETED } diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java index 56cca27..686c071 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java @@ -1,5 +1,7 @@ package com.bop.youthpick.policy.entity; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; import com.bop.youthpick.global.entity.BaseEntity; import com.bop.youthpick.user.entity.User; import jakarta.persistence.Column; @@ -22,7 +24,9 @@ /** * 사용자별 정책 신청 진행관리 (관심 → 신청 → 완료). 기존 favorite_policies를 흡수 — status=INTERESTED가 즐겨찾기. UNIQUE(user, - * policy)로 같은 정책 중복 등록 방지. + * policy)로 같은 정책 중복 등록 방지. 이 엔티티는 오직 PolicyApplicationService를 통해서만 생성/변경된다 — Controller에서 직접 다루지 + * 않는다(entity-jpa.md 규칙). 조회는 PolicyApplicationRepository, API 노출은 PolicyApplicationResponse.from()이 + * 담당한다. */ @Entity @Table( @@ -50,7 +54,7 @@ public class PolicyApplication extends BaseEntity { @Enumerated(EnumType.STRING) @Column(length = 20, nullable = false) - private ApplicationStatus status = ApplicationStatus.INTERESTED; + private ApplicationStatus status; @Column(columnDefinition = "TEXT") private String memo; @@ -63,7 +67,71 @@ public class PolicyApplication extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + // 정적 팩토리 — PolicyApplicationService.register()의 "완전히 새로운 신청" 경로에서만 호출된다 + // (기존에 soft-delete된 행이 있으면 이걸 쓰지 않고 아래 reactivate()로 같은 행을 재사용한다). + public static PolicyApplication register( + User user, Policy policy, ApplicationStatus status, String memo, LocalDateTime endAt) { + PolicyApplication application = new PolicyApplication(); + application.user = user; + application.policy = policy; + application.status = status; + application.memo = memo; + application.endAt = endAt; + return application; + } + + // PolicyApplicationService.changeStatus()에서 호출. setter 대신 의미 있는 메서드로 상태 변경(entity-jpa.md 규칙). + // save() 호출 없이 필드만 바꿔도 @Transactional 커밋 시 JPA dirty checking으로 자동 UPDATE된다. public void changeStatus(ApplicationStatus status) { this.status = status; } + + // PolicyApplicationService.updateMemo()에서 호출. null 정규화(blankToNull)는 서비스 계층 책임이라 여기선 그대로 대입만 + // 한다. + public void updateMemo(String memo) { + this.memo = memo; + } + + // PolicyApplicationService.updateEndAt()에서 호출. 정책 마감일 초과 검증(validateWithinPolicyDeadline)도 서비스 + // 책임 — + // 엔티티는 검증된 값을 그대로 받아 대입만 한다. + public void updateEndAt(LocalDateTime endAt) { + this.endAt = endAt; + } + + /** + * soft-delete된 행을 UNIQUE(user, policy) 충돌 없이 재등록한다. PolicyApplicationService.register()의 "기존 + * 신청이 있는데 삭제된 상태" 분기에서만 호출되며, status/memo/endAt을 과거 값과 무관하게 이번 요청 값으로 완전히 덮어쓴다. 리뷰 노트: 이 + * 엔티티엔 @Version(낙관적 락)이 없어서, 같은 (user, policy)에 대해 동시에 재등록 요청이 들어오면 나중에 커밋되는 쪽이 조용히 덮어쓸 수 있다 — + * register()의 "새 INSERT" 경로가 DataIntegrityViolationException으로 명시적으로 막는 것과 달리 이 경로는 방어가 없다. + */ + public void reactivate(ApplicationStatus status, String memo, LocalDateTime endAt) { + this.status = status; + this.memo = memo; + this.endAt = endAt; + this.deletedAt = null; + } + + // PolicyApplicationService.delete()에서 호출. 물리 DELETE가 아니라 deletedAt 타임스탬프만 세팅하는 소프트 삭제 — + // UNIQUE(user, policy) 제약은 유지한 채로 "삭제됨"만 표시해서, 나중에 register()의 reactivate() 경로가 이 행을 재사용할 수 있게 + // 한다. + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + // register()가 "새로 만들지 vs 재활성화할지"를 가르는 조건, findActive()류 조회 필터의 기준이 되는 판별 메서드. + public boolean isDeleted() { + return deletedAt != null; + } + + // 소유권 검증 — PolicyApplicationService(changeStatus/updateMemo/updateEndAt/delete)와 + // PolicyApplicationChecklistService(findActiveApplication/findActive, + // checklist.getApplication() 경유)가 + // 공통으로 사용한다. 원래 두 서비스에 토씨 하나 안 다른 private verifyOwner()가 각각 중복돼 있었는데, + // "이 신청의 소유자가 누구인가"는 PolicyApplication 자신이 답할 수 있는 질문이라 엔티티 메서드로 옮겼다. + public void verifyOwner(Long userId) { + if (!user.getId().equals(userId)) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } + } } diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java new file mode 100644 index 0000000..874bf98 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java @@ -0,0 +1,88 @@ +package com.bop.youthpick.policy.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 신청관리별 준비 체크리스트 (제출서류 등). PolicyApplicationChecklistService를 통해서만 생성/변경되고, application + * FK(policy_application_checklists.application_id)로 부모 {@link PolicyApplication}에 종속된다 — 부모가 소프트 + * 삭제되면 이 엔티티는 개별적으로는 안 지워지고 남아있는 채로 "고아" 상태가 되는데, 그건 + * PolicyApplicationChecklistService.findActive()가 checklist.getApplication().isDeleted()로 걸러낸다. + */ +@Entity +@Table( + name = "policy_application_checklists", + indexes = + @Index( + name = "idx_policy_application_checklists_app", + columnList = "application_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PolicyApplicationChecklist extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id", nullable = false) + private PolicyApplication application; + + // 컬럼/필드명은 content이지만, 이걸 감싸는 DTO 쪽(PolicyApplicationCreateChecklistRequest 등)은 + // 전부 message라는 이름을 쓴다 — 같은 값인데 계층마다 이름이 달라서 코드를 오갈 때 헷갈리기 쉽다. + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + // Lombok @Getter가 boolean 필드 checked에 대해 isChecked()를 생성한다 — + // PolicyApplicationChecklistResponse.from()이 이 getter로 값을 읽는다. + @Column(name = "is_checked", nullable = false) + private boolean checked; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + // PolicyApplicationChecklistService.add()에서만 호출되는 정적 팩토리 — 새 항목은 항상 미체크(checked=false) 상태로 + // 시작한다. + public static PolicyApplicationChecklist create(PolicyApplication application, String content) { + PolicyApplicationChecklist checklist = new PolicyApplicationChecklist(); + checklist.application = application; + checklist.content = content; + checklist.checked = false; + return checklist; + } + + // PolicyApplicationChecklistService.update()에서 호출. + public void updateContent(String content) { + this.content = content; + } + + // PolicyApplicationChecklistService.check()에서 호출. + public void check() { + this.checked = true; + } + + // PolicyApplicationChecklistService.uncheck()에서 호출. + public void uncheck() { + this.checked = false; + } + + // PolicyApplicationChecklistService.delete()에서 호출하는 개별 소프트 삭제. + // 여러 항목을 한 번에 지우는 경로(부모 신청관리 재등록 시)는 이 메서드를 쓰지 않고 + // PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()가 벌크 UPDATE로 처리한다. + public void delete() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java index fc83dcf..9a2ac73 100644 --- a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -8,11 +8,29 @@ @Getter @RequiredArgsConstructor public enum PolicyErrorCode implements ErrorCode { + // PolicyApplicationService.register()에서 policyRepository.findById(policyId)가 비었을 때 던진다. POLICY_NOT_FOUND("P001", "일치하는 정책이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + // PolicyApplicationService.register()에서 두 번 쓰인다: ①활성 상태인 기존 신청이 있을 때(중복 등록 시도), + // ②동시 요청으로 UNIQUE(user, policy) 제약을 위반한 DataIntegrityViolationException을 잡았을 때 — 같은 도메인 에러로 통일. POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT), + // policy-application 기능이 아니라 user 도메인의 OnboardingService에서 쓰인다(관심 지역 코드 검증) — + // "정책" 접두어(P)를 쓰지만 실제로는 지역 코드 조회 실패에 재사용되는 코드다. REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND), - POLICY_APPLICATION_NOT_FOUND("P004", "일치하는 정책 신청 내역이 존재하지 않습니다.", HttpStatus.NOT_FOUND), - INVALID_APPLICATION_STATUS("P005", "유효하지 않은 신청 상태 값입니다.", HttpStatus.BAD_REQUEST); + // PolicyApplicationService.findActive(id)가 못 찾을 때(changeStatus/updateMemo/updateEndAt/delete 공통 + // 경로), + // PolicyApplicationChecklistService에서도 같은 목적으로 재사용된다. + POLICY_APPLICATION_NOT_FOUND("P004", "정책 관리 항목을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + // PolicyApplicationChecklistService 전용 — 이 파일이 속한 policy-application 핵심 + // 흐름(register/changeStatus 등)에선 쓰이지 않는다. + CHECKLIST_NOT_FOUND("P005", "체크리스트 항목을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + // PolicyApplicationService.validateWithinPolicyDeadline()에서 던진다 — register()의 resolveEndAt()과 + // updateEndAt()이 공통으로 이 검증을 거친다(개인 마감일이 정책 자체 마감일보다 늦으면 안 됨). + END_AT_AFTER_POLICY_DEADLINE("P006", "개인 마감일은 정책 신청 마감일을 넘을 수 없습니다.", HttpStatus.BAD_REQUEST), + // PolicyApplicationController.parseStatus()에서 던진다 — DTO/@RequestParam의 @Pattern이 이미 걸러주는 + // 값이라 평소엔 발생하지 않지만, ApplicationStatus enum과 두 곳의 @Pattern 문자열이 어긋나는 경우를 대비한 + // 방어선이다(ApplicationStatus.java 클래스 주석 참고). 이게 없으면 valueOf()의 IllegalArgumentException이 + // GlobalExceptionHandler의 Exception.class 핸들러로 떨어져 500(S001)으로 샌다. + INVALID_APPLICATION_STATUS("P007", "유효하지 않은 상태값입니다.", HttpStatus.BAD_REQUEST); private final String code; private final String message; diff --git a/src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java b/src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java deleted file mode 100644 index 6199314..0000000 --- a/src/main/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.bop.youthpick.policy.repository; - -import com.bop.youthpick.policy.entity.ApplicationChecklist; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ApplicationChecklistRepository extends JpaRepository { - - List findByApplicationIdOrderByIdAsc(Long applicationId); -} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java new file mode 100644 index 0000000..0a60128 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java @@ -0,0 +1,36 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PolicyApplicationChecklistRepository + extends JpaRepository { + + // PolicyApplicationChecklistService.findActive(id, userId)가 이걸 감싼다 — + // update/check/uncheck/delete 네 메서드가 모두 이 조회를 거친다. + Optional findByIdAndDeletedAtIsNull(Long id); + + /** 체크리스트 노출 순서를 id 오름차순(등록 순서)으로 고정한다. */ + // PolicyApplicationChecklistService.getByApplication()에서 사용 — 목록 API의 실제 조회. + Page findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + Long applicationId, Pageable pageable); + + // 이 리포지토리를 쓰는 다른 모든 메서드와 달리, 이건 PolicyApplicationChecklistService가 아니라 + // 옆 도메인 파일인 PolicyApplicationService.register()의 reactivate 분기에서 직접 호출된다 — + // 사용자가 관심 삭제했던 정책을 재등록하면, 그 신청에 달려있던 예전 체크리스트를 전부 무효화하기 위함이다. + // flushAutomatically = true라서, 직전에 PolicyApplication.reactivate()로 바뀐 변경분이 먼저 flush된 + // 뒤에 이 벌크 UPDATE가 실행된다(순서 보장). clearAutomatically는 안 켜져 있어서, 같은 트랜잭션 안에서 + // 이미 영속 상태로 로드해둔 PolicyApplicationChecklist가 있다면 이 벌크 UPDATE 이후에도 메모리 값은 + // 갱신되지 않는다 — 지금 호출 지점(register())은 체크리스트 엔티티를 따로 로드하지 않아서 문제되지 않는다. + @Modifying(flushAutomatically = true) + @Query( + "UPDATE PolicyApplicationChecklist c SET c.deletedAt = CURRENT_TIMESTAMP " + + "WHERE c.application.id = :applicationId AND c.deletedAt IS NULL") + void softDeleteAllByApplicationId(@Param("applicationId") Long applicationId); +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java index 203e554..e9390e8 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java @@ -1,9 +1,28 @@ package com.bop.youthpick.policy.repository; import com.bop.youthpick.policy.entity.PolicyApplication; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface PolicyApplicationRepository extends JpaRepository, - JpaSpecificationExecutor {} + JpaSpecificationExecutor { + + // PolicyApplicationService.findActive(id)가 이걸 감싼다 — changeStatus/updateMemo/updateEndAt/delete가 + // 모두 이 메서드를 거쳐 "존재하지 않거나 이미 삭제된 id"를 POLICY_APPLICATION_NOT_FOUND로 통일해서 처리한다. + Optional findByIdAndDeletedAtIsNull(Long id); + + // PolicyApplicationService.getApplications()(목록 조회)에서 사용. @EntityGraph로 policy 연관관계를 함께 + // fetch해서, + // PolicyApplicationResponse.from()이 policy.getTitle() 등을 부를 때 N+1 쿼리가 나지 않게 한다. + @EntityGraph(attributePaths = "policy") + Page findByUser_IdAndDeletedAtIsNull(Long userId, Pageable pageable); + + // PolicyApplicationService.register()의 첫 줄에서 사용 — soft-delete 여부와 무관하게 (user, policy) 조합의 + // 기존 행이 있는지부터 확인한다(테이블의 UNIQUE(user_id, policy_id) 제약과 짝을 이루는 조회). + Optional findByUser_IdAndPolicy_Id(Long userId, Long policyId); +} diff --git a/src/main/java/com/bop/youthpick/policy/service/.gitkeep b/src/main/java/com/bop/youthpick/policy/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java new file mode 100644 index 0000000..ed92c8b --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java @@ -0,0 +1,112 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyApplicationChecklistResponse; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 정책 신청관리(PolicyApplication)에 딸린 준비 체크리스트의 비즈니스 로직. 체크리스트는 항상 부모 PolicyApplication을 통해서만 접근 가능하다 — + * applicationId 하나 없이 체크리스트만 단독 조회하는 API는 없다. PolicyApplicationRepository를 직접 참조하는 것도 그래서다(체크리스트 자체 + * 레포만으론 소유권을 못 가린다). + */ +@Service +@RequiredArgsConstructor +public class PolicyApplicationChecklistService { + + private final PolicyApplicationChecklistRepository applicationChecklistRepository; + private final PolicyApplicationRepository policyApplicationRepository; + + // PolicyApplicationChecklistController.add()에서 호출. 먼저 findActiveApplication()으로 부모 신청관리가 + // 존재하고 요청자 소유인지 확인한 뒤에만 PolicyApplicationChecklist.create()로 새 항목을 만든다. + @Transactional + public PolicyApplicationChecklist add(Long applicationId, Long userId, String message) { + PolicyApplication application = findActiveApplication(applicationId, userId); + + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application, message); + return applicationChecklistRepository.save(checklist); + } + + // PolicyApplicationChecklistController.update()에서 호출. save() 없이 필드만 바꿔도 @Transactional + // 커밋 시 dirty checking으로 자동 반영된다(PolicyApplication.changeStatus()와 같은 패턴). + @Transactional + public PolicyApplicationChecklist update(Long id, Long userId, String message) { + PolicyApplicationChecklist checklist = findActive(id, userId); + checklist.updateContent(message); + return checklist; + } + + // PolicyApplicationChecklistController.check()에서 호출. + @Transactional + public void check(Long id, Long userId) { + findActive(id, userId).check(); + } + + // PolicyApplicationChecklistController.uncheck()에서 호출. + @Transactional + public void uncheck(Long id, Long userId) { + findActive(id, userId).uncheck(); + } + + // PolicyApplicationChecklistController.delete()에서 호출 — 항목 하나만 소프트 삭제. + // 부모 신청관리 전체가 삭제/재등록될 때 체크리스트를 일괄 정리하는 건 여기가 아니라 + // PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()이고, 그건 + // PolicyApplicationService.register()의 reactivate 분기에서 (이 서비스를 거치지 않고) 직접 호출된다. + @Transactional + public void delete(Long id, Long userId) { + findActive(id, userId).delete(); + } + + // PolicyApplicationChecklistController.getByApplication()에서 호출. 부모 신청관리 소유권 확인(1쿼리) 후 + // 체크리스트 목록을 id 오름차순으로 조회(1쿼리)해 PolicyApplicationChecklistResponse로 변환한다. + @Transactional(readOnly = true) + public Page getByApplication( + Long applicationId, Long userId, Pageable pageable) { + findActiveApplication(applicationId, userId); + + return applicationChecklistRepository + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc(applicationId, pageable) + .map(PolicyApplicationChecklistResponse::from); + } + + // add()/getByApplication()이 공통으로 쓰는 "부모 신청관리가 존재하고, 삭제되지 않았고, 요청자 소유인지" 확인. + // PolicyApplicationService.findActive()와 거의 같은 조회(findByIdAndDeletedAtIsNull)를 이 서비스가 + // 별도로 다시 한다 — 두 서비스가 같은 Repository를 각자 호출하는 구조라 로직이 갈라져 있다. + private PolicyApplication findActiveApplication(Long applicationId, Long userId) { + PolicyApplication application = + policyApplicationRepository + .findByIdAndDeletedAtIsNull(applicationId) + .orElseThrow( + () -> + new CustomException( + PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + application.verifyOwner(userId); + return application; + } + + // update/check/uncheck/delete가 공통으로 쓰는 조회. 체크리스트 자신의 deletedAt뿐 아니라 + // checklist.getApplication().isDeleted()까지 한 번 더 확인하는 이유: 체크리스트는 안 지워졌는데 부모 + // PolicyApplication만 나중에 소프트 삭제된 경우를 걸러내기 위해서다(그런 상태의 체크리스트는 "고아"로 취급해 + // CHECKLIST_NOT_FOUND로 통일). + private PolicyApplicationChecklist findActive(Long id, Long userId) { + PolicyApplicationChecklist checklist = + applicationChecklistRepository + .findByIdAndDeletedAtIsNull(id) + .orElseThrow( + () -> new CustomException(PolicyErrorCode.CHECKLIST_NOT_FOUND)); + if (checklist.getApplication().isDeleted()) { + throw new CustomException(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + checklist.getApplication().verifyOwner(userId); + return checklist; + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java new file mode 100644 index 0000000..d341b89 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java @@ -0,0 +1,202 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyApplicationResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service // 서비스 계층 등록 +@RequiredArgsConstructor // final 필드에 대한 생성자를 별도로 생성하지 않고 Lombok이 자동으로 생성자를 생성해준다. +public class PolicyApplicationService { + + // 사용자 정보,정책 신청 관리, 정책, 정책 신청 체크리스트의 객체를 참조한다. + private final PolicyApplicationRepository policyApplicationRepository; + private final UserRepository userRepository; + private final PolicyRepository policyRepository; + private final PolicyApplicationChecklistRepository policyApplicationChecklistRepository; + + // 여러 DB 변경 작업을 하나로 묶어서 작업하는 에노테이션을 트랜잭션이라고 한다. 사용자가 정책 등록같은 행동을 취했을 때 작동하고 하나라도 오류 날 시 롤백해준다. + // register 메서드는 컨트롤러로부터 사용자 ID, 정책 ID, 상태(준비중같은 상태 말하는거임), 메모, 마감일을 매개변수로 전달받는다. + // existing은 policyApplicationRepository에게 사용자 id와 정책 id로 조회를 요청한 결과를 할당한다. + // 기존 정책 신청 내역 조회 결과가 없으면 null로 처리한다. + // 메모가 비어있거나 공백이면 null로 통일하고, 값이 있으면 원본 그대로 사용한다(내용 가공 없음). + @Transactional + public PolicyApplication register( + Long userId, + Long policyId, + ApplicationStatus status, + String memo, + LocalDateTime endAt) { + PolicyApplication existing = + policyApplicationRepository + .findByUser_IdAndPolicy_Id(userId, policyId) + .orElse(null); + String normalizedMemo = blankToNull(memo); + + // 이 조건문은 기존 신청 내역이 존재하는 경우 이 조건문이 실행된다. + // 기존 신청 내역이 삭제되지 않았다면 예외를 발생시켜 이미 존재하는 정책이라고 반환한다. + // 만약에 삭제 내역이 있다면 reactivate를 통해 다시 활성화한다.(상태, 메모, 마감일등을 갱신한다. 기존 신청에 연결된 체크리스트(id)를 + // 소프트삭제한다.) + // 다시 활성화된 신청 정보를 반환한다. + if (existing != null) { + if (!existing.isDeleted()) { + throw new CustomException(PolicyErrorCode.POLICY_ALREADY_EXISTS); + } + existing.reactivate(status, normalizedMemo, resolveEndAt(endAt, existing.getPolicy())); + policyApplicationChecklistRepository.softDeleteAllByApplicationId(existing.getId()); + return existing; + } + // user는 사용자 리포의 사용자 id를 조회한다. + // 사용자가 존재하지 않을 시 예외를 발생시켜 사용자를 찾을 수 없다고 반환한다. + // 조회된 사용자는 user에 할당한다. + User user = + userRepository + .findById(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + // policy는 정책 리포의 정책 id를 조회한다. + // 정책 조회가 되지 않는다면 예외를 발생시켜 정책을 찾을 수 없다고 반환한다. + // 정책 조회가 된다면 policy에 할당한다. + Policy policy = + policyRepository + .findById(policyId) + .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + // PolicyApplication.register 정적 팩토리 메서드에 user, policy, status, memo, endAt을 인자로 넘겨 새 + // PolicyApplication 객체를 만든다. + // 생성된 객체를 application 변수에 할당한다. + PolicyApplication application = + PolicyApplication.register( + user, policy, status, normalizedMemo, resolveEndAt(endAt, policy)); + + // 리포 save 메서드에 application 정보를 저장한다. + // 저장된 정책 신청 객체를 반환한다. + try { + return policyApplicationRepository.save(application); + + // 동시에 같은 정책 신청 요청이 들어와 유니크 제약조건 위반이 발생한 경우 + // 중복 신청 예외로 변환한다. + } catch (DataIntegrityViolationException e) { + // findByUser_IdAndPolicy_Id 확인 이후 동시 요청이 먼저 저장한 경우 + // (uk_policy_applications_user_policy UNIQUE 위반). 같은 도메인 에러로 통일한다. + throw new CustomException(PolicyErrorCode.POLICY_ALREADY_EXISTS); + } + } + + // 여러 DB 작업들을 하나로 묶어서 작업하는 걸 트랜잭션이라고 한다 오류가 하나라도 발생하면 롤백한다. + // changeStatus 메서드는 컨트롤러로부터 PK id, 사용자 id, 상태를 매개변수로 전달받는다. + // application은 findActive(id)로 삭제되지 않은 신청 내역을 조회한 결과를 할당한다. + // application.verifyOwner(userId)로 해당 정책 신청이 요청한 사용자의 것이 맞는지 확인한다. + // application.changeStatus(status)로 상태를 변경한다(트랜잭션 커밋 시 자동으로 DB에 반영되고, 별도 save 호출은 필요 없다). 변경된 + // application을 반환한다. + @Transactional + public PolicyApplication changeStatus(Long id, Long userId, ApplicationStatus status) { + PolicyApplication application = findActive(id); + application.verifyOwner(userId); + application.changeStatus(status); + return application; + } + + // 여러 DB 작업들을 하나로 묶어서 작업하는 걸 트랜잭션이라고한다. 하나라도 오류 날 시 변경 값들을 롤백한다. + // updateMemo 메서드는 컨트롤러로부터 PK id, 사용자 id, 메모를 매개변수로 전달받는다. + // application은 findActive(id)로 삭제되지 않은 신청 내역을 조회하고, application.verifyOwner(userId)로 요청자가 소유자인지 + // 확인한다. + + @Transactional + public PolicyApplication updateMemo(Long id, Long userId, String memo) { + PolicyApplication application = findActive(id); + application.verifyOwner(userId); + application.updateMemo(blankToNull(memo)); + return application; + } + + // 여러 DB 변경 작업들을 하나로 묶어서 처리하는 걸 트랜잭션이라고 한다. 하나라도 오류 날 시 변경값을 롤백한다. + // updateEndAt 메서드는 컨트롤러로부터 pk, 사용자 id, 마감일(endAt)을 매개변수로 전달받는다. + // application은 findActive(id)로 삭제되지 않은 신청 내역을 조회한 결과를 할당한다. + // application.verifyOwner(userId)로 해당 신청이 요청한 사용자의 것이 맞는지 확인한다. + // 마감일이 null이 아니면 정책 자체의 신청 마감일을 넘지 않는지 검증한다. + // application.updateEndAt(endAt)으로 마감일을 갱신하고, 변경된 application을 반환한다. + @Transactional + public PolicyApplication updateEndAt(Long id, Long userId, LocalDateTime endAt) { + PolicyApplication application = findActive(id); + application.verifyOwner(userId); + if (endAt != null) { + validateWithinPolicyDeadline(endAt, application.getPolicy()); + } + application.updateEndAt(endAt); + return application; + } + + // 여러 DB 변경 작업들을 하나로 묶어서 작업하는 에노테이션을 트랜잭션이라고한다. 리드온리는 오직 읽기만 가능하다. + // getApplications 메서드는 컨트롤러로부터 사용자 id와 Pageable을 매개변수로 전달받는다. + // policyApplicationRepository에게 해당 사용자의 삭제되지 않은 정책 신청 목록을 조회한다. + // 조회 결과를 PolicyApplicationResponse DTO로 변환하여 Page 형태로 반환한다. + @Transactional(readOnly = true) + public Page getApplications(Long userId, Pageable pageable) { + return policyApplicationRepository + .findByUser_IdAndDeletedAtIsNull(userId, pageable) + .map(PolicyApplicationResponse::from); + } + + // 여러 DB 작업들을 하나로 묶어서 작업하는 트랜잭션 + // 사용자가 삭제 할 때 PK ID, 사용자 ID등을 매개변수를 통해 전달받는다. + // application은 pk id가 삭제되지 않았는지 조회한다. + // 해당 삭제를 사용자가 한게 맞는지 사용자 ID를 확인한다. + // 다른 사람 신청이면 권한 예외를 발생시킨다. + // 정책 신청을 삭제 처리한다. + @Transactional + public void delete(Long id, Long userId) { + PolicyApplication application = findActive(id); + application.verifyOwner(userId); + application.delete(); + } + + // 해당 ID가 지워지지 않았는지 확인한다. + // 리포에게 PK ID를 확인하여 존재하지 않으면 정책 신청이 존재하지 않는다고 에러를 퍼트린다. + private PolicyApplication findActive(Long id) { + return policyApplicationRepository + .findByIdAndDeletedAtIsNull(id) + .orElseThrow( + () -> new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + } + + // 메모가 비어있거나 공백이면 null로 통일하고, 값이 있으면 원본 그대로(가공 없이) 반환하는 메서드 + private static String blankToNull(String memo) { + return StringUtils.hasText(memo) ? memo : null; + } + + /** 개인이 마감일을 지정하지 않으면(null) 정책 자체의 신청 마감일을 기본값으로 잡는다. 직접 지정한 경우엔 정책 마감일을 넘지 않는지 검증한다. */ + // 정책 마감일을 확인하는 코드 + private static LocalDateTime resolveEndAt(LocalDateTime requestedEndAt, Policy policy) { + if (requestedEndAt == null) { + return policy.getApplicationEndDate() != null + ? policy.getApplicationEndDate().atStartOfDay() + : null; + } + validateWithinPolicyDeadline(requestedEndAt, policy); + return requestedEndAt; + } + + /** 정책 자체의 신청 마감일이 알려져 있다면(null이 아니면), 개인 마감일이 그 날짜를 넘지 않아야 한다. */ + private static void validateWithinPolicyDeadline(LocalDateTime endAt, Policy policy) { + if (policy.getApplicationEndDate() != null + && endAt.toLocalDate().isAfter(policy.getApplicationEndDate())) { + throw new CustomException(PolicyErrorCode.END_AT_AFTER_POLICY_DEADLINE); + } + } +} diff --git a/src/main/java/com/bop/youthpick/user/entity/.gitkeep b/src/main/java/com/bop/youthpick/user/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f42f64f..857e0ca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,6 +17,12 @@ spring: # 스트림이 흐르는 한 걸리지 않는다 — 배치 실측(페이지당 ~2초)과도 여유 있음. connect-timeout: 3s read-timeout: 5s + data: + web: + pageable: + # 프론트가 페이지 번호를 1부터 쓰므로(page=1이 첫 페이지) 맞춘다. + # PageMeta.from()도 응답의 page를 +1해서 요청/응답 모두 1-based로 통일한다. + one-indexed-parameters: true youthpick: auth: diff --git a/src/main/resources/db/migration/V3__update_policy_applications_status_comment.sql b/src/main/resources/db/migration/V3__update_policy_applications_status_comment.sql new file mode 100644 index 0000000..c5d4ad1 --- /dev/null +++ b/src/main/resources/db/migration/V3__update_policy_applications_status_comment.sql @@ -0,0 +1,10 @@ +-- ============================================================ +-- V3 — policy_applications.status 컬럼 코멘트 갱신 +-- ApplicationStatus에 PREPARING(준비중) 추가 (2026-07-14) 후 +-- V1의 코멘트가 예전 3단계 값만 나열해 실제 허용값과 어긋나 있었음. +-- CHECK 제약은 없으므로(문서용 코멘트) 데이터 마이그레이션은 불필요. +-- ============================================================ + +ALTER TABLE policy_applications + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'INTERESTED' + COMMENT 'INTERESTED(관심=구 즐겨찾기) | PREPARING(준비중) | APPLIED | COMPLETED'; diff --git a/src/main/resources/db/migration/V4__seed_local_sample_policies.sql b/src/main/resources/db/migration/V4__seed_local_sample_policies.sql new file mode 100644 index 0000000..5195777 --- /dev/null +++ b/src/main/resources/db/migration/V4__seed_local_sample_policies.sql @@ -0,0 +1,23 @@ +-- ============================================================ +-- V4 — 로컬 개발용 샘플 정책 시드 데이터 +-- 프론트 목업(entities/policy/api/policyMockData.ts)의 id를 +-- 'p1'..'p14' 대신 실제 policies.id(1..14)와 맞추기 위해 +-- 동일한 순서로 최소 데이터를 적재한다. 정식 온통청년 동기화 +-- 배치가 구현되기 전까지 로컬 실연동 테스트용 임시 데이터다. +-- ============================================================ + +INSERT INTO policies (policy_no, title, description, category, min_age, max_age, application_end_date, support_content, additional_qualification, application_method, submission_documents, screening_method, participation_restriction, application_url) VALUES +('MOCK-P001', '청년 디지털 일자리 지원 사업', 'IT 분야 실무 경험과 교육을 제공하고 취업 연계를 지원하는 고용노동부 주관 사업입니다.', '일자리', 19, 34, '2025-06-30', '청년 채용 기업에 인건비 월 최대 190만원 지원 및 실무 교육 제공', '만 19세 이상 34세 이하의 미취업 청년', '워크넷 청년일자리사업 페이지에서 온라인 접수', '이력서, 자기소개서, 최종학력 졸업(예정)증명서', NULL, '동일 사업 참여 이력이 있는 기업은 재신청 불가', 'https://www.work.go.kr'), +('MOCK-P002', '청년 월세 한시 특별지원', '서울 및 전국 무주택 청년의 주거비 부담 경감을 위해 실제 납부하는 월세를 지원합니다.', '주거', 19, 34, '2025-06-30', '실제 납부하는 월세 범위 내에서 월 최대 20만원까지 최장 12개월 분할 지원', '부모와 별도 거주하는 무주택 청년 중 소득 기준 충족자', '복지로 온라인 신청 또는 주소지 관할 행정복지센터 방문 신청', '임대차계약서, 통장사본, 소득금액증명원', '서류 요건 충족 여부 행정 확인 후 순차 지급', '주택 소유자, 다른 월세 지원사업 중복 수급자는 제외', 'https://www.bokjiro.go.kr'), +('MOCK-P003', 'K-디지털 트레이닝 (국비지원)', '디지털, AI, 신기술 핵심 실무 중심 교육을 통해 고소득 기술 취업 역량을 극대화하는 과정입니다.', '교육', 19, 34, '2025-07-15', '수강료 전액 지원(국비무료) 및 월 최대 31만 6천원의 훈련장려금 지급', '만 19세~34세 대학 졸업(예정)자 및 미취업 청년, 공학계열 전공자 한정', 'HRD-Net에서 과정 검색 후 온라인 수강 신청', '수강신청서, 재직·졸업증명서, 훈련장려금 지급 계좌 사본', '선착순 접수 및 과정별 사전 상담 통과자 선발', NULL, 'https://www.hrd.go.kr'), +('MOCK-P004', '청년내일채움공제', '중소기업 취업 청년의 자산 형성을 지원하여 장기 근속을 유도하고 대기업과의 격차를 완화합니다.', '일자리', 15, 34, '2025-08-31', '청년이 2년간 300만원을 적립하면 정부와 기업이 적립해 총 1,200만원 자산 마련', '5인 이상 중소기업에 신규 취업한 청년 및 해당 기업', '청년내일채움공제 홈페이지에서 청약 신청', '취업사실확인서, 4대보험 가입내역확인서, 기업 사업자등록증 사본', NULL, '동일 사업장에서 과거 청약 후 중도해지한 이력이 있으면 재가입 제한', 'https://www.sbcof.or.kr'), +('MOCK-P005', '청년 주거급여 분리지급', '주거급여 수급 가구 중 학업이나 취업으로 부모와 타 지역에 거주하는 청년에게 별도로 급여를 지급합니다.', '주거', 19, 30, '2025-12-31', '부모와 떨어진 별도 가구원에 대해 서울 및 해당 지역 기준 임차료 지급', '주거급여 수급 가구원 중 만 19세 이상 30세 미만 미혼 자녀', '주민등록상 부모 거주지 읍면동 행정복지센터 방문 혹은 복지로 온라인 신청', '임대차계약서, 전입세대열람원', NULL, NULL, 'https://www.bokjiro.go.kr'), +('MOCK-P006', '국민내일배움카드', '취업준비생 및 이직 희망 청년이 스스로 직업 능력을 개발하도록 평생교육 배움 비용을 보조합니다.', '교육', NULL, NULL, '2025-12-31', '5년간 300만원에서 최대 500만원까지의 훈련 직무 수강 비용 45~85% 지원', '일반 직장인, 미취업 청년, 졸업 예정 대학생 등 누구나 발급 가능', 'HRD-Net 또는 고용센터 방문을 통한 카드 발급 신청', NULL, NULL, '공무원, 사립학교 교직원, 연 매출 1억 5천만원 이상 자영업자 등은 제외', 'https://www.hrd.go.kr'), +('MOCK-P007', '서울시 청년수당', '서울 거주 미취업 청년들의 안정적인 구직활동과 진로 개척을 돕기 위해 생계 보조 청년 수당을 지급합니다.', '복지·문화', 19, 34, '2025-09-30', '월 50만원씩 최대 6개월간 매월 체크카드 바우처 포인트 형태로 지원금 지급', '서울 거주 만 19세~34세 졸업 후 미취업자 중 중위소득 150% 이하', '청년몽땅정보통 홈페이지에서 온라인 신청', '졸업증명서, 소득금액증명원, 구직활동계획서', '자격요건 서류 심사 후 예산 범위 내 선발', '구직촉진수당 등 유사 목적의 정부 지원사업과 중복 수급 불가', 'https://youth.seoul.go.kr'), +('MOCK-P008', '청년내일저축계좌', '일하는 청년들이 주거, 취업 등으로 인한 자립 기반을 갖출 수 있도록 정부가 목돈을 함께 마련해 줍니다.', '복지·문화', 19, 34, '2025-10-15', '본인 저축 월 10만원당 정부 지원금 10만~30만원을 3년간 1:1 매칭 적립', '일하고 있는 만 19세~34세 이하 청년 중 소득기준 충족자', '복지로 온라인 신청 또는 주소지 관할 행정복지센터 방문 신청', '재직증명서 또는 사업자등록증, 소득금액증명원, 통장사본', NULL, '가입 기간 중 3년 내 근로·사업활동을 지속하지 않으면 지급 제한', 'https://www.bokjiro.go.kr'), +('MOCK-P009', '청년문화예술패스', '사회에 첫 발을 내딛는 청년들에게 다채로운 문화생활과 연극, 전시, 음악 관람 비용을 전폭적으로 제공합니다.', '복지·문화', 19, 19, '2025-11-30', '공연 및 전시 관람료로 사용할 수 있는 포인트 최대 15만원 즉시 지급', '만 19세가 되는 국내 거주 모든 청년 (선착순 신청)', '청년문화예술패스 홈페이지 또는 앱에서 회원가입 후 신청', NULL, NULL, NULL, 'https://www.kopis.or.kr'), +('MOCK-P010', '정부부처 청년인턴', '중앙 행정 기관의 입법, 사업 진행 등의 부처 행정 실무를 인턴십으로 체험하고 참관할 기회를 제공합니다.', '참여·권리', 19, 34, '2025-06-15', '국가 공공 기관 인턴 근무 경력 증명서 발급 및 월 고정 기본급 및 수당 지급', '만 19세 이상 34세 이하의 미취업자 (학력 제한 없음)', '나라일터(잡알리오) 채용 공고를 통한 온라인 접수', '이력서, 자기소개서, 최종학력 증명서', '서류 전형 및 면접 전형', NULL, 'https://www.jobis.or.kr'), +('MOCK-P011', '청년 창업 지원금', '예비 창업자 및 창업 3년 이내 청년에게 사업화 자금과 멘토링을 함께 지원하는 사업입니다.', '일자리', 19, 39, '2025-08-20', '사업화 자금 최대 5천만원 및 전담 멘토 1:1 컨설팅 제공', '만 19세 이상 39세 이하 예비 창업자 및 창업 3년 이내 대표', 'K-스타트업 홈페이지에서 온라인 접수', '사업계획서, 사업자등록증 사본(창업자), 대표자 신분증 사본', '사업계획서 서류 심사 및 발표 평가', '동일 사업 기수 내 중복 선정 불가', 'https://www.k-startup.go.kr'), +('MOCK-P012', '청년 대중교통비 지원 (K-패스)', '월 15회 이상 대중교통을 이용하는 청년에게 지출 금액 일부를 다음 달에 환급해 줍니다.', '복지·문화', 19, 34, '2025-12-31', '월 대중교통 지출액의 30%를 익월 환급 (최대 60회 한도)', '만 19세 이상 34세 이하 K-패스 카드 발급자', 'K-패스 앱 또는 카드사 홈페이지에서 청년 등급 등록', NULL, NULL, NULL, 'https://korea-pass.kr'), +('MOCK-P013', '청년 전세자금 대출 이자 지원', '무주택 청년의 전세자금 대출 이자 부담을 낮춰 안정적인 주거 마련을 지원합니다.', '주거', 19, 39, '2025-09-30', '전세자금 대출 이자의 연 최대 2%p 차액 보전', '경기도 거주 무주택 미혼 청년 중 소득 기준 충족자', '경기도 청년포털에서 온라인 신청', '임대차계약서, 전세자금 대출 실행 확인서, 소득금액증명원', NULL, NULL, 'https://www.gg.go.kr'), +('MOCK-P014', '청년 정책참여단 모집', '청년 정책 기획 단계부터 직접 참여해 의견을 제안하고 정책 모니터링 활동을 수행합니다.', '참여·권리', 19, 34, '2025-07-31', '활동비 지급 및 정책 제안 채택 시 인센티브 제공', '만 19세 이상 34세 이하 청년 누구나 지원 가능', '청년정책 통합 플랫폼에서 온라인 신청', NULL, '지원서 심사 후 선발', '월 1회 정기 회의 2회 이상 불참 시 활동 중단', 'https://youth.go.kr'); diff --git a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java index 2551954..da4d67b 100644 --- a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java @@ -3,17 +3,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.policy.entity.ApplicationChecklist; import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; import com.bop.youthpick.policy.exception.PolicyErrorCode; -import com.bop.youthpick.policy.repository.ApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; import com.bop.youthpick.policy.repository.PolicyApplicationRepository; import com.bop.youthpick.user.entity.User; import java.util.List; @@ -33,7 +34,7 @@ class AdminPolicyApplicationServiceTest { @Mock private PolicyApplicationRepository policyApplicationRepository; - @Mock private ApplicationChecklistRepository applicationChecklistRepository; + @Mock private PolicyApplicationChecklistRepository applicationChecklistRepository; private AdminPolicyApplicationService adminPolicyApplicationService; @@ -76,15 +77,16 @@ private PolicyApplication mockApplication() { void 체크리스트를_조회한다() { when(policyApplicationRepository.findById(APPLICATION_ID)) .thenReturn(Optional.of(mock(PolicyApplication.class))); - ApplicationChecklist item = mock(ApplicationChecklist.class); + PolicyApplicationChecklist item = mock(PolicyApplicationChecklist.class); PolicyApplication application = mock(PolicyApplication.class); when(item.getId()).thenReturn(100L); when(item.getApplication()).thenReturn(application); when(application.getId()).thenReturn(APPLICATION_ID); when(item.isChecked()).thenReturn(true); when(item.getContent()).thenReturn("주민등록등본 제출"); - when(applicationChecklistRepository.findByApplicationIdOrderByIdAsc(APPLICATION_ID)) - .thenReturn(List.of(item)); + when(applicationChecklistRepository.findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + eq(APPLICATION_ID), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(item))); List result = adminPolicyApplicationService.getChecklist(APPLICATION_ID); diff --git a/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java b/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java index 7b7f607..77a532d 100644 --- a/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java +++ b/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java @@ -24,12 +24,14 @@ class ApiResponseTest { @Test void 페이지_응답은_meta에_page_totalCount_totalPages가_담긴다() throws Exception { + // PageRequest.of(1, 2)는 Spring Data 내부 규약상 0-based 두 번째 페이지 -> 응답 meta.page는 + // 1-based로 변환되어 2가 된다. PageImpl page = new PageImpl<>(List.of("a", "b"), PageRequest.of(1, 2), 5); String json = objectMapper.writeValueAsString(ApiResponse.ok(page.getContent(), page)); JsonNode meta = objectMapper.readTree(json).get("meta"); - assertThat(meta.get("page").asInt()).isEqualTo(1); + assertThat(meta.get("page").asInt()).isEqualTo(2); assertThat(meta.get("totalCount").asLong()).isEqualTo(5); assertThat(meta.get("totalPages").asInt()).isEqualTo(3); } diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java new file mode 100644 index 0000000..a1fccfe --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java @@ -0,0 +1,255 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.policy.dto.PolicyApplicationChecklistResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import com.bop.youthpick.policy.service.PolicyApplicationChecklistService; +import com.bop.youthpick.user.entity.User; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = PolicyApplicationChecklistController.class) +@AutoConfigureMockMvc(addFilters = false) +class PolicyApplicationChecklistControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private PolicyApplicationChecklistService checklistService; + + private PolicyApplicationChecklist checklist() { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.APPLIED, + null, + null); + return PolicyApplicationChecklist.create(application, "제출 서류 준비"); + } + + @Test + void add_유효한_요청이면_201과_생성된_체크리스트를_반환한다() throws Exception { + when(checklistService.add(1L, 1L, "제출 서류 준비")).thenReturn(checklist()); + + String body = + """ + { + "applicationId": 1, + "message": "제출 서류 준비" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + post("/api/v1/policy-application-checklists") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.message").value("제출 서류 준비")) + .andExpect(jsonPath("$.data.checked").value(false))); + } + + @Test + void add_message가_비어있으면_400과_C001을_반환한다() throws Exception { + String body = + """ + { + "applicationId": 1, + "message": "" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + post("/api/v1/policy-application-checklists") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void getByApplication_신청관리별_체크리스트_목록을_반환한다() throws Exception { + Page page = + new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + when(checklistService.getByApplication(eq(1L), eq(1L), any())).thenReturn(page); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + get( + "/api/v1/policy-application-checklists/application/{applicationId}", + 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.meta.page").value(1)) + .andExpect(jsonPath("$.meta.totalCount").value(0)) + .andExpect(jsonPath("$.meta.totalPages").value(0))); + } + + @Test + void update_유효한_요청이면_200과_수정된_체크리스트를_반환한다() throws Exception { + PolicyApplicationChecklist updated = checklist(); + updated.updateContent("서류 다시 준비"); + when(checklistService.update(5L, 1L, "서류 다시 준비")).thenReturn(updated); + + String body = + """ + { + "message": "서류 다시 준비" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-application-checklists/{id}", 5L) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("서류 다시 준비"))); + } + + @Test + void update_message가_비어있으면_400과_C001을_반환한다() throws Exception { + String body = + """ + { + "message": "" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-application-checklists/{id}", 5L) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void check_성공하면_200과_체크_완료_메시지를_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch( + "/api/v1/policy-application-checklists/{id}/check", + 5L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("체크 완료"))); + + verify(checklistService).check(5L, 1L); + } + + @Test + void uncheck_성공하면_200과_체크_해제_완료_메시지를_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch( + "/api/v1/policy-application-checklists/{id}/uncheck", + 5L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("체크 해제 완료"))); + + verify(checklistService).uncheck(5L, 1L); + } + + @Test + void delete_성공하면_200과_체크리스트_삭제_완료_메시지를_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform(delete("/api/v1/policy-application-checklists/{id}", 5L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("체크리스트 삭제 완료"))); + + verify(checklistService).delete(5L, 1L); + } + + @Test + void getByApplication_applicationId가_숫자가_아니면_400과_C001을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + get( + "/api/v1/policy-application-checklists/application/{applicationId}", + "abc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void check_id가_숫자가_아니면_400과_C001을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch( + "/api/v1/policy-application-checklists/{id}/check", + "abc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void delete_id가_숫자가_아니면_400과_C001을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform(delete("/api/v1/policy-application-checklists/{id}", "abc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + /** + * addFilters=false로 시큐리티 필터 체인(JwtAuthenticationFilter 포함)을 건너뛰므로, SecurityContextHolder를 직접 + * 채워 @CurrentUser를 해석시킨다. + */ + private void withAuthenticatedPrincipal(ThrowingRunnable runnable) throws Exception { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(1L, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + try { + runnable.run(); + } finally { + SecurityContextHolder.clearContext(); + } + } + + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java new file mode 100644 index 0000000..aab8645 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java @@ -0,0 +1,422 @@ +package com.bop.youthpick.policy.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.policy.dto.PolicyApplicationResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.service.PolicyApplicationService; +import com.bop.youthpick.user.entity.User; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = PolicyApplicationController.class) +@AutoConfigureMockMvc(addFilters = false) +class PolicyApplicationControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private PolicyApplicationService policyApplicationService; + + @Test + void 유효한_요청이면_201과_등록된_신청관리를_반환한다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + "메모", + null); + when(policyApplicationService.register( + eq(1L), eq(2L), eq(ApplicationStatus.INTERESTED), any(), any())) + .thenReturn(application); + + String body = + """ + { + "policyId": 2, + "status": "INTERESTED", + "memo": "메모" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + post("/api/v1/policy-applications") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.status").value("INTERESTED"))); + } + + @Test + void 등록시_PREPARING도_유효한_상태값이다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.PREPARING, + null, + null); + when(policyApplicationService.register( + eq(1L), eq(2L), eq(ApplicationStatus.PREPARING), any(), any())) + .thenReturn(application); + + String body = + """ + { + "policyId": 2, + "status": "PREPARING" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + post("/api/v1/policy-applications") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.status").value("PREPARING"))); + } + + @Test + void status값이_유효하지_않으면_400과_C001을_반환한다() throws Exception { + String body = + """ + { + "policyId": 2, + "status": "UNKNOWN" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + post("/api/v1/policy-applications") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void endAt_형식이_잘못되면_400과_C001을_반환한다() throws Exception { + String body = + """ + { + "policyId": 2, + "status": "INTERESTED", + "endAt": "not-a-date" + } + """; + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + post("/api/v1/policy-applications") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void getApplications_사용자의_신청관리_목록을_반환한다() throws Exception { + Page page = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + when(policyApplicationService.getApplications(eq(1L), any())).thenReturn(page); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform(get("/api/v1/policy-applications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.meta.page").value(1)) + .andExpect(jsonPath("$.meta.totalCount").value(0)) + .andExpect(jsonPath("$.meta.totalPages").value(0))); + } + + @Test + void getApplications_page가_0이거나_음수여도_첫_페이지로_보정된다() throws Exception { + Page page = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + when(policyApplicationService.getApplications(eq(1L), any())).thenReturn(page); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform(get("/api/v1/policy-applications").param("page", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.page").value(1))); + withAuthenticatedPrincipal( + () -> + mockMvc.perform(get("/api/v1/policy-applications").param("page", "-5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.page").value(1))); + + verify(policyApplicationService, times(2)) + .getApplications(eq(1L), pageableCaptor.capture()); + assertThat(pageableCaptor.getAllValues()) + .allSatisfy(pageable -> assertThat(pageable.getPageNumber()).isZero()); + } + + @Test + void changeStatus_유효한_요청이면_200과_변경된_상태를_반환한다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.APPLIED, + null, + null); + when(policyApplicationService.changeStatus(10L, 1L, ApplicationStatus.APPLIED)) + .thenReturn(application); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/status", 10L) + .param("status", "APPLIED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("APPLIED"))); + + verify(policyApplicationService).changeStatus(10L, 1L, ApplicationStatus.APPLIED); + } + + @Test + void changeStatus_PREPARING도_유효한_상태값이다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.PREPARING, + null, + null); + when(policyApplicationService.changeStatus(10L, 1L, ApplicationStatus.PREPARING)) + .thenReturn(application); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/status", 10L) + .param("status", "PREPARING")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PREPARING"))); + } + + @Test + void changeStatus_status값이_유효하지_않으면_400과_C001을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/status", 10L) + .param("status", "UNKNOWN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void changeStatus_status_파라미터를_생략하면_400과_C001을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform(patch("/api/v1/policy-applications/{id}/status", 10L)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void updateMemo_유효한_요청이면_200과_수정된_메모를_반환한다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + "새 메모", + null); + when(policyApplicationService.updateMemo(eq(10L), eq(1L), eq("새 메모"))) + .thenReturn(application); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/memo", 10L) + .param("memo", "새 메모")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memo").value("새 메모"))); + } + + @Test + void updateMemo_2000자를_초과하면_400과_C001을_반환한다() throws Exception { + String tooLongMemo = "a".repeat(2001); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/memo", 10L) + .param("memo", tooLongMemo)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void updateMemo_파라미터를_생략하면_400과_C001을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform(patch("/api/v1/policy-applications/{id}/memo", 10L)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void updateMemo_빈_문자열이면_메모를_비운다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + "", + null); + when(policyApplicationService.updateMemo(eq(10L), eq(1L), eq(""))).thenReturn(application); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/memo", 10L) + .param("memo", "")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memo").value(""))); + } + + @Test + void updateEndAt_유효한_요청이면_200과_수정된_마감일을_반환한다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + LocalDateTime.of(2026, 12, 31, 23, 59)); + when(policyApplicationService.updateEndAt(eq(10L), eq(1L), any())).thenReturn(application); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/end-at", 10L) + .param("endAt", "2026-12-31T23:59:00")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.endAt").value("2026-12-31T23:59:00"))); + } + + @Test + void updateEndAt_빈_문자열이면_마감일을_비운다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + when(policyApplicationService.updateEndAt(eq(10L), eq(1L), eq(null))) + .thenReturn(application); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/end-at", 10L) + .param("endAt", "")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.endAt").doesNotExist())); + } + + @Test + void updateEndAt_파라미터를_생략해도_마감일을_비운다() throws Exception { + PolicyApplication application = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + when(policyApplicationService.updateEndAt(eq(10L), eq(1L), eq(null))) + .thenReturn(application); + + withAuthenticatedPrincipal( + () -> + mockMvc.perform(patch("/api/v1/policy-applications/{id}/end-at", 10L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.endAt").doesNotExist())); + } + + @Test + void updateEndAt_형식이_잘못되면_400과_C001을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/end-at", 10L) + .param("endAt", "not-a-date")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void delete_성공하면_200을_반환한다() throws Exception { + withAuthenticatedPrincipal( + () -> + mockMvc.perform(delete("/api/v1/policy-applications/{id}", 10L)) + .andExpect(status().isOk())); + + verify(policyApplicationService).delete(10L, 1L); + } + + /** + * addFilters=false로 시큐리티 필터 체인(JwtAuthenticationFilter 포함)을 건너뛰므로, SecurityContextHolder를 직접 + * 채워 @CurrentUser를 해석시킨다. + */ + private void withAuthenticatedPrincipal(ThrowingRunnable runnable) throws Exception { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(1L, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + try { + runnable.run(); + } finally { + SecurityContextHolder.clearContext(); + } + } + + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepositoryTest.java similarity index 71% rename from src/test/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepositoryTest.java rename to src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepositoryTest.java index d328e96..dc5da6e 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/ApplicationChecklistRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepositoryTest.java @@ -3,10 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import com.bop.youthpick.global.config.JpaAuditingConfig; -import com.bop.youthpick.policy.entity.ApplicationChecklist; import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.repository.UserRepository; @@ -19,13 +19,14 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; @DataJpaTest @Import(JpaAuditingConfig.class) -class ApplicationChecklistRepositoryTest { +class PolicyApplicationChecklistRepositoryTest { - @Autowired private ApplicationChecklistRepository applicationChecklistRepository; + @Autowired private PolicyApplicationChecklistRepository applicationChecklistRepository; @Autowired private UserRepository userRepository; @@ -55,7 +56,8 @@ void setUp() { } private void saveChecklistItem(PolicyApplication application, String content) { - ApplicationChecklist item = BeanUtils.instantiateClass(ApplicationChecklist.class); + PolicyApplicationChecklist item = + BeanUtils.instantiateClass(PolicyApplicationChecklist.class); ReflectionTestUtils.setField(item, "application", application); ReflectionTestUtils.setField(item, "content", content); ReflectionTestUtils.setField(item, "checked", false); @@ -64,30 +66,38 @@ private void saveChecklistItem(PolicyApplication application, String content) { @Test void 체크리스트는_id_오름차순으로_조회된다() { - List result = - applicationChecklistRepository.findByApplicationIdOrderByIdAsc(applicationId); + List result = + applicationChecklistRepository + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .getContent(); assertThat(result) - .extracting(ApplicationChecklist::getContent) + .extracting(PolicyApplicationChecklist::getContent) .containsExactly("서류 A", "서류 B", "서류 C"); } @Test void 관리_해제된_체크리스트는_조회에서_제외된다() { - ApplicationChecklist target = + PolicyApplicationChecklist target = applicationChecklistRepository - .findByApplicationIdOrderByIdAsc(applicationId) + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .getContent() .get(0); ReflectionTestUtils.setField(target, "deletedAt", LocalDateTime.now()); entityManager.persistAndFlush(target); entityManager.clear(); - List result = - applicationChecklistRepository.findByApplicationIdOrderByIdAsc(applicationId); + List result = + applicationChecklistRepository + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .getContent(); assertThat(result).hasSize(2); assertThat(result) - .extracting(ApplicationChecklist::getContent) + .extracting(PolicyApplicationChecklist::getContent) .containsExactly("서류 B", "서류 C"); } } diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java new file mode 100644 index 0000000..7827787 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java @@ -0,0 +1,354 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyApplicationChecklistResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; +import com.bop.youthpick.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class PolicyApplicationChecklistServiceTest { + + @Mock private PolicyApplicationChecklistRepository applicationChecklistRepository; + @Mock private PolicyApplicationRepository policyApplicationRepository; + + private PolicyApplicationChecklistService checklistService; + + private static final Long APPLICATION_ID = 1L; + private static final Long USER_ID = 1L; + private static final Long OTHER_USER_ID = 999L; + + @BeforeEach + void setUp() { + checklistService = + new PolicyApplicationChecklistService( + applicationChecklistRepository, policyApplicationRepository); + } + + /** 소유자가 USER_ID인 활성 신청. owner.getId()는 소유권 검증에 안 걸리는 테스트에선 안 쓰일 수 있어 lenient로 둔다. */ + private PolicyApplication application() { + User owner = mock(User.class); + lenient().when(owner.getId()).thenReturn(USER_ID); + return PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.APPLIED, null, null); + } + + @Test + void add_체크리스트를_정상적으로_생성한다() { + PolicyApplication application = application(); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) + .thenReturn(Optional.of(application)); + when(applicationChecklistRepository.save(any(PolicyApplicationChecklist.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PolicyApplicationChecklist result = + checklistService.add(APPLICATION_ID, USER_ID, "제출 서류 준비"); + + assertThat(result.getContent()).isEqualTo("제출 서류 준비"); + assertThat(result.isChecked()).isFalse(); + } + + @Test + void add_대상_신청관리가_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> checklistService.add(APPLICATION_ID, USER_ID, "제출 서류 준비")) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void add_소유자가_아니면_FORBIDDEN_예외를_던진다() { + PolicyApplication application = application(); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) + .thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> checklistService.add(APPLICATION_ID, OTHER_USER_ID, "제출 서류 준비")) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void update_체크리스트_내용을_수정한다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + PolicyApplicationChecklist result = checklistService.update(5L, USER_ID, "서류 다시 준비"); + + assertThat(result.getContent()).isEqualTo("서류 다시 준비"); + } + + @Test + void update_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> checklistService.update(5L, USER_ID, "수정")) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void update_소유자가_아니면_FORBIDDEN_예외를_던진다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.update(5L, OTHER_USER_ID, "수정")) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void update_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + PolicyApplication deletedApplication = application(); + deletedApplication.delete(); + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.update(5L, USER_ID, "수정")) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void check_체크상태로_변경한다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + checklistService.check(5L, USER_ID); + + assertThat(checklist.isChecked()).isTrue(); + } + + @Test + void uncheck_체크해제_상태로_변경한다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + checklist.check(); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + checklistService.uncheck(5L, USER_ID); + + assertThat(checklist.isChecked()).isFalse(); + } + + @Test + void check_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> checklistService.check(5L, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void uncheck_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> checklistService.uncheck(5L, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void delete_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> checklistService.delete(5L, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void delete_soft_delete한다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + checklistService.delete(5L, USER_ID); + + assertThat(checklist.getDeletedAt()).isNotNull(); + } + + @Test + void check_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + PolicyApplication deletedApplication = application(); + deletedApplication.delete(); + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.check(5L, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void uncheck_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + PolicyApplication deletedApplication = application(); + deletedApplication.delete(); + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); + checklist.check(); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.uncheck(5L, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void delete_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + PolicyApplication deletedApplication = application(); + deletedApplication.delete(); + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.delete(5L, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + @Test + void check_소유자가_아니면_FORBIDDEN_예외를_던진다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.check(5L, OTHER_USER_ID)) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void uncheck_소유자가_아니면_FORBIDDEN_예외를_던진다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + checklist.check(); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.uncheck(5L, OTHER_USER_ID)) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void delete_소유자가_아니면_FORBIDDEN_예외를_던진다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + .thenReturn(Optional.of(checklist)); + + assertThatThrownBy(() -> checklistService.delete(5L, OTHER_USER_ID)) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void getByApplication_신청관리별_체크리스트_목록을_반환한다() { + PolicyApplication application = application(); + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application, "제출 서류 준비"); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(checklist), pageable, 1); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) + .thenReturn(Optional.of(application)); + when(applicationChecklistRepository.findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + APPLICATION_ID, pageable)) + .thenReturn(page); + + Page result = + checklistService.getByApplication(APPLICATION_ID, USER_ID, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).message()).isEqualTo("제출 서류 준비"); + } + + @Test + void getByApplication_대상_신청관리가_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + checklistService.getByApplication( + APPLICATION_ID, USER_ID, PageRequest.of(0, 20))) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void getByApplication_소유자가_아니면_FORBIDDEN_예외를_던진다() { + PolicyApplication application = application(); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) + .thenReturn(Optional.of(application)); + + assertThatThrownBy( + () -> + checklistService.getByApplication( + APPLICATION_ID, OTHER_USER_ID, PageRequest.of(0, 20))) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java new file mode 100644 index 0000000..b2dcbb9 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java @@ -0,0 +1,592 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyApplicationResponse; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class PolicyApplicationServiceTest { + + @Mock private PolicyApplicationRepository policyApplicationRepository; + @Mock private UserRepository userRepository; + @Mock private PolicyRepository policyRepository; + @Mock private PolicyApplicationChecklistRepository policyApplicationChecklistRepository; + + private PolicyApplicationService policyApplicationService; + + private static final Long USER_ID = 1L; + private static final Long POLICY_ID = 2L; + + @BeforeEach + void setUp() { + policyApplicationService = + new PolicyApplicationService( + policyApplicationRepository, + userRepository, + policyRepository, + policyApplicationChecklistRepository); + } + + @Test + void 신규_신청관리를_정상적으로_등록한다() { + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); + when(policyApplicationRepository.save(any(PolicyApplication.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PolicyApplication result = + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", null); + + assertThat(result.getStatus()).isEqualTo(ApplicationStatus.INTERESTED); + assertThat(result.getMemo()).isEqualTo("메모"); + } + + @Test + void 마감일을_지정하지_않으면_정책의_신청_마감일을_기본값으로_사용한다() { + Policy policy = mock(Policy.class); + when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyApplicationRepository.save(any(PolicyApplication.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PolicyApplication result = + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", null); + + assertThat(result.getEndAt()).isEqualTo(LocalDateTime.of(2026, 8, 31, 0, 0)); + } + + @Test + void 마감일을_직접_지정하면_정책_마감일_대신_그_값을_사용한다() { + Policy policy = mock(Policy.class); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyApplicationRepository.save(any(PolicyApplication.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + LocalDateTime explicitEndAt = LocalDateTime.of(2026, 9, 15, 18, 0); + PolicyApplication result = + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", explicitEndAt); + + assertThat(result.getEndAt()).isEqualTo(explicitEndAt); + } + + @Test + void 마감일이_정책_마감일과_같으면_등록을_허용한다() { + Policy policy = mock(Policy.class); + when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyApplicationRepository.save(any(PolicyApplication.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + LocalDateTime sameDayEndAt = LocalDateTime.of(2026, 8, 31, 23, 59); + PolicyApplication result = + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", sameDayEndAt); + + assertThat(result.getEndAt()).isEqualTo(sameDayEndAt); + } + + @Test + void 마감일이_정책_마감일을_넘으면_END_AT_AFTER_POLICY_DEADLINE_예외를_던진다() { + Policy policy = mock(Policy.class); + when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + + LocalDateTime tooLateEndAt = LocalDateTime.of(2026, 9, 1, 0, 0); + assertThatThrownBy( + () -> + policyApplicationService.register( + USER_ID, + POLICY_ID, + ApplicationStatus.INTERESTED, + "메모", + tooLateEndAt)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.END_AT_AFTER_POLICY_DEADLINE); + } + + @Test + void 메모가_빈_문자열이면_null로_저장한다() { + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); + when(policyApplicationRepository.save(any(PolicyApplication.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PolicyApplication result = + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, " ", null); + + assertThat(result.getMemo()).isNull(); + } + + @Test + void 이미_등록된_행이_있으면_POLICY_ALREADY_EXISTS_예외를_던진다() { + PolicyApplication existing = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + assertThatThrownBy( + () -> + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.APPLIED, null, null)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_ALREADY_EXISTS); + } + + @Test + void soft_delete된_행이_있으면_재활성화한다() { + PolicyApplication existing = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + existing.delete(); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); + + assertThat(result).isSameAs(existing); + assertThat(result.getStatus()).isEqualTo(ApplicationStatus.APPLIED); + assertThat(result.getMemo()).isEqualTo("재등록"); + assertThat(result.isDeleted()).isFalse(); + } + + @Test + void soft_delete된_행을_재활성화할때_마감일을_지정하지_않으면_정책_마감일을_사용한다() { + Policy policy = mock(Policy.class); + when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 10, 1)); + PolicyApplication existing = + PolicyApplication.register( + mock(User.class), policy, ApplicationStatus.INTERESTED, null, null); + existing.delete(); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); + + assertThat(result.getEndAt()).isEqualTo(LocalDateTime.of(2026, 10, 1, 0, 0)); + } + + @Test + void soft_delete된_행을_재활성화하면_이전_체크리스트를_모두_소프트딜리트한다() { + PolicyApplication existing = + PolicyApplication.register( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + existing.delete(); + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + policyApplicationService.register( + USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); + + verify(policyApplicationChecklistRepository).softDeleteAllByApplicationId(existing.getId()); + } + + @Test + void 저장_시점에_동시요청으로_유니크_제약이_위반되면_POLICY_ALREADY_EXISTS_예외를_던진다() { + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); + when(policyApplicationRepository.save(any(PolicyApplication.class))) + .thenThrow( + new DataIntegrityViolationException("uk_policy_applications_user_policy")); + + assertThatThrownBy( + () -> + policyApplicationService.register( + USER_ID, + POLICY_ID, + ApplicationStatus.INTERESTED, + null, + null)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_ALREADY_EXISTS); + } + + @Test + void 존재하지_않는_사용자면_USER_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + policyApplicationService.register( + USER_ID, + POLICY_ID, + ApplicationStatus.INTERESTED, + null, + null)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.USER_NOT_FOUND); + } + + @Test + void 존재하지_않는_정책이면_POLICY_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + policyApplicationService.register( + USER_ID, + POLICY_ID, + ApplicationStatus.INTERESTED, + null, + null)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_NOT_FOUND); + } + + @Test + void changeStatus_성공() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = + policyApplicationService.changeStatus(10L, USER_ID, ApplicationStatus.APPLIED); + + assertThat(result.getStatus()).isEqualTo(ApplicationStatus.APPLIED); + } + + @Test + void changeStatus_대상이_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + policyApplicationService.changeStatus( + 10L, USER_ID, ApplicationStatus.APPLIED)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void changeStatus_소유자가_아니면_FORBIDDEN_예외를_던진다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + Long otherUserId = 999L; + assertThatThrownBy( + () -> + policyApplicationService.changeStatus( + 10L, otherUserId, ApplicationStatus.APPLIED)) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void updateMemo_성공() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, "기존 메모", null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = policyApplicationService.updateMemo(10L, USER_ID, "새 메모"); + + assertThat(result.getMemo()).isEqualTo("새 메모"); + } + + @Test + void updateMemo_빈_문자열이면_null로_비운다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, "기존 메모", null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = policyApplicationService.updateMemo(10L, USER_ID, " "); + + assertThat(result.getMemo()).isNull(); + } + + @Test + void updateMemo_대상이_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> policyApplicationService.updateMemo(10L, USER_ID, "메모")) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void updateMemo_소유자가_아니면_FORBIDDEN_예외를_던진다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + Long otherUserId = 999L; + assertThatThrownBy(() -> policyApplicationService.updateMemo(10L, otherUserId, "메모")) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void updateEndAt_성공() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + LocalDateTime newEndAt = LocalDateTime.of(2026, 12, 31, 23, 59); + PolicyApplication result = policyApplicationService.updateEndAt(10L, USER_ID, newEndAt); + + assertThat(result.getEndAt()).isEqualTo(newEndAt); + } + + @Test + void updateEndAt_null이면_마감일을_비운다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + LocalDateTime.of(2026, 12, 31, 23, 59)); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = policyApplicationService.updateEndAt(10L, USER_ID, null); + + assertThat(result.getEndAt()).isNull(); + } + + @Test + void updateEndAt_정책_마감일을_넘으면_END_AT_AFTER_POLICY_DEADLINE_예외를_던진다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + Policy policy = mock(Policy.class); + when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); + PolicyApplication existing = + PolicyApplication.register(owner, policy, ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + LocalDateTime tooLateEndAt = LocalDateTime.of(2026, 9, 1, 0, 0); + assertThatThrownBy(() -> policyApplicationService.updateEndAt(10L, USER_ID, tooLateEndAt)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.END_AT_AFTER_POLICY_DEADLINE); + assertThat(existing.getEndAt()).isNull(); + } + + @Test + void updateEndAt_null로_비울때는_정책_마감일_검증을_건너뛴다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + Policy policy = mock(Policy.class); + PolicyApplication existing = + PolicyApplication.register( + owner, + policy, + ApplicationStatus.INTERESTED, + null, + LocalDateTime.of(2026, 8, 1, 0, 0)); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = policyApplicationService.updateEndAt(10L, USER_ID, null); + + assertThat(result.getEndAt()).isNull(); + } + + @Test + void updateEndAt_대상이_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> policyApplicationService.updateEndAt(10L, USER_ID, null)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void updateEndAt_소유자가_아니면_FORBIDDEN_예외를_던진다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + Long otherUserId = 999L; + assertThatThrownBy(() -> policyApplicationService.updateEndAt(10L, otherUserId, null)) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void delete_성공() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + policyApplicationService.delete(10L, USER_ID); + + assertThat(existing.isDeleted()).isTrue(); + } + + @Test + void delete_대상이_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> policyApplicationService.delete(10L, USER_ID)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND); + } + + @Test + void delete_소유자가_아니면_FORBIDDEN_예외를_던진다() { + User owner = mock(User.class); + when(owner.getId()).thenReturn(USER_ID); + PolicyApplication existing = + PolicyApplication.register( + owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); + when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) + .thenReturn(Optional.of(existing)); + + Long otherUserId = 999L; + assertThatThrownBy(() -> policyApplicationService.delete(10L, otherUserId)) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.FORBIDDEN); + } + + @Test + void getApplications_사용자의_신청관리_목록을_반환한다() { + Policy policy = mock(Policy.class); + when(policy.getId()).thenReturn(POLICY_ID); + when(policy.getTitle()).thenReturn("정책제목"); + when(policy.getCategory()).thenReturn("일자리"); + when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 12, 31)); + PolicyApplication application = + PolicyApplication.register( + mock(User.class), policy, ApplicationStatus.APPLIED, "메모", null); + Pageable pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of(application), pageable, 1); + when(policyApplicationRepository.findByUser_IdAndDeletedAtIsNull(USER_ID, pageable)) + .thenReturn(page); + + Page result = + policyApplicationService.getApplications(USER_ID, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).policyTitle()).isEqualTo("정책제목"); + assertThat(result.getContent().get(0).status()).isEqualTo(ApplicationStatus.APPLIED); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 0ac4a29..80b5460 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -15,6 +15,9 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} + web: + pageable: + one-indexed-parameters: true youthpick: jwt: From 8923fb6c970bff639acc9009152d245fe971dbce Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Mon, 20 Jul 2026 16:33:04 +0900 Subject: [PATCH 38/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=EB=B3=84=20STO?= =?UTF-8?q?MP=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 정책별 STOMP 실시간 채팅 지원 (#88) * fix: PR #89 리뷰 지적사항 반영 (STOMP 채팅) - 채팅 이력 조회에 페이지 크기 상한(50) 적용해 무제한 로드 방지 - applicationTaskExecutor에 maxPoolSize/queueCapacity/CallerRunsPolicy 설정 - publishAfterCommit이 트랜잭션 없이 호출돼도 안전하게 즉시 발행하도록 가드 - STOMP DISCONNECT 정리를 SessionDisconnectEvent 리스너로 일원화(인터셉터 중복 제거) - 자가검증 어설션·에러코드 미검증 등 테스트 취약점 보강 Kilo Code 자동 리뷰의 나머지 지적(VARCHAR 바이트 길이, Flyway 테스트, deletedAt 미사용)은 근거 확인 결과 오탐/설계 의도로 판단해 반영하지 않음. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- build.gradle | 1 + docs/api-spec-db.md | 16 +- docs/api-spec.md | 63 ++-- .../auth/service/JwtAuthenticationFilter.java | 5 + .../auth/service/JwtStompAuthentication.java | 32 ++ .../global/config/SecurityConfig.java | 13 +- .../global/config/TaskExecutionConfig.java | 24 ++ .../global/config/WebSocketConfig.java | 51 ++++ .../global/error/GlobalExceptionHandler.java | 10 +- .../controller/PolicyChatController.java | 32 ++ .../PolicyChatMessageController.java | 89 ++++++ .../policy/dto/PolicyChatErrorResponse.java | 3 + .../dto/PolicyChatMessageCreateRequest.java | 9 + .../policy/dto/PolicyChatMessageResponse.java | 30 ++ .../dto/PolicyChatMessagesResponse.java | 14 + .../policy/entity/PolicyChatMessage.java | 56 ++++ .../PolicyChatMessageRepository.java | 14 + .../service/PolicyChatAccessService.java | 24 ++ .../service/PolicyChatInboundInterceptor.java | 172 +++++++++++ .../service/PolicyChatOutboundMessage.java | 39 +++ .../policy/service/PolicyChatService.java | 60 ++++ .../PolicyChatSessionLifecycleListener.java | 18 ++ .../service/PolicyChatStompPublisher.java | 47 +++ .../PolicyChatSubscriptionRegistry.java | 75 +++++ ...te_policy_applications_status_comment.sql} | 2 +- .../V4__seed_local_sample_policies.sql | 23 -- .../V9__create_policy_chat_messages.sql | 14 + .../service/JwtAuthenticationFilterTest.java | 5 + .../config/FlywayMigrationLocationsTest.java | 20 ++ .../global/config/SecurityConfigTest.java | 20 ++ .../controller/PolicyChatControllerTest.java | 85 ++++++ .../PolicyChatHistoryIntegrationTest.java | 102 +++++++ .../PolicyChatMessageControllerTest.java | 65 ++++ .../PolicyChatWebSocketIntegrationTest.java | 277 ++++++++++++++++++ .../PolicyChatMessageRepositoryTest.java | 65 ++++ .../service/PolicyChatAccessServiceTest.java | 33 +++ .../PolicyChatCommitIntegrationTest.java | 94 ++++++ .../PolicyChatInboundInterceptorTest.java | 227 ++++++++++++++ .../policy/service/PolicyChatServiceTest.java | 186 ++++++++++++ ...olicyChatSessionLifecycleListenerTest.java | 25 ++ .../service/PolicyChatStompPublisherTest.java | 128 ++++++++ 41 files changed, 2211 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/auth/service/JwtStompAuthentication.java create mode 100644 src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java create mode 100644 src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java create mode 100644 src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java create mode 100644 src/main/java/com/bop/youthpick/policy/controller/PolicyChatMessageController.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyChatErrorResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageCreateRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessagesResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/entity/PolicyChatMessage.java create mode 100644 src/main/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepository.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyChatOutboundMessage.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyChatService.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListener.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyChatStompPublisher.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyChatSubscriptionRegistry.java rename src/main/resources/db/migration/{V3__update_policy_applications_status_comment.sql => V10__update_policy_applications_status_comment.sql} (90%) delete mode 100644 src/main/resources/db/migration/V4__seed_local_sample_policies.sql create mode 100644 src/main/resources/db/migration/V9__create_policy_chat_messages.sql create mode 100644 src/test/java/com/bop/youthpick/global/config/FlywayMigrationLocationsTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyChatControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyChatHistoryIntegrationTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyChatMessageControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyChatWebSocketIntegrationTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepositoryTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyChatCommitIntegrationTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyChatServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListenerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyChatStompPublisherTest.java diff --git a/build.gradle b/build.gradle index c1c0e6d..631685c 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' diff --git a/docs/api-spec-db.md b/docs/api-spec-db.md index 9775108..c7de44b 100644 --- a/docs/api-spec-db.md +++ b/docs/api-spec-db.md @@ -6,7 +6,6 @@ |---|---|---|---|---|---|---|---| | 정책 수집 실행 | POST | `/api/v1/admin/policy-sync-jobs` | 관리자 | SYNC | 확정 | | | | 로그아웃 | POST | `/api/v1/auth/logout` | 회원 | AUTH | 확정 | | | -| 정책 탐색 챗봇 질문 | POST | `/api/v1/policy-chat/queries` | 비회원 | CHAT | 확정 | | | | 관심정책 등록 | PUT | `/api/v1/me/favorites/{policyId}` | 회원 | FAV | 확정 | | | | 정책 비교 생성 | POST | `/api/v1/policy-comparisons` | 비회원 | CMP | 확정 | | | | OAuth 인가 URL 생성 | GET | `/api/v1/auth/oauth/{provider}/authorization-url` | 비회원 | AUTH | 확정 | | | @@ -18,7 +17,6 @@ | 맞춤정책 조회 | GET | `/api/v1/me/recommended-policies` | 회원 | REC | 확정 | | | | 회원 탈퇴 | DELETE | `/api/v1/auth/me` | 회원 | AUTH | 확정 | | | | 정책 상세 조회 | GET | `/api/v1/policies/{policyId}` | 비회원 | SEARCH | 확정 | | | -| 챗봇 프로필 사용 동의 설정 | POST | `/api/v1/policy-chat/profile-consent` | 회원 | CHAT | 확정 | | | | 읽음 상태 목록 조회 | GET | `/api/v1/me/policy-read-states` | 회원 | READ | 확정 | | | | 관심정책 해제 | DELETE | `/api/v1/me/favorites/{policyId}` | 회원 | FAV | 확정 | | | | 관심정책 목록 | GET | `/api/v1/me/favorites` | 회원 | FAV | 확정 | | | @@ -27,3 +25,17 @@ | 내 로그인 사용자 조회 | GET | `/api/v1/auth/me` | 회원 | AUTH | 확정 | | | | 정책 비교 조회 | GET | `/api/v1/policy-comparisons/{comparisonId}` | 비회원 | CMP | 확정 | | | | 정책 검색/필터 | GET | `/api/v1/policies` | 비회원 | SEARCH | 확정 | | | + +## 정책 채팅 DB 스키마 + +`policy_chat_messages`는 정책별 회원 대화 메시지를 저장한다. 삭제되지 않은 정책 채팅 이력은 `(policy_id, id)` 인덱스로 커서 조회한다. + +| 테이블 | 컬럼 | 제약 | 설명 | +|---|---|---|---| +| `policy_chat_messages` | `id` | PK, auto increment | 메시지 ID | +| `policy_chat_messages` | `policy_id` | NOT NULL, FK `policies(id)`, index `(policy_id, id)` | 메시지가 속한 정책 | +| `policy_chat_messages` | `user_id` | NOT NULL, FK `users(id)`, index | 작성 회원 | +| `policy_chat_messages` | `content` | NOT NULL, `VARCHAR(1000)` | 메시지 본문 | +| `policy_chat_messages` | `created_at` | NOT NULL, 기본 `CURRENT_TIMESTAMP` | 생성 시각 | +| `policy_chat_messages` | `updated_at` | NOT NULL, 기본 `CURRENT_TIMESTAMP`, 수정 시 자동 갱신 | 수정 시각 | +| `policy_chat_messages` | `deleted_at` | NULL | 소프트 삭제 시각 | diff --git a/docs/api-spec.md b/docs/api-spec.md index a942182..46d2371 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -33,6 +33,11 @@ Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 A | 정책 검색 | 정책 검색/필터 | `GET` | `/api/v1/policies` | 비회원 | query: keyword 선택, region 선택, category 선택, page 기본 0, size 기본 20, sort 기본 relevance | | 정책 검색 | 검색어 제안 | `GET` | `/api/v1/policies/search-suggestions` | 비회원 | query: keyword 선택 | | 정책 검색 | 정책 상세 조회 | `GET` | `/api/v1/policies/{policyId}` | 비회원 | path: policyId | +| 정책 채팅 | 메시지 이력 조회 | `GET` | `/api/v1/policies/{policyId}/chat/messages` | 회원 | path: policyId, query: afterId 기본 0 | +| 정책 채팅 | STOMP 연결 | `WebSocket` | `/api/ws` | 회원 | STOMP `CONNECT` header: `Authorization: Bearer ` | +| 정책 채팅 | 실시간 메시지 전송 | `STOMP SEND` | `/app/policies/{policyId}/chat/messages` | 회원 | body: content(1~1000자) | +| 정책 채팅 | 실시간 메시지 수신 | `STOMP SUBSCRIBE` | `/user/queue/policies/{policyId}/chat/messages` | 회원 | path: policyId | +| 정책 채팅 | 오류 수신 | `STOMP SUBSCRIBE` | `/user/queue/policies/{policyId}/chat/errors` | 회원 | path: policyId | | 맞춤 추천 | 맞춤정책 조회 | `GET` | `/api/v1/recommended-policies` | 회원 | query: region 선택, category 선택, keyword 선택 | | 신청관리 | 신청 등록 | `POST` | `/api/v1/policy-applications` | 회원 | body: policyId, status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED), memo 선택, endAt 선택 | | 신청관리 | 신청 목록 조회 | `GET` | `/api/v1/policy-applications` | 회원 | query: page 기본 1, size 기본 20 | @@ -54,8 +59,6 @@ Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 A | 정책 동기화 | 정책 수집 실행 | `POST` | `/api/v1/policy-batch-histories` | 관리자 | body: mode(FULL 또는 DELTA) | | 정책 동기화 | 검색 인덱스 재생성 | `POST` | `/api/v1/search-indexes/rebuild` | 관리자 | 없음 | | 관리자/운영 | 운영 지표 조회 | `GET` | `/api/v1/admin/metrics` | 관리자 | 없음 | -| 챗봇 | 챗봇 일반 질의 | `POST` | `/chat` | 비회원 | body: message, thread_id 선택, user_profile 선택 | -| 챗봇 | 챗봇 스트리밍 질의 | `POST` | `/chat/stream` | 비회원 | body: message, thread_id 선택, user_profile 선택 | ## 인증 @@ -195,6 +198,38 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 | 권한 | 비회원 | | 파라미터 | path: policyId | +## 정책 채팅 + +삭제되지 않은 `VISIBLE` 정책에서 로그인 회원끼리 메시지를 주고받는다. 숨김·삭제된 정책은 이력 조회, 전송, 구독 모두 거부한다. 메시지는 정책별 ID 오름차순이며 `afterId`를 제외한 이후 메시지만 반환한다. + +### 메시지 이력 조회 + +현재 저장된 메시지를 즉시 반환한다. 실시간 수신은 아래 STOMP 구독을 사용한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policies/{policyId}/chat/messages` | +| 권한 | 회원 | +| 파라미터 | path: policyId. query: afterId 기본 0 | +| 응답 data | `messages[{id, policyId, authorName, content, createdAt, mine}]`, `nextCursor` | + +### STOMP 연결과 목적지 + +브라우저 WebSocket handshake에는 토큰을 넣지 않는다. 클라이언트는 `/api/ws`에 native WebSocket으로 연결한 뒤 STOMP `CONNECT` frame의 `Authorization` native header에 access token을 보낸다. 토큰이 없거나 유효하지 않으면 연결을 거부한다. + +| 항목 | 내용 | +|---|---| +| WebSocket endpoint | `/api/ws` | +| application prefix | `/app` | +| user destination prefix | `/user` | +| broker prefix | `/queue`만 사용. 정책 채팅에 `/topic` broadcast를 사용하지 않음 | +| 전송 | `SEND /app/policies/{policyId}/chat/messages`, body: `{content}`(필수, 공백 불가, 최대 1000자) | +| 메시지 구독 | `SUBSCRIBE /user/queue/policies/{policyId}/chat/messages` | +| 오류 구독 | `SUBSCRIBE /user/queue/policies/{policyId}/chat/errors` | + +메시지는 DB commit이 끝난 뒤 해당 정책을 구독 중인 사용자에게만 개별 전송한다. 메시지 payload는 `{id, policyId, authorName, content, createdAt, mine}`이며 `mine`은 수신 사용자별로 계산한다. 사용자 id, 이메일, 소셜 provider, token은 노출하지 않는다. 오류 payload는 `{code, message}`다. 허용 목록 밖의 전역 목적지와 `/topic` SEND/SUBSCRIBE는 거부한다. + ## 맞춤 추천 ### 맞춤정책 조회 @@ -442,28 +477,6 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 | 권한 | 관리자 | | 파라미터 | 없음 | -## 챗봇 FastAPI - -아래 경로는 프론트가 FastAPI 챗봇 서비스로 직접 호출하는 API다. Spring Boot 백엔드 엔티티 기반 경로와 별도로 취급하며, Spring Controller로 중복 구현하지 않는다. - -### 챗봇 일반 질의 - -| 항목 | 내용 | -|---|---| -| 메서드 | `POST` | -| 경로 | `/chat` | -| 권한 | 비회원 | -| 파라미터 | body: message, thread_id 선택, user_profile 선택 | - -### 챗봇 스트리밍 질의 - -| 항목 | 내용 | -|---|---| -| 메서드 | `POST` | -| 경로 | `/chat/stream` | -| 권한 | 비회원 | -| 파라미터 | body: message, thread_id 선택, user_profile 선택 | - ## 기존/보류 명세 아래 항목은 Notion DB에 유지된 기존 데이터다. 현재 프론트 필요 API 경로 변경 작업의 직접 대상이 아니므로 별도 확인 후 사용한다. @@ -473,8 +486,6 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 | 온보딩/프로필 | 프로필 삭제 | `DELETE` | `/api/v1/me/profile` | 회원 | 없음 | | 정책 동기화 | 정책 수집 상세 | `GET` | `/api/v1/admin/policy-sync-jobs/{jobId}` | 관리자 | path: jobId | | 관리자/운영 | 헬스 체크 | `GET` | `/api/v1/health` | 공통 | 없음 | -| 챗봇 | 챗봇 프로필 사용 동의 설정 | `POST` | `/api/v1/policy-chat/profile-consent` | 회원 | 명세 기준 확인 필요 | -| 챗봇 | 정책 탐색 챗봇 질문 | `POST` | `/api/v1/policy-chat/queries` | 비회원 | 명세 기준 확인 필요 | ## 백엔드 구현 메모 diff --git a/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java b/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java index 08344a7..5361857 100644 --- a/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java +++ b/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java @@ -29,6 +29,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) diff --git a/src/main/java/com/bop/youthpick/auth/service/JwtStompAuthentication.java b/src/main/java/com/bop/youthpick/auth/service/JwtStompAuthentication.java new file mode 100644 index 0000000..312c3c8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/JwtStompAuthentication.java @@ -0,0 +1,32 @@ +package com.bop.youthpick.auth.service; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import java.util.List; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +public final class JwtStompAuthentication extends AbstractAuthenticationToken { + + private final AuthPrincipal principal; + + public JwtStompAuthentication(Long userId, String role) { + super(List.of(new SimpleGrantedAuthority("ROLE_" + role))); + this.principal = new AuthPrincipal(userId, role); + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public AuthPrincipal getPrincipal() { + return principal; + } + + @Override + public String getName() { + return principal.userId().toString(); + } +} diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index d9a95a1..08cf29e 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -62,11 +62,17 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // 관리자(ADMIN) 전용 — 정책 수집 실행/이력 조회 .requestMatchers("/api/v1/admin/**") .hasRole("ADMIN") + // 브라우저 WebSocket handshake에는 Authorization 헤더를 넣을 수 없다. + // 실제 인증/인가는 STOMP CONNECT/SEND/SUBSCRIBE interceptor가 수행한다. + .requestMatchers("/api/ws") + .permitAll() + // 정책 상세/목록은 공개지만 정책별 채팅 이력 조회는 회원 전용 + .requestMatchers(HttpMethod.GET, "/api/v1/policies/*/chat/messages") + .authenticated() // 비회원(공개) — 로그인 자체, 정책 탐색/비교/검색, 메타 조회, 헬스체크 .requestMatchers( "/api/v1/auth/oauth/**", "/api/v1/auth/token/refresh", - "/api/v1/policy-chat/queries", "/api/v1/policy-comparisons/**", "/api/v1/meta/profile-options", "/api/v1/policies/**", @@ -78,14 +84,13 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/v1/posts/**") .authenticated() // 회원 전용 — 로그인 상태 조회/로그아웃, 회원 탈퇴(컨트롤러 미구현, 경로만 선점), - // 마이페이지(관심정책/추천/읽음/프로필), 최근 본 정책, 챗봇 프로필 동의 + // 마이페이지(관심정책/추천/읽음/프로필), 최근 본 정책 .requestMatchers( "/api/v1/auth/me", "/api/v1/auth/logout", "/api/v1/users", "/api/v1/me/**", - "/api/v1/policy-recent-views", - "/api/v1/policy-chat/profile-consent") + "/api/v1/policy-recent-views") .authenticated() // 회원 전용 — 정책 신청관리(관심정책 흡수) + 체크리스트. .requestMatchers( diff --git a/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java new file mode 100644 index 0000000..eb563b4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java @@ -0,0 +1,24 @@ +package com.bop.youthpick.global.config; + +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class TaskExecutionConfig { + + @Bean + @Primary + TaskExecutor applicationTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setMaxPoolSize(32); + executor.setQueueCapacity(500); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setThreadNamePrefix("application-"); + return executor; + } +} diff --git a/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java new file mode 100644 index 0000000..97d29fc --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java @@ -0,0 +1,51 @@ +package com.bop.youthpick.global.config; + +import com.bop.youthpick.policy.service.PolicyChatInboundInterceptor; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final PolicyChatInboundInterceptor policyChatInboundInterceptor; + private final ObjectMapper objectMapper; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/api/ws") + .setAllowedOrigins(SecurityConfig.ALLOWED_ORIGINS.toArray(String[]::new)); + registry.setPreserveReceiveOrder(true); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); + registry.setUserDestinationPrefix("/user/"); + registry.enableSimpleBroker("/queue"); + registry.setPreservePublishOrder(true); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(policyChatInboundInterceptor); + } + + @Override + public boolean configureMessageConverters(List messageConverters) { + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + converter.setObjectMapper(objectMapper); + messageConverters.add(converter); + return true; + } +} diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index 65be1e4..bd44956 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -110,6 +111,12 @@ public ResponseEntity handleCustomException(CustomException excep return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported() { + ErrorCode errorCode = GlobalErrorCode.METHOD_NOT_ALLOWED; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); + } + // 개발자가 예상치 못한 에러 — application_logs 테이블에 남도록 ERROR로 로깅한다. @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { @@ -132,7 +139,8 @@ private String fieldName(ConstraintViolation violation) { enum GlobalErrorCode implements ErrorCode { INTERNAL_SERVER_ERROR( HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."); + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", "지원하지 않는 HTTP 메서드입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java new file mode 100644 index 0000000..b2d19e0 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java @@ -0,0 +1,32 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.PolicyChatMessagesResponse; +import com.bop.youthpick.policy.service.PolicyChatService; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping("/api/v1/policies/{policyId}/chat/messages") +@RequiredArgsConstructor +@Validated +public class PolicyChatController { + + private final PolicyChatService policyChatService; + + @GetMapping + public ApiResponse getMessages( + @CurrentUser Long userId, + @PathVariable Long policyId, + @RequestParam(defaultValue = "0") @Min(value = 0, message = "afterId는 0 이상이어야 합니다.") + Long afterId) { + return ApiResponse.ok(policyChatService.getMessages(policyId, userId, afterId)); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyChatMessageController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyChatMessageController.java new file mode 100644 index 0000000..8bd8e0c --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyChatMessageController.java @@ -0,0 +1,89 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.global.error.ErrorCode; +import com.bop.youthpick.policy.dto.PolicyChatErrorResponse; +import com.bop.youthpick.policy.dto.PolicyChatMessageCreateRequest; +import com.bop.youthpick.policy.service.PolicyChatService; +import jakarta.validation.Valid; +import java.security.Principal; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.validation.method.MethodValidationException; + +@Controller +@RequiredArgsConstructor +public class PolicyChatMessageController { + + private static final Pattern SEND_DESTINATION = + Pattern.compile("^/app/policies/([1-9]\\d*)/chat/messages$"); + private static final PolicyChatErrorResponse INVALID_INPUT = + new PolicyChatErrorResponse("C001", "입력 형태가 올바르지 않습니다."); + private static final PolicyChatErrorResponse SERVER_ERROR = + new PolicyChatErrorResponse("S001", "서버 내부 오류가 발생했습니다."); + + private final PolicyChatService policyChatService; + private final SimpMessagingTemplate messagingTemplate; + + @MessageMapping("/policies/{policyId}/chat/messages") + public void send( + @DestinationVariable Long policyId, + @Valid @Payload PolicyChatMessageCreateRequest request, + Principal principal) { + AuthPrincipal authPrincipal = requirePrincipal(principal); + policyChatService.send(policyId, authPrincipal.userId(), request); + } + + @MessageExceptionHandler(Exception.class) + public void handleException( + Exception exception, + Principal principal, + @Header("simpDestination") String destination) { + Matcher matcher = SEND_DESTINATION.matcher(destination); + if (!matcher.matches() || principal == null) { + return; + } + messagingTemplate.convertAndSendToUser( + principal.getName(), + "/queue/policies/" + matcher.group(1) + "/chat/errors", + errorResponse(exception)); + } + + private AuthPrincipal requirePrincipal(Principal principal) { + if (!(principal instanceof Authentication authentication) + || !authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof AuthPrincipal authPrincipal)) { + throw new AuthException(AuthErrorCode.UNAUTHORIZED); + } + return authPrincipal; + } + + private PolicyChatErrorResponse errorResponse(Throwable throwable) { + Throwable cause = throwable; + while (cause != null) { + if (cause instanceof CustomException customException) { + ErrorCode errorCode = customException.getErrorCode(); + return new PolicyChatErrorResponse(errorCode.getCode(), errorCode.getMessage()); + } + if (cause instanceof MethodArgumentNotValidException + || cause instanceof MethodValidationException) { + return INVALID_INPUT; + } + cause = cause.getCause(); + } + return SERVER_ERROR; + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyChatErrorResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatErrorResponse.java new file mode 100644 index 0000000..c0e1781 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatErrorResponse.java @@ -0,0 +1,3 @@ +package com.bop.youthpick.policy.dto; + +public record PolicyChatErrorResponse(String code, String message) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageCreateRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageCreateRequest.java new file mode 100644 index 0000000..e29c572 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageCreateRequest.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PolicyChatMessageCreateRequest( + @NotBlank(message = "메시지 내용은 필수입니다.") @Size(max = 1000, message = "메시지 내용은 1000자 이하여야 합니다.") + String content, + @Size(max = 100, message = "클라이언트 메시지 ID는 100자 이하여야 합니다.") String clientMessageId) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageResponse.java new file mode 100644 index 0000000..dda5b23 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessageResponse.java @@ -0,0 +1,30 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.LocalDateTime; + +public record PolicyChatMessageResponse( + Long id, + Long policyId, + String authorName, + String content, + LocalDateTime createdAt, + boolean mine, + @JsonInclude(JsonInclude.Include.NON_NULL) String clientMessageId) { + + private static final String DEFAULT_AUTHOR_NAME = "사용자"; + + public static PolicyChatMessageResponse from(PolicyChatMessage message, Long currentUserId) { + String nickname = message.getUser().getNickname(); + String authorName = nickname == null || nickname.isBlank() ? DEFAULT_AUTHOR_NAME : nickname; + return new PolicyChatMessageResponse( + message.getId(), + message.getPolicy().getId(), + authorName, + message.getContent(), + message.getCreatedAt(), + message.getUser().getId().equals(currentUserId), + null); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessagesResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessagesResponse.java new file mode 100644 index 0000000..d329e6f --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyChatMessagesResponse.java @@ -0,0 +1,14 @@ +package com.bop.youthpick.policy.dto; + +import java.util.List; + +public record PolicyChatMessagesResponse( + List messages, Long nextCursor) { + + public static PolicyChatMessagesResponse of( + List messages, Long originalCursor) { + Long nextCursor = + messages.isEmpty() ? originalCursor : messages.get(messages.size() - 1).id(); + return new PolicyChatMessagesResponse(messages, nextCursor); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyChatMessage.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyChatMessage.java new file mode 100644 index 0000000..d15e477 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyChatMessage.java @@ -0,0 +1,56 @@ +package com.bop.youthpick.policy.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "policy_chat_messages", + indexes = { + @Index(name = "idx_policy_chat_messages_policy_cursor", columnList = "policy_id, id"), + @Index(name = "idx_policy_chat_messages_user", columnList = "user_id") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PolicyChatMessage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "policy_id", nullable = false) + private Policy policy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(length = 1000, nullable = false) + private String content; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public static PolicyChatMessage create(Policy policy, User user, String content) { + PolicyChatMessage message = new PolicyChatMessage(); + message.policy = policy; + message.user = user; + message.content = content; + return message; + } +} diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepository.java new file mode 100644 index 0000000..7b10208 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepository.java @@ -0,0 +1,14 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyChatMessageRepository extends JpaRepository { + + @EntityGraph(attributePaths = "user") + List findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + Long policyId, Long afterId, Pageable pageable); +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java new file mode 100644 index 0000000..fcffdf1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java @@ -0,0 +1,24 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PolicyChatAccessService { + + private final PolicyRepository policyRepository; + + @Transactional(readOnly = true) + public Policy requireVisiblePolicy(Long policyId) { + return policyRepository + .findByIdAndVisibilityAndDeletedAtIsNull(policyId, PolicyVisibility.VISIBLE) + .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java new file mode 100644 index 0000000..e0a0145 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java @@ -0,0 +1,172 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.auth.service.JwtStompAuthentication; +import com.bop.youthpick.auth.service.JwtTokenProvider; +import com.bop.youthpick.policy.service.PolicyChatSubscriptionRegistry.SubscriptionType; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import io.jsonwebtoken.Claims; +import java.security.Principal; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@RequiredArgsConstructor +public class PolicyChatInboundInterceptor implements ChannelInterceptor { + + private static final String BEARER_PREFIX = "Bearer "; + private static final Pattern SEND_DESTINATION = + Pattern.compile("^/app/policies/([1-9]\\d*)/chat/messages$"); + private static final Pattern SUBSCRIBE_DESTINATION = + Pattern.compile("^/user/queue/policies/([1-9]\\d*)/chat/(messages|errors)$"); + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final PolicyChatAccessService accessService; + private final PolicyChatSubscriptionRegistry subscriptionRegistry; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null || accessor.getCommand() == null) { + return message; + } + + switch (accessor.getCommand()) { + case CONNECT -> authenticate(accessor); + case SEND -> authorizeSend(accessor); + case SUBSCRIBE -> authorizeSubscribe(accessor); + default -> { + return message; + } + } + return message; + } + + @Override + public void afterSendCompletion( + Message message, MessageChannel channel, boolean sent, Exception exception) { + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null || accessor.getCommand() == null) { + return; + } + if (!sent || exception != null) { + return; + } + if (accessor.getCommand() == StompCommand.SUBSCRIBE) { + register(accessor); + } else if (accessor.getCommand() == StompCommand.UNSUBSCRIBE) { + unregister(accessor); + } + } + + private void authenticate(StompHeaderAccessor accessor) { + String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); + if (!StringUtils.hasText(authorization) || !authorization.startsWith(BEARER_PREFIX)) { + throw new AuthException(AuthErrorCode.UNAUTHORIZED); + } + String token = authorization.substring(BEARER_PREFIX.length()); + if (!StringUtils.hasText(token)) { + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + Claims claims = jwtTokenProvider.validateAccessToken(token); + Long userId = jwtTokenProvider.getUserId(claims); + String role = jwtTokenProvider.getRole(claims); + if (!StringUtils.hasText(role)) { + throw new AuthException(AuthErrorCode.INVALID_TOKEN); + } + userRepository + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + accessor.setUser(new JwtStompAuthentication(userId, role)); + } + + private void authorizeSend(StompHeaderAccessor accessor) { + requireAuthentication(accessor); + Long policyId = requirePolicyId(accessor.getDestination(), SEND_DESTINATION); + accessService.requireVisiblePolicy(policyId); + } + + private void authorizeSubscribe(StompHeaderAccessor accessor) { + requireAuthentication(accessor); + requireText(accessor.getSessionId()); + requireText(accessor.getSubscriptionId()); + Long policyId = requirePolicyId(accessor.getDestination(), SUBSCRIBE_DESTINATION); + accessService.requireVisiblePolicy(policyId); + } + + private void register(StompHeaderAccessor accessor) { + Authentication authentication = requireAuthentication(accessor); + Matcher matcher = SUBSCRIBE_DESTINATION.matcher(accessor.getDestination()); + if (!matcher.matches()) { + return; + } + AuthPrincipal principal = (AuthPrincipal) authentication.getPrincipal(); + SubscriptionType type = + "messages".equals(matcher.group(2)) + ? SubscriptionType.MESSAGES + : SubscriptionType.ERRORS; + subscriptionRegistry.register( + accessor.getSessionId(), + accessor.getSubscriptionId(), + principal.userId(), + authentication.getName(), + Long.valueOf(matcher.group(1)), + type); + } + + private void unregister(StompHeaderAccessor accessor) { + if (StringUtils.hasText(accessor.getSessionId()) + && StringUtils.hasText(accessor.getSubscriptionId())) { + subscriptionRegistry.unregister(accessor.getSessionId(), accessor.getSubscriptionId()); + } + } + + private Authentication requireAuthentication(StompHeaderAccessor accessor) { + Principal user = accessor.getUser(); + if (!(user instanceof Authentication authentication) + || !authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof AuthPrincipal)) { + throw new AuthException(AuthErrorCode.UNAUTHORIZED); + } + return authentication; + } + + private Long requirePolicyId(String destination, Pattern pattern) { + if (!StringUtils.hasText(destination)) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } + Matcher matcher = pattern.matcher(destination); + if (!matcher.matches()) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } + try { + return Long.valueOf(matcher.group(1)); + } catch (NumberFormatException exception) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } + } + + private void requireText(String value) { + if (!StringUtils.hasText(value)) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatOutboundMessage.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatOutboundMessage.java new file mode 100644 index 0000000..6889cc6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatOutboundMessage.java @@ -0,0 +1,39 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.policy.dto.PolicyChatMessageResponse; +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import java.time.LocalDateTime; + +record PolicyChatOutboundMessage( + Long id, + Long policyId, + Long authorId, + String authorName, + String content, + LocalDateTime createdAt, + String clientMessageId) { + + static PolicyChatOutboundMessage from(PolicyChatMessage message, String clientMessageId) { + Long authorId = message.getUser().getId(); + PolicyChatMessageResponse response = PolicyChatMessageResponse.from(message, authorId); + return new PolicyChatOutboundMessage( + response.id(), + response.policyId(), + authorId, + response.authorName(), + response.content(), + response.createdAt(), + clientMessageId); + } + + PolicyChatMessageResponse forUser(Long userId) { + return new PolicyChatMessageResponse( + id, + policyId, + authorName, + content, + createdAt, + authorId.equals(userId), + authorId.equals(userId) ? clientMessageId : null); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatService.java new file mode 100644 index 0000000..262a90f --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatService.java @@ -0,0 +1,60 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.policy.dto.PolicyChatMessageCreateRequest; +import com.bop.youthpick.policy.dto.PolicyChatMessageResponse; +import com.bop.youthpick.policy.dto.PolicyChatMessagesResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import com.bop.youthpick.policy.repository.PolicyChatMessageRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PolicyChatService { + + private static final int MESSAGE_PAGE_SIZE = 50; + + private final PolicyChatAccessService accessService; + private final UserRepository userRepository; + private final PolicyChatMessageRepository messageRepository; + private final PolicyChatStompPublisher stompPublisher; + + @Transactional(readOnly = true) + public PolicyChatMessagesResponse getMessages(Long policyId, Long userId, Long afterId) { + requireActiveUser(userId); + accessService.requireVisiblePolicy(policyId); + List messages = + messageRepository + .findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + policyId, afterId, Pageable.ofSize(MESSAGE_PAGE_SIZE)) + .stream() + .map(message -> PolicyChatMessageResponse.from(message, userId)) + .toList(); + return PolicyChatMessagesResponse.of(messages, afterId); + } + + @Transactional + public PolicyChatMessageResponse send( + Long policyId, Long userId, PolicyChatMessageCreateRequest request) { + Policy policy = accessService.requireVisiblePolicy(policyId); + User user = requireActiveUser(userId); + PolicyChatMessage message = + messageRepository.save(PolicyChatMessage.create(policy, user, request.content())); + stompPublisher.publishAfterCommit(message, request.clientMessageId()); + return PolicyChatMessageResponse.from(message, userId); + } + + private User requireActiveUser(Long userId) { + return userRepository + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListener.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListener.java new file mode 100644 index 0000000..9fac1b3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListener.java @@ -0,0 +1,18 @@ +package com.bop.youthpick.policy.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +@Component +@RequiredArgsConstructor +public class PolicyChatSessionLifecycleListener { + + private final PolicyChatSubscriptionRegistry subscriptionRegistry; + + @EventListener + public void handleDisconnect(SessionDisconnectEvent event) { + subscriptionRegistry.disconnect(event.getSessionId()); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatStompPublisher.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatStompPublisher.java new file mode 100644 index 0000000..5c11dc2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatStompPublisher.java @@ -0,0 +1,47 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Component +@RequiredArgsConstructor +public class PolicyChatStompPublisher { + + private final PolicyChatSubscriptionRegistry subscriptionRegistry; + private final SimpMessagingTemplate messagingTemplate; + private final TaskExecutor taskExecutor; + + public void publishAfterCommit(PolicyChatMessage savedMessage, String clientMessageId) { + publishAfterCommit(PolicyChatOutboundMessage.from(savedMessage, clientMessageId)); + } + + void publishAfterCommit(PolicyChatOutboundMessage outboundMessage) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + taskExecutor.execute(() -> publish(outboundMessage)); + return; + } + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + taskExecutor.execute(() -> publish(outboundMessage)); + } + }); + } + + void publish(PolicyChatOutboundMessage message) { + String destination = "/queue/policies/" + message.policyId() + "/chat/messages"; + subscriptionRegistry.messageSubscribers(message.policyId()).stream() + .forEach( + subscriber -> + messagingTemplate.convertAndSendToUser( + subscriber.userName(), + destination, + message.forUser(subscriber.userId()))); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatSubscriptionRegistry.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatSubscriptionRegistry.java new file mode 100644 index 0000000..a55b32d --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatSubscriptionRegistry.java @@ -0,0 +1,75 @@ +package com.bop.youthpick.policy.service; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +@Component +public class PolicyChatSubscriptionRegistry { + + private final ConcurrentHashMap> sessions = + new ConcurrentHashMap<>(); + + public void register( + String sessionId, + String subscriptionId, + Long userId, + String userName, + Long policyId, + SubscriptionType type) { + sessions.computeIfAbsent(sessionId, ignored -> new ConcurrentHashMap<>()) + .put(subscriptionId, new Subscription(userId, userName, policyId, type)); + } + + public void unregister(String sessionId, String subscriptionId) { + sessions.computeIfPresent( + sessionId, + (ignored, subscriptions) -> { + subscriptions.remove(subscriptionId); + return subscriptions.isEmpty() ? null : subscriptions; + }); + } + + public void disconnect(String sessionId) { + sessions.remove(sessionId); + } + + public Collection messageSubscribers(Long policyId) { + Map subscribers = new ConcurrentHashMap<>(); + sessions.values().stream() + .flatMap(subscriptions -> subscriptions.values().stream()) + .filter(subscription -> subscription.type() == SubscriptionType.MESSAGES) + .filter(subscription -> subscription.policyId().equals(policyId)) + .forEach( + subscription -> + subscribers.putIfAbsent( + subscription.userName(), + new Subscriber( + subscription.userId(), subscription.userName()))); + return List.copyOf(subscribers.values()); + } + + int registrationCount() { + return sessions.values().stream().mapToInt(Map::size).sum(); + } + + long registrationCount(Long policyId, SubscriptionType type) { + return sessions.values().stream() + .flatMap(subscriptions -> subscriptions.values().stream()) + .filter(subscription -> subscription.policyId().equals(policyId)) + .filter(subscription -> subscription.type() == type) + .count(); + } + + public enum SubscriptionType { + MESSAGES, + ERRORS + } + + public record Subscriber(Long userId, String userName) {} + + private record Subscription( + Long userId, String userName, Long policyId, SubscriptionType type) {} +} diff --git a/src/main/resources/db/migration/V3__update_policy_applications_status_comment.sql b/src/main/resources/db/migration/V10__update_policy_applications_status_comment.sql similarity index 90% rename from src/main/resources/db/migration/V3__update_policy_applications_status_comment.sql rename to src/main/resources/db/migration/V10__update_policy_applications_status_comment.sql index c5d4ad1..205670d 100644 --- a/src/main/resources/db/migration/V3__update_policy_applications_status_comment.sql +++ b/src/main/resources/db/migration/V10__update_policy_applications_status_comment.sql @@ -1,5 +1,5 @@ -- ============================================================ --- V3 — policy_applications.status 컬럼 코멘트 갱신 +-- V10 — policy_applications.status 컬럼 코멘트 갱신 -- ApplicationStatus에 PREPARING(준비중) 추가 (2026-07-14) 후 -- V1의 코멘트가 예전 3단계 값만 나열해 실제 허용값과 어긋나 있었음. -- CHECK 제약은 없으므로(문서용 코멘트) 데이터 마이그레이션은 불필요. diff --git a/src/main/resources/db/migration/V4__seed_local_sample_policies.sql b/src/main/resources/db/migration/V4__seed_local_sample_policies.sql deleted file mode 100644 index 5195777..0000000 --- a/src/main/resources/db/migration/V4__seed_local_sample_policies.sql +++ /dev/null @@ -1,23 +0,0 @@ --- ============================================================ --- V4 — 로컬 개발용 샘플 정책 시드 데이터 --- 프론트 목업(entities/policy/api/policyMockData.ts)의 id를 --- 'p1'..'p14' 대신 실제 policies.id(1..14)와 맞추기 위해 --- 동일한 순서로 최소 데이터를 적재한다. 정식 온통청년 동기화 --- 배치가 구현되기 전까지 로컬 실연동 테스트용 임시 데이터다. --- ============================================================ - -INSERT INTO policies (policy_no, title, description, category, min_age, max_age, application_end_date, support_content, additional_qualification, application_method, submission_documents, screening_method, participation_restriction, application_url) VALUES -('MOCK-P001', '청년 디지털 일자리 지원 사업', 'IT 분야 실무 경험과 교육을 제공하고 취업 연계를 지원하는 고용노동부 주관 사업입니다.', '일자리', 19, 34, '2025-06-30', '청년 채용 기업에 인건비 월 최대 190만원 지원 및 실무 교육 제공', '만 19세 이상 34세 이하의 미취업 청년', '워크넷 청년일자리사업 페이지에서 온라인 접수', '이력서, 자기소개서, 최종학력 졸업(예정)증명서', NULL, '동일 사업 참여 이력이 있는 기업은 재신청 불가', 'https://www.work.go.kr'), -('MOCK-P002', '청년 월세 한시 특별지원', '서울 및 전국 무주택 청년의 주거비 부담 경감을 위해 실제 납부하는 월세를 지원합니다.', '주거', 19, 34, '2025-06-30', '실제 납부하는 월세 범위 내에서 월 최대 20만원까지 최장 12개월 분할 지원', '부모와 별도 거주하는 무주택 청년 중 소득 기준 충족자', '복지로 온라인 신청 또는 주소지 관할 행정복지센터 방문 신청', '임대차계약서, 통장사본, 소득금액증명원', '서류 요건 충족 여부 행정 확인 후 순차 지급', '주택 소유자, 다른 월세 지원사업 중복 수급자는 제외', 'https://www.bokjiro.go.kr'), -('MOCK-P003', 'K-디지털 트레이닝 (국비지원)', '디지털, AI, 신기술 핵심 실무 중심 교육을 통해 고소득 기술 취업 역량을 극대화하는 과정입니다.', '교육', 19, 34, '2025-07-15', '수강료 전액 지원(국비무료) 및 월 최대 31만 6천원의 훈련장려금 지급', '만 19세~34세 대학 졸업(예정)자 및 미취업 청년, 공학계열 전공자 한정', 'HRD-Net에서 과정 검색 후 온라인 수강 신청', '수강신청서, 재직·졸업증명서, 훈련장려금 지급 계좌 사본', '선착순 접수 및 과정별 사전 상담 통과자 선발', NULL, 'https://www.hrd.go.kr'), -('MOCK-P004', '청년내일채움공제', '중소기업 취업 청년의 자산 형성을 지원하여 장기 근속을 유도하고 대기업과의 격차를 완화합니다.', '일자리', 15, 34, '2025-08-31', '청년이 2년간 300만원을 적립하면 정부와 기업이 적립해 총 1,200만원 자산 마련', '5인 이상 중소기업에 신규 취업한 청년 및 해당 기업', '청년내일채움공제 홈페이지에서 청약 신청', '취업사실확인서, 4대보험 가입내역확인서, 기업 사업자등록증 사본', NULL, '동일 사업장에서 과거 청약 후 중도해지한 이력이 있으면 재가입 제한', 'https://www.sbcof.or.kr'), -('MOCK-P005', '청년 주거급여 분리지급', '주거급여 수급 가구 중 학업이나 취업으로 부모와 타 지역에 거주하는 청년에게 별도로 급여를 지급합니다.', '주거', 19, 30, '2025-12-31', '부모와 떨어진 별도 가구원에 대해 서울 및 해당 지역 기준 임차료 지급', '주거급여 수급 가구원 중 만 19세 이상 30세 미만 미혼 자녀', '주민등록상 부모 거주지 읍면동 행정복지센터 방문 혹은 복지로 온라인 신청', '임대차계약서, 전입세대열람원', NULL, NULL, 'https://www.bokjiro.go.kr'), -('MOCK-P006', '국민내일배움카드', '취업준비생 및 이직 희망 청년이 스스로 직업 능력을 개발하도록 평생교육 배움 비용을 보조합니다.', '교육', NULL, NULL, '2025-12-31', '5년간 300만원에서 최대 500만원까지의 훈련 직무 수강 비용 45~85% 지원', '일반 직장인, 미취업 청년, 졸업 예정 대학생 등 누구나 발급 가능', 'HRD-Net 또는 고용센터 방문을 통한 카드 발급 신청', NULL, NULL, '공무원, 사립학교 교직원, 연 매출 1억 5천만원 이상 자영업자 등은 제외', 'https://www.hrd.go.kr'), -('MOCK-P007', '서울시 청년수당', '서울 거주 미취업 청년들의 안정적인 구직활동과 진로 개척을 돕기 위해 생계 보조 청년 수당을 지급합니다.', '복지·문화', 19, 34, '2025-09-30', '월 50만원씩 최대 6개월간 매월 체크카드 바우처 포인트 형태로 지원금 지급', '서울 거주 만 19세~34세 졸업 후 미취업자 중 중위소득 150% 이하', '청년몽땅정보통 홈페이지에서 온라인 신청', '졸업증명서, 소득금액증명원, 구직활동계획서', '자격요건 서류 심사 후 예산 범위 내 선발', '구직촉진수당 등 유사 목적의 정부 지원사업과 중복 수급 불가', 'https://youth.seoul.go.kr'), -('MOCK-P008', '청년내일저축계좌', '일하는 청년들이 주거, 취업 등으로 인한 자립 기반을 갖출 수 있도록 정부가 목돈을 함께 마련해 줍니다.', '복지·문화', 19, 34, '2025-10-15', '본인 저축 월 10만원당 정부 지원금 10만~30만원을 3년간 1:1 매칭 적립', '일하고 있는 만 19세~34세 이하 청년 중 소득기준 충족자', '복지로 온라인 신청 또는 주소지 관할 행정복지센터 방문 신청', '재직증명서 또는 사업자등록증, 소득금액증명원, 통장사본', NULL, '가입 기간 중 3년 내 근로·사업활동을 지속하지 않으면 지급 제한', 'https://www.bokjiro.go.kr'), -('MOCK-P009', '청년문화예술패스', '사회에 첫 발을 내딛는 청년들에게 다채로운 문화생활과 연극, 전시, 음악 관람 비용을 전폭적으로 제공합니다.', '복지·문화', 19, 19, '2025-11-30', '공연 및 전시 관람료로 사용할 수 있는 포인트 최대 15만원 즉시 지급', '만 19세가 되는 국내 거주 모든 청년 (선착순 신청)', '청년문화예술패스 홈페이지 또는 앱에서 회원가입 후 신청', NULL, NULL, NULL, 'https://www.kopis.or.kr'), -('MOCK-P010', '정부부처 청년인턴', '중앙 행정 기관의 입법, 사업 진행 등의 부처 행정 실무를 인턴십으로 체험하고 참관할 기회를 제공합니다.', '참여·권리', 19, 34, '2025-06-15', '국가 공공 기관 인턴 근무 경력 증명서 발급 및 월 고정 기본급 및 수당 지급', '만 19세 이상 34세 이하의 미취업자 (학력 제한 없음)', '나라일터(잡알리오) 채용 공고를 통한 온라인 접수', '이력서, 자기소개서, 최종학력 증명서', '서류 전형 및 면접 전형', NULL, 'https://www.jobis.or.kr'), -('MOCK-P011', '청년 창업 지원금', '예비 창업자 및 창업 3년 이내 청년에게 사업화 자금과 멘토링을 함께 지원하는 사업입니다.', '일자리', 19, 39, '2025-08-20', '사업화 자금 최대 5천만원 및 전담 멘토 1:1 컨설팅 제공', '만 19세 이상 39세 이하 예비 창업자 및 창업 3년 이내 대표', 'K-스타트업 홈페이지에서 온라인 접수', '사업계획서, 사업자등록증 사본(창업자), 대표자 신분증 사본', '사업계획서 서류 심사 및 발표 평가', '동일 사업 기수 내 중복 선정 불가', 'https://www.k-startup.go.kr'), -('MOCK-P012', '청년 대중교통비 지원 (K-패스)', '월 15회 이상 대중교통을 이용하는 청년에게 지출 금액 일부를 다음 달에 환급해 줍니다.', '복지·문화', 19, 34, '2025-12-31', '월 대중교통 지출액의 30%를 익월 환급 (최대 60회 한도)', '만 19세 이상 34세 이하 K-패스 카드 발급자', 'K-패스 앱 또는 카드사 홈페이지에서 청년 등급 등록', NULL, NULL, NULL, 'https://korea-pass.kr'), -('MOCK-P013', '청년 전세자금 대출 이자 지원', '무주택 청년의 전세자금 대출 이자 부담을 낮춰 안정적인 주거 마련을 지원합니다.', '주거', 19, 39, '2025-09-30', '전세자금 대출 이자의 연 최대 2%p 차액 보전', '경기도 거주 무주택 미혼 청년 중 소득 기준 충족자', '경기도 청년포털에서 온라인 신청', '임대차계약서, 전세자금 대출 실행 확인서, 소득금액증명원', NULL, NULL, 'https://www.gg.go.kr'), -('MOCK-P014', '청년 정책참여단 모집', '청년 정책 기획 단계부터 직접 참여해 의견을 제안하고 정책 모니터링 활동을 수행합니다.', '참여·권리', 19, 34, '2025-07-31', '활동비 지급 및 정책 제안 채택 시 인센티브 제공', '만 19세 이상 34세 이하 청년 누구나 지원 가능', '청년정책 통합 플랫폼에서 온라인 신청', NULL, '지원서 심사 후 선발', '월 1회 정기 회의 2회 이상 불참 시 활동 중단', 'https://youth.go.kr'); diff --git a/src/main/resources/db/migration/V9__create_policy_chat_messages.sql b/src/main/resources/db/migration/V9__create_policy_chat_messages.sql new file mode 100644 index 0000000..8209945 --- /dev/null +++ b/src/main/resources/db/migration/V9__create_policy_chat_messages.sql @@ -0,0 +1,14 @@ +CREATE TABLE policy_chat_messages ( + id BIGINT NOT NULL AUTO_INCREMENT, + policy_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + content VARCHAR(1000) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL, + PRIMARY KEY (id), + KEY idx_policy_chat_messages_policy_cursor (policy_id, id), + KEY idx_policy_chat_messages_user (user_id), + CONSTRAINT fk_policy_chat_messages_policy FOREIGN KEY (policy_id) REFERENCES policies (id), + CONSTRAINT fk_policy_chat_messages_user FOREIGN KEY (user_id) REFERENCES users (id) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='정책별 회원 대화 메시지'; diff --git a/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java b/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java index 7bf7366..50bb50d 100644 --- a/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java @@ -27,6 +27,11 @@ void clearContext() { SecurityContextHolder.clearContext(); } + @Test + void 비동기_재디스패치에서도_JWT_인증을_수행한다() { + assertThat(filter.shouldNotFilterAsyncDispatch()).isFalse(); + } + @Test void 유효한_토큰이면_인증정보를_채운다() throws Exception { HttpServletRequest request = mock(HttpServletRequest.class); diff --git a/src/test/java/com/bop/youthpick/global/config/FlywayMigrationLocationsTest.java b/src/test/java/com/bop/youthpick/global/config/FlywayMigrationLocationsTest.java new file mode 100644 index 0000000..a64f948 --- /dev/null +++ b/src/test/java/com/bop/youthpick/global/config/FlywayMigrationLocationsTest.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.global.config; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; + +class FlywayMigrationLocationsTest { + + @Test + void migration과_seed_위치를_함께_해석할_수_있다() { + Flyway flyway = + Flyway.configure() + .dataSource("jdbc:h2:mem:flyway-location-test;MODE=MySQL", "sa", "") + .locations("classpath:db/migration", "classpath:db/seed") + .load(); + + assertThatCode(flyway::info).doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java index b8aff05..0ef19aa 100644 --- a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java +++ b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java @@ -84,6 +84,26 @@ class SecurityConfigTest { assertThat(status).isNotIn(401, 403); } + @Test + void 정책_상세는_공개지만_정책_채팅_GET은_인증없이_접근하면_401이다() throws Exception { + int detailStatus = + mockMvc.perform(get("/api/v1/policies/1")).andReturn().getResponse().getStatus(); + assertThat(detailStatus).isNotIn(401, 403); + + mockMvc.perform(get("/api/v1/policies/1/chat/messages")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + + @Test + void 정책_채팅_HTTP_POST는_더이상_존재하지_않는다() throws Exception { + mockMvc.perform( + post("/api/v1/policies/1/chat/messages") + .contentType("application/json") + .content("{\"content\":\"메시지\"}")) + .andExpect(status().isMethodNotAllowed()); + } + @Test void 게시글_조회는_인증없이_접근할_수_있다() throws Exception { int status = mockMvc.perform(get("/api/v1/posts")).andReturn().getResponse().getStatus(); diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyChatControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatControllerTest.java new file mode 100644 index 0000000..bda691c --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatControllerTest.java @@ -0,0 +1,85 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.error.GlobalExceptionHandler; +import com.bop.youthpick.policy.dto.PolicyChatMessageResponse; +import com.bop.youthpick.policy.dto.PolicyChatMessagesResponse; +import com.bop.youthpick.policy.service.PolicyChatService; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@ExtendWith(MockitoExtension.class) +class PolicyChatControllerTest { + + @Mock private PolicyChatService policyChatService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + PolicyChatController controller = new PolicyChatController(policyChatService); + mockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new TestCurrentUserArgumentResolver()) + .build(); + } + + @Test + void GET은_기본_cursor로_이력을_즉시_응답한다() throws Exception { + PolicyChatMessagesResponse response = PolicyChatMessagesResponse.of(List.of(message()), 0L); + when(policyChatService.getMessages(10L, 1L, 0L)).thenReturn(response); + + mockMvc.perform(get("/api/v1/policies/{policyId}/chat/messages", 10L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.messages[0].id").value(11)) + .andExpect(jsonPath("$.data.nextCursor").value(11)); + verify(policyChatService).getMessages(10L, 1L, 0L); + } + + @Test + void POST_전송_엔드포인트는_존재하지_않는다() throws Exception { + mockMvc.perform(post("/api/v1/policies/{policyId}/chat/messages", 10L)) + .andExpect(status().isMethodNotAllowed()); + } + + private PolicyChatMessageResponse message() { + return new PolicyChatMessageResponse( + 11L, 10L, "작성자", "안녕하세요", LocalDateTime.of(2026, 7, 19, 12, 0), true, null); + } + + private static class TestCurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return 1L; + } + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyChatHistoryIntegrationTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatHistoryIntegrationTest.java new file mode 100644 index 0000000..d1e7d84 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatHistoryIntegrationTest.java @@ -0,0 +1,102 @@ +package com.bop.youthpick.policy.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.service.JwtTokenProvider; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.repository.PolicyChatMessageRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class PolicyChatHistoryIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private PolicyRepository policyRepository; + @Autowired private UserRepository userRepository; + @Autowired private PolicyChatMessageRepository messageRepository; + + @Test + void 인증된_GET은_wait없이_cursor_이후_정책_메시지만_반환한다() throws Exception { + Policy policy = policyRepository.save(newPolicy("CHAT-HISTORY-1")); + User user = + userRepository.save( + User.createSocialUser("kakao", "chat-history-user", null, "이력 사용자")); + PolicyChatMessage first = + messageRepository.save(PolicyChatMessage.create(policy, user, "첫 메시지")); + PolicyChatMessage second = + messageRepository.save(PolicyChatMessage.create(policy, user, "둘째 메시지")); + String accessToken = jwtTokenProvider.createAccessToken(user.getId(), "USER"); + + mockMvc.perform( + get("/api/v1/policies/{policyId}/chat/messages", policy.getId()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .param("afterId", first.getId().toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.messages.length()").value(1)) + .andExpect(jsonPath("$.data.messages[0].id").value(second.getId())) + .andExpect(jsonPath("$.data.messages[0].policyId").value(policy.getId())) + .andExpect(jsonPath("$.data.messages[0].mine").value(true)) + .andExpect(jsonPath("$.data.messages[0].email").doesNotExist()) + .andExpect(jsonPath("$.data.messages[0].provider").doesNotExist()) + .andExpect(jsonPath("$.data.messages[0].userId").doesNotExist()) + .andExpect(jsonPath("$.data.messages[0].token").doesNotExist()) + .andExpect(jsonPath("$.data.messages[0].clientMessageId").doesNotExist()) + .andExpect(jsonPath("$.data.nextCursor").value(second.getId())); + } + + @Test + void 존재하지_않는_JWT_사용자의_GET은_USER_NOT_FOUND를_반환한다() throws Exception { + Policy policy = policyRepository.save(newPolicy("CHAT-HISTORY-MISSING-USER")); + String accessToken = jwtTokenProvider.createAccessToken(Long.MAX_VALUE, "USER"); + + mockMvc.perform( + get("/api/v1/policies/{policyId}/chat/messages", policy.getId()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("U001")); + } + + @Test + void 삭제된_JWT_사용자의_GET은_USER_NOT_FOUND를_반환한다() throws Exception { + Policy policy = policyRepository.save(newPolicy("CHAT-HISTORY-DELETED-USER")); + User deletedUser = + userRepository.save( + User.createSocialUser( + "kakao", "chat-history-deleted-user", null, "삭제 사용자")); + String accessToken = jwtTokenProvider.createAccessToken(deletedUser.getId(), "USER"); + deletedUser.softDelete(); + userRepository.saveAndFlush(deletedUser); + + mockMvc.perform( + get("/api/v1/policies/{policyId}/chat/messages", policy.getId()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("U001")); + } + + private Policy newPolicy(String policyNo) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", "이력 테스트 정책"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + return policy; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyChatMessageControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatMessageControllerTest.java new file mode 100644 index 0000000..8bc24bc --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatMessageControllerTest.java @@ -0,0 +1,65 @@ +package com.bop.youthpick.policy.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import com.bop.youthpick.auth.service.JwtStompAuthentication; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyChatErrorResponse; +import com.bop.youthpick.policy.dto.PolicyChatMessageCreateRequest; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.service.PolicyChatService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +@ExtendWith(MockitoExtension.class) +class PolicyChatMessageControllerTest { + + @Mock private PolicyChatService policyChatService; + @Mock private SimpMessagingTemplate messagingTemplate; + + private PolicyChatMessageController controller; + + @BeforeEach + void setUp() { + controller = new PolicyChatMessageController(policyChatService, messagingTemplate); + } + + @Test + void SEND는_STOMP_Principal의_userId로_메시지를_저장하고_직접_echo하지_않는다() { + PolicyChatMessageCreateRequest request = new PolicyChatMessageCreateRequest("메시지", null); + JwtStompAuthentication principal = new JwtStompAuthentication(7L, "USER"); + + controller.send(10L, request, principal); + + verify(policyChatService).send(10L, 7L, request); + org.mockito.Mockito.verifyNoInteractions(messagingTemplate); + } + + @Test + void 비즈니스_오류는_민감정보없이_해당_정책의_user_error_queue로_보낸다() { + JwtStompAuthentication principal = new JwtStompAuthentication(7L, "USER"); + + controller.handleException( + new CustomException(PolicyErrorCode.POLICY_NOT_FOUND), + principal, + "/app/policies/10/chat/messages"); + + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(messagingTemplate) + .convertAndSendToUser( + org.mockito.ArgumentMatchers.eq("7"), + org.mockito.ArgumentMatchers.eq("/queue/policies/10/chat/errors"), + payload.capture()); + assertThat(payload.getValue()) + .isEqualTo( + new PolicyChatErrorResponse( + PolicyErrorCode.POLICY_NOT_FOUND.getCode(), + PolicyErrorCode.POLICY_NOT_FOUND.getMessage())); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyChatWebSocketIntegrationTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatWebSocketIntegrationTest.java new file mode 100644 index 0000000..7680f91 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyChatWebSocketIntegrationTest.java @@ -0,0 +1,277 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import com.bop.youthpick.auth.service.JwtTokenProvider; +import com.bop.youthpick.policy.dto.PolicyChatErrorResponse; +import com.bop.youthpick.policy.dto.PolicyChatMessageCreateRequest; +import com.bop.youthpick.policy.dto.PolicyChatMessageResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.repository.PolicyChatMessageRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.service.PolicyChatSubscriptionRegistry.SubscriptionType; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PolicyChatWebSocketIntegrationTest { + + @LocalServerPort private int port; + + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private PolicyRepository policyRepository; + @Autowired private UserRepository userRepository; + @Autowired private PolicyChatMessageRepository messageRepository; + @Autowired private PolicyChatSubscriptionRegistry subscriptionRegistry; + @Autowired private SimpUserRegistry simpUserRegistry; + @Autowired private ObjectMapper objectMapper; + @MockitoSpyBean private SimpMessagingTemplate messagingTemplate; + + private WebSocketStompClient stompClient; + private final List sessions = new ArrayList<>(); + + @BeforeEach + void setUp() { + stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + converter.setObjectMapper(objectMapper); + stompClient.setMessageConverter(converter); + } + + @AfterEach + void tearDown() { + sessions.forEach(session -> subscriptionRegistry.disconnect(session.getSessionId())); + sessions.stream().filter(StompSession::isConnected).forEach(StompSession::disconnect); + stompClient.stop(); + } + + @Test + void CONNECT_JWT_SUBSCRIBE_SEND가_커밋된_메시지를_수신자별_mine으로_전달한다() throws Exception { + Policy policy = policyRepository.save(newPolicy("CHAT-STOMP-1")); + User author = + userRepository.save( + User.createSocialUser("kakao", "chat-stomp-author", null, "작성자")); + User reader = + userRepository.save( + User.createSocialUser("kakao", "chat-stomp-reader", null, "독자")); + StompSession authorSession = connect(author.getId()); + StompSession readerSession = connect(reader.getId()); + CompletableFuture authorMessage = new CompletableFuture<>(); + CompletableFuture readerMessage = new CompletableFuture<>(); + CompletableFuture sendError = new CompletableFuture<>(); + String userDestination = "/user/queue/policies/" + policy.getId() + "/chat/messages"; + subscribe(authorSession, userDestination, PolicyChatMessageResponse.class, authorMessage); + subscribe(readerSession, userDestination, PolicyChatMessageResponse.class, readerMessage); + subscribe( + authorSession, + "/user/queue/policies/" + policy.getId() + "/chat/errors", + PolicyChatErrorResponse.class, + sendError); + awaitSubscriptions(policy.getId(), SubscriptionType.MESSAGES, 2L); + awaitSubscriptions(policy.getId(), SubscriptionType.ERRORS, 1L); + awaitBrokerSubscription(author.getId(), userDestination); + awaitBrokerSubscription(reader.getId(), userDestination); + + authorSession.send( + "/app/policies/" + policy.getId() + "/chat/messages", + new PolicyChatMessageCreateRequest("STOMP 메시지", "stomp-client-message")); + + ArgumentCaptor recipients = ArgumentCaptor.forClass(String.class); + verify(messagingTemplate, timeout(5_000).times(2)) + .convertAndSendToUser( + recipients.capture(), + org.mockito.ArgumentMatchers.eq( + "/queue/policies/" + policy.getId() + "/chat/messages"), + org.mockito.ArgumentMatchers.any()); + assertThat(recipients.getAllValues()) + .containsExactlyInAnyOrder(author.getId().toString(), reader.getId().toString()); + CompletableFuture.anyOf(authorMessage, sendError).get(5, TimeUnit.SECONDS); + assertThat(sendError.isDone()) + .withFailMessage( + () -> + "SEND failed with " + + sendError.join().code() + + ": " + + sendError.join().message()) + .isFalse(); + PolicyChatMessageResponse authorView = authorMessage.join(); + PolicyChatMessageResponse readerView = readerMessage.get(5, TimeUnit.SECONDS); + assertThat(authorView.id()).isEqualTo(readerView.id()); + assertThat(authorView.policyId()).isEqualTo(policy.getId()); + assertThat(authorView.content()).isEqualTo("STOMP 메시지"); + assertThat(authorView.mine()).isTrue(); + assertThat(authorView.clientMessageId()).isEqualTo("stomp-client-message"); + assertThat(readerView.mine()).isFalse(); + assertThat(readerView.clientMessageId()).isNull(); + assertThat(messageRepository.findById(authorView.id())).isPresent(); + } + + @Test + void 길이_제한을_초과한_clientMessageId는_C001을_보내고_저장하지_않는다() throws Exception { + Policy policy = policyRepository.save(newPolicy("CHAT-STOMP-CLIENT-ID-ERROR")); + User user = + userRepository.save( + User.createSocialUser( + "kakao", "chat-stomp-client-id-error", null, "오류 사용자")); + StompSession session = connect(user.getId()); + CompletableFuture error = new CompletableFuture<>(); + subscribe( + session, + "/user/queue/policies/" + policy.getId() + "/chat/errors", + PolicyChatErrorResponse.class, + error); + awaitSubscriptions(policy.getId(), SubscriptionType.ERRORS, 1L); + awaitBrokerSubscription( + user.getId(), "/user/queue/policies/" + policy.getId() + "/chat/errors"); + + session.send( + "/app/policies/" + policy.getId() + "/chat/messages", + new PolicyChatMessageCreateRequest("메시지", "x".repeat(101))); + + assertThat(error.get(5, TimeUnit.SECONDS).code()).isEqualTo("C001"); + assertThat( + messageRepository + .findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + policy.getId(), 0L, Pageable.ofSize(50))) + .isEmpty(); + } + + @Test + void 빈_SEND는_저장하지_않고_정책별_error_queue에_C001을_보낸다() throws Exception { + Policy policy = policyRepository.save(newPolicy("CHAT-STOMP-ERROR-1")); + User user = + userRepository.save( + User.createSocialUser("kakao", "chat-stomp-error-user", null, "오류 사용자")); + StompSession session = connect(user.getId()); + CompletableFuture error = new CompletableFuture<>(); + subscribe( + session, + "/user/queue/policies/" + policy.getId() + "/chat/errors", + PolicyChatErrorResponse.class, + error); + awaitSubscriptions(policy.getId(), SubscriptionType.ERRORS, 1L); + awaitBrokerSubscription( + user.getId(), "/user/queue/policies/" + policy.getId() + "/chat/errors"); + + session.send( + "/app/policies/" + policy.getId() + "/chat/messages", + new PolicyChatMessageCreateRequest(" ", null)); + + assertThat(error.get(5, TimeUnit.SECONDS).code()).isEqualTo("C001"); + assertThat( + messageRepository + .findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + policy.getId(), 0L, Pageable.ofSize(50))) + .isEmpty(); + } + + private StompSession connect(Long userId) throws Exception { + StompHeaders headers = new StompHeaders(); + headers.add( + HttpHeaders.AUTHORIZATION, + "Bearer " + jwtTokenProvider.createAccessToken(userId, "USER")); + StompSession session = + stompClient + .connectAsync( + webSocketUrl(), + new WebSocketHttpHeaders(), + headers, + new StompSessionHandlerAdapter() {}) + .get(5, TimeUnit.SECONDS); + sessions.add(session); + return session; + } + + private void subscribe( + StompSession session, + String destination, + Class payloadType, + CompletableFuture payload) + throws Exception { + session.subscribe( + destination, + new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return payloadType; + } + + @Override + public void handleFrame(StompHeaders headers, Object framePayload) { + payload.complete(payloadType.cast(framePayload)); + } + }); + } + + private void awaitSubscriptions(Long policyId, SubscriptionType type, long expected) + throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (subscriptionRegistry.registrationCount(policyId, type) == expected) { + return; + } + Thread.sleep(10L); + } + assertThat(subscriptionRegistry.registrationCount(policyId, type)).isEqualTo(expected); + } + + private void awaitBrokerSubscription(Long userId, String destination) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + var user = simpUserRegistry.getUser(userId.toString()); + if (user != null + && user.getSessions().stream() + .flatMap(session -> session.getSubscriptions().stream()) + .anyMatch( + subscription -> + destination.equals(subscription.getDestination()))) { + return; + } + Thread.sleep(10L); + } + assertThat(simpUserRegistry.getUser(userId.toString())).isNotNull(); + } + + private String webSocketUrl() { + return "ws://localhost:" + port + "/api/ws"; + } + + private Policy newPolicy(String policyNo) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", "STOMP 테스트 정책"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + return policy; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepositoryTest.java new file mode 100644 index 0000000..45e7713 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyChatMessageRepositoryTest.java @@ -0,0 +1,65 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PolicyChatMessageRepositoryTest { + + @Autowired private PolicyRepository policyRepository; + @Autowired private UserRepository userRepository; + @Autowired private PolicyChatMessageRepository messageRepository; + + @Test + void 정책과_exclusive_cursor로_메시지를_ID_오름차순_조회한다() { + Policy firstPolicy = policyRepository.save(newPolicy("P001")); + Policy secondPolicy = policyRepository.save(newPolicy("P002")); + User user = + userRepository.save( + User.createSocialUser("kakao", "provider-1", "private@example.com", "청년")); + PolicyChatMessage first = + messageRepository.saveAndFlush( + PolicyChatMessage.create(firstPolicy, user, "첫 메시지")); + PolicyChatMessage second = + messageRepository.saveAndFlush( + PolicyChatMessage.create(firstPolicy, user, "둘째 메시지")); + messageRepository.saveAndFlush(PolicyChatMessage.create(secondPolicy, user, "다른 정책")); + PolicyChatMessage deleted = + messageRepository.saveAndFlush( + PolicyChatMessage.create(firstPolicy, user, "삭제 메시지")); + ReflectionTestUtils.setField(deleted, "deletedAt", LocalDateTime.now()); + messageRepository.saveAndFlush(deleted); + + List result = + messageRepository.findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + firstPolicy.getId(), first.getId(), Pageable.ofSize(50)); + + assertThat(result).extracting(PolicyChatMessage::getId).containsExactly(second.getId()); + assertThat(result) + .allMatch(message -> message.getPolicy().getId().equals(firstPolicy.getId())); + } + + private Policy newPolicy(String policyNo) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + return policy; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java new file mode 100644 index 0000000..592d02e --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java @@ -0,0 +1,33 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PolicyChatAccessServiceTest { + + @Mock private PolicyRepository policyRepository; + @InjectMocks private PolicyChatAccessService accessService; + + @Test + void 숨김_삭제_미존재_정책은_모두_POLICY_NOT_FOUND로_거부한다() { + when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull( + 10L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> accessService.requireVisiblePolicy(10L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PolicyErrorCode.POLICY_NOT_FOUND); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatCommitIntegrationTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatCommitIntegrationTest.java new file mode 100644 index 0000000..1ac1ec9 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatCommitIntegrationTest.java @@ -0,0 +1,94 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import com.bop.youthpick.policy.dto.PolicyChatMessageCreateRequest; +import com.bop.youthpick.policy.dto.PolicyChatMessageResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.repository.PolicyChatMessageRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.policy.service.PolicyChatSubscriptionRegistry.SubscriptionType; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Pageable; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; + +@SpringBootTest +class PolicyChatCommitIntegrationTest { + + @Autowired private PolicyChatService policyChatService; + @Autowired private PolicyRepository policyRepository; + @Autowired private UserRepository userRepository; + @Autowired private PolicyChatMessageRepository messageRepository; + @Autowired private PolicyChatSubscriptionRegistry subscriptionRegistry; + + @MockitoBean private SimpMessagingTemplate messagingTemplate; + + @AfterEach + void cleanUp() { + messageRepository.deleteAll(); + } + + @Test + void send_트랜잭션이_커밋된_뒤_저장된_메시지를_STOMP로_발행한다() { + Policy policy = policyRepository.save(newPolicy("CHAT-COMMIT-1")); + User user = + userRepository.save( + User.createSocialUser("kakao", "chat-commit-user", null, "커밋 사용자")); + subscriptionRegistry.register( + "commit-session", + "commit-subscription", + user.getId(), + user.getId().toString(), + policy.getId(), + SubscriptionType.MESSAGES); + + policyChatService.send( + policy.getId(), + user.getId(), + new PolicyChatMessageCreateRequest("커밋 메시지", "commit-client-message")); + + assertThat( + messageRepository + .findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + policy.getId(), 0L, Pageable.ofSize(50))) + .hasSize(1); + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(messagingTemplate, timeout(1_000)) + .convertAndSendToUser( + org.mockito.ArgumentMatchers.eq(user.getId().toString()), + org.mockito.ArgumentMatchers.eq( + "/queue/policies/" + policy.getId() + "/chat/messages"), + payload.capture()); + assertThat(payload.getValue()) + .isInstanceOfSatisfying( + PolicyChatMessageResponse.class, + response -> { + assertThat(response.policyId()).isEqualTo(policy.getId()); + assertThat(response.content()).isEqualTo("커밋 메시지"); + assertThat(response.mine()).isTrue(); + assertThat(response.clientMessageId()) + .isEqualTo("commit-client-message"); + }); + subscriptionRegistry.disconnect("commit-session"); + } + + private Policy newPolicy(String policyNo) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", "커밋 테스트 정책"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + return policy; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java new file mode 100644 index 0000000..ca2964e --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java @@ -0,0 +1,227 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; +import com.bop.youthpick.auth.service.JwtProperties; +import com.bop.youthpick.auth.service.JwtStompAuthentication; +import com.bop.youthpick.auth.service.JwtTokenProvider; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; + +class PolicyChatInboundInterceptorTest { + + private JwtTokenProvider jwtTokenProvider; + private UserRepository userRepository; + private PolicyChatAccessService accessService; + private PolicyChatSubscriptionRegistry registry; + private PolicyChatInboundInterceptor interceptor; + private MessageChannel channel; + + @BeforeEach + void setUp() { + jwtTokenProvider = + new JwtTokenProvider( + new JwtProperties( + "test-secret-key-please-be-long-enough-for-hs256", + Duration.ofMinutes(30), + Duration.ofDays(14))); + accessService = mock(PolicyChatAccessService.class); + userRepository = mock(UserRepository.class); + registry = new PolicyChatSubscriptionRegistry(); + interceptor = + new PolicyChatInboundInterceptor( + jwtTokenProvider, userRepository, accessService, registry); + channel = mock(MessageChannel.class); + } + + @Test + void CONNECT는_Bearer_access_token으로_userId와_role을_가진_Principal을_설정한다() { + String token = jwtTokenProvider.createAccessToken(7L, "USER"); + when(userRepository.findByIdAndDeletedAtIsNull(7L)) + .thenReturn(Optional.of(mock(User.class))); + Message connect = frame(StompCommand.CONNECT, null, null, "session-1", null); + StompHeaderAccessor accessor = accessor(connect); + accessor.setNativeHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + + interceptor.preSend(connect, channel); + + assertThat(accessor.getUser()).isInstanceOf(JwtStompAuthentication.class); + JwtStompAuthentication authentication = (JwtStompAuthentication) accessor.getUser(); + assertThat(authentication.getName()).isEqualTo("7"); + assertThat(authentication.getPrincipal()).isEqualTo(new AuthPrincipal(7L, "USER")); + assertThat(authentication.getAuthorities()) + .extracting("authority") + .containsExactly("ROLE_USER"); + } + + @Test + void CONNECT는_삭제되었거나_존재하지_않는_JWT_사용자를_USER_NOT_FOUND로_거부한다() { + String token = jwtTokenProvider.createAccessToken(99L, "USER"); + when(userRepository.findByIdAndDeletedAtIsNull(99L)).thenReturn(Optional.empty()); + Message connect = frame(StompCommand.CONNECT, null, null, "session-1", null); + StompHeaderAccessor accessor = accessor(connect); + accessor.setNativeHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); + + assertThatThrownBy(() -> interceptor.preSend(connect, channel)) + .isInstanceOf(UserException.class) + .hasFieldOrPropertyWithValue("errorCode", UserError.USER_NOT_FOUND); + assertThat(accessor.getUser()).isNull(); + } + + @Test + void CONNECT는_토큰이_없거나_유효하지_않으면_거부한다() { + Message missing = frame(StompCommand.CONNECT, null, null, "session-1", null); + Message invalid = frame(StompCommand.CONNECT, null, null, "session-2", null); + accessor(invalid).setNativeHeader(HttpHeaders.AUTHORIZATION, "Bearer invalid"); + + assertThatThrownBy(() -> interceptor.preSend(missing, channel)) + .isInstanceOf(AuthException.class) + .hasFieldOrPropertyWithValue("errorCode", AuthErrorCode.UNAUTHORIZED); + assertThatThrownBy(() -> interceptor.preSend(invalid, channel)) + .isInstanceOf(AuthException.class) + .hasFieldOrPropertyWithValue("errorCode", AuthErrorCode.INVALID_TOKEN); + } + + @Test + void SEND는_인증과_정확한_app_목적지와_보이는_정책만_허용한다() { + JwtStompAuthentication user = new JwtStompAuthentication(7L, "USER"); + Message allowed = + frame(StompCommand.SEND, "/app/policies/10/chat/messages", user, "session-1", null); + + interceptor.preSend(allowed, channel); + + verify(accessService).requireVisiblePolicy(10L); + assertThatThrownBy( + () -> + interceptor.preSend( + frame( + StompCommand.SEND, + "/topic/policies/10/chat/messages", + user, + "session-1", + null), + channel)) + .isInstanceOf(AuthException.class); + assertThatThrownBy( + () -> + interceptor.preSend( + frame( + StompCommand.SEND, + "/app/policies/10/chat/messages", + null, + "session-1", + null), + channel)) + .isInstanceOf(AuthException.class); + } + + @Test + void SUBSCRIBE는_보이지_않는_정책과_알수없는_queue를_거부한다() { + JwtStompAuthentication user = new JwtStompAuthentication(7L, "USER"); + doThrow(new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)) + .when(accessService) + .requireVisiblePolicy(99L); + + assertThatThrownBy( + () -> + interceptor.preSend( + frame( + StompCommand.SUBSCRIBE, + "/user/queue/policies/99/chat/messages", + user, + "session-1", + "sub-1"), + channel)) + .isInstanceOf(CustomException.class); + assertThatThrownBy( + () -> + interceptor.preSend( + frame( + StompCommand.SUBSCRIBE, + "/user/queue/global/chat/messages", + user, + "session-1", + "sub-2"), + channel)) + .isInstanceOf(AuthException.class); + } + + @Test + void SUBSCRIBE_UNSUBSCRIBE가_등록을_안전하게_정리한다() { + JwtStompAuthentication user = new JwtStompAuthentication(7L, "USER"); + Message first = + frame( + StompCommand.SUBSCRIBE, + "/user/queue/policies/10/chat/messages", + user, + "session-1", + "sub-1"); + Message second = + frame( + StompCommand.SUBSCRIBE, + "/user/queue/policies/10/chat/errors", + user, + "session-1", + "sub-2"); + interceptor.preSend(first, channel); + interceptor.preSend(second, channel); + interceptor.afterSendCompletion(first, channel, true, null); + interceptor.afterSendCompletion(second, channel, true, null); + assertThat(registry.registrationCount()).isEqualTo(2); + + Message unsubscribe = + frame(StompCommand.UNSUBSCRIBE, null, user, "session-1", "sub-1"); + interceptor.afterSendCompletion(unsubscribe, channel, true, null); + assertThat(registry.registrationCount()).isOne(); + } + + private Message frame( + StompCommand command, + String destination, + JwtStompAuthentication user, + String sessionId, + String subscriptionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(command); + if (destination != null) { + accessor.setDestination(destination); + } + if (user != null) { + accessor.setUser(user); + } + if (sessionId != null) { + accessor.setSessionId(sessionId); + } + if (subscriptionId != null) { + accessor.setSubscriptionId(subscriptionId); + } + accessor.setLeaveMutable(true); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } + + private StompHeaderAccessor accessor(Message message) { + return MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatServiceTest.java new file mode 100644 index 0000000..a7a4f05 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatServiceTest.java @@ -0,0 +1,186 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyChatMessageCreateRequest; +import com.bop.youthpick.policy.dto.PolicyChatMessageResponse; +import com.bop.youthpick.policy.dto.PolicyChatMessagesResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyChatMessage; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyChatMessageRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyChatServiceTest { + + @Mock private PolicyChatAccessService accessService; + @Mock private UserRepository userRepository; + @Mock private PolicyChatMessageRepository messageRepository; + @Mock private PolicyChatStompPublisher stompPublisher; + + private PolicyChatService policyChatService; + + @BeforeEach + void setUp() { + policyChatService = + new PolicyChatService( + accessService, userRepository, messageRepository, stompPublisher); + } + + @Test + void 보이는_정책의_메시지만_cursor_이후로_조회한다() { + Policy policy = newPolicy(10L, PolicyVisibility.VISIBLE, null); + User mine = newUser(1L, "내 닉네임", "private@example.com"); + User other = newUser(2L, null, "other@example.com"); + PolicyChatMessage first = newMessage(11L, policy, mine, "첫 메시지"); + PolicyChatMessage second = newMessage(12L, policy, other, "둘째 메시지"); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(mine)); + when(accessService.requireVisiblePolicy(10L)).thenReturn(policy); + when(messageRepository.findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + eq(10L), eq(7L), any(Pageable.class))) + .thenReturn(List.of(first, second)); + + PolicyChatMessagesResponse response = policyChatService.getMessages(10L, 1L, 7L); + + assertThat(response.nextCursor()).isEqualTo(12L); + assertThat(response.messages()) + .extracting(PolicyChatMessageResponse::id) + .containsExactly(11L, 12L); + assertThat(response.messages().get(0).mine()).isTrue(); + assertThat(response.messages().get(1).authorName()).isEqualTo("사용자"); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(messageRepository) + .findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc( + eq(10L), eq(7L), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(50); + } + + @Test + void 삭제되었거나_존재하지_않는_사용자는_메시지_이력을_조회할_수_없다() { + when(userRepository.findByIdAndDeletedAtIsNull(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> policyChatService.getMessages(10L, 99L, 0L)) + .isInstanceOf(UserException.class) + .hasFieldOrPropertyWithValue("errorCode", UserError.USER_NOT_FOUND); + + verify(messageRepository, never()) + .findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc(any(), any(), any()); + } + + @Test + void 숨김_정책이면_POLICY_NOT_FOUND를_던지고_메시지를_조회하지_않는다() { + when(userRepository.findByIdAndDeletedAtIsNull(1L)) + .thenReturn(Optional.of(newUser(1L, "사용자", null))); + when(accessService.requireVisiblePolicy(10L)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + + assertPolicyNotFound(() -> policyChatService.getMessages(10L, 1L, 0L)); + + verify(messageRepository, never()) + .findByPolicyIdAndIdGreaterThanAndDeletedAtIsNullOrderByIdAsc(any(), any(), any()); + } + + @Test + void 삭제_정책이면_POLICY_NOT_FOUND를_던지고_메시지를_보내지_않는다() { + when(accessService.requireVisiblePolicy(10L)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + + assertPolicyNotFound( + () -> + policyChatService.send( + 10L, 1L, new PolicyChatMessageCreateRequest("메시지", null))); + + verify(messageRepository, never()).save(any()); + verify(stompPublisher, never()).publishAfterCommit(any(PolicyChatMessage.class), any()); + } + + @Test + void 삭제되었거나_존재하지_않는_사용자는_메시지를_보낼_수_없다() { + Policy policy = newPolicy(10L, PolicyVisibility.VISIBLE, null); + when(accessService.requireVisiblePolicy(10L)).thenReturn(policy); + when(userRepository.findByIdAndDeletedAtIsNull(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + policyChatService.send( + 10L, 99L, new PolicyChatMessageCreateRequest("메시지", null))) + .isInstanceOf(UserException.class) + .hasFieldOrPropertyWithValue("errorCode", UserError.USER_NOT_FOUND); + + verify(messageRepository, never()).save(any()); + verify(stompPublisher, never()).publishAfterCommit(any(PolicyChatMessage.class), any()); + } + + @Test + void 메시지를_저장하고_정책별_커밋후_알림을_예약한다() { + Policy policy = newPolicy(10L, PolicyVisibility.VISIBLE, null); + User user = newUser(1L, "청년", "private@example.com"); + PolicyChatMessage saved = newMessage(20L, policy, user, "안녕하세요"); + when(accessService.requireVisiblePolicy(10L)).thenReturn(policy); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + when(messageRepository.save(any(PolicyChatMessage.class))).thenReturn(saved); + + PolicyChatMessageResponse response = + policyChatService.send( + 10L, 1L, new PolicyChatMessageCreateRequest("안녕하세요", "client-message-1")); + + assertThat(response.id()).isEqualTo(20L); + assertThat(response.policyId()).isEqualTo(10L); + assertThat(response.authorName()).isEqualTo("청년"); + assertThat(response.mine()).isTrue(); + verify(stompPublisher).publishAfterCommit(saved, "client-message-1"); + } + + private void assertPolicyNotFound(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) { + assertThatThrownBy(call) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PolicyErrorCode.POLICY_NOT_FOUND); + } + + private Policy newPolicy(Long id, PolicyVisibility visibility, LocalDateTime deletedAt) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "id", id); + ReflectionTestUtils.setField(policy, "policyNo", "P" + id); + ReflectionTestUtils.setField(policy, "title", "정책 " + id); + ReflectionTestUtils.setField(policy, "visibility", visibility); + ReflectionTestUtils.setField(policy, "deletedAt", deletedAt); + return policy; + } + + private User newUser(Long id, String nickname, String email) { + User user = User.createSocialUser("kakao", "provider-" + id, email, nickname); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private PolicyChatMessage newMessage(Long id, Policy policy, User user, String content) { + PolicyChatMessage message = PolicyChatMessage.create(policy, user, content); + ReflectionTestUtils.setField(message, "id", id); + ReflectionTestUtils.setField(message, "createdAt", LocalDateTime.of(2026, 7, 19, 12, 0)); + return message; + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListenerTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListenerTest.java new file mode 100644 index 0000000..0369364 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatSessionLifecycleListenerTest.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.policy.service.PolicyChatSubscriptionRegistry.SubscriptionType; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +class PolicyChatSessionLifecycleListenerTest { + + @Test + void transport가_갑자기_종료되어도_session_등록을_모두_정리한다() { + PolicyChatSubscriptionRegistry registry = new PolicyChatSubscriptionRegistry(); + registry.register("session-1", "sub-1", 1L, "1", 10L, SubscriptionType.MESSAGES); + registry.register("session-1", "sub-2", 1L, "1", 10L, SubscriptionType.ERRORS); + SessionDisconnectEvent event = mock(SessionDisconnectEvent.class); + when(event.getSessionId()).thenReturn("session-1"); + + new PolicyChatSessionLifecycleListener(registry).handleDisconnect(event); + + assertThat(registry.registrationCount()).isZero(); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatStompPublisherTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatStompPublisherTest.java new file mode 100644 index 0000000..8ed0f35 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatStompPublisherTest.java @@ -0,0 +1,128 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.bop.youthpick.policy.dto.PolicyChatMessageResponse; +import com.bop.youthpick.policy.service.PolicyChatSubscriptionRegistry.SubscriptionType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.LocalDateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@ExtendWith(MockitoExtension.class) +class PolicyChatStompPublisherTest { + + @Mock private SimpMessagingTemplate messagingTemplate; + + private PolicyChatSubscriptionRegistry registry; + private PolicyChatStompPublisher publisher; + + @BeforeEach + void setUp() { + registry = new PolicyChatSubscriptionRegistry(); + publisher = + new PolicyChatStompPublisher(registry, messagingTemplate, new SyncTaskExecutor()); + } + + @AfterEach + void clearSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void 같은_정책의_활성_사용자에게만_mine과_작성자_correlation으로_전송한다() throws Exception { + registry.register("s1", "m1", 1L, "1", 10L, SubscriptionType.MESSAGES); + registry.register("s2", "m2", 2L, "2", 10L, SubscriptionType.MESSAGES); + registry.register("s3", "m3", 3L, "3", 20L, SubscriptionType.MESSAGES); + registry.register("s1", "e1", 1L, "1", 10L, SubscriptionType.ERRORS); + PolicyChatOutboundMessage message = message(); + + publisher.publish(message); + + ArgumentCaptor user = ArgumentCaptor.forClass(String.class); + ArgumentCaptor destination = ArgumentCaptor.forClass(String.class); + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(messagingTemplate, times(2)) + .convertAndSendToUser(user.capture(), destination.capture(), payload.capture()); + assertThat(user.getAllValues()).containsExactlyInAnyOrder("1", "2"); + assertThat(destination.getAllValues()).containsOnly("/queue/policies/10/chat/messages"); + assertThat(payload.getAllValues()) + .allSatisfy( + value -> assertThat(value).isInstanceOf(PolicyChatMessageResponse.class)); + PolicyChatMessageResponse authorView = responseFor("1", user, payload); + PolicyChatMessageResponse otherView = responseFor("2", user, payload); + assertThat(authorView.mine()).isTrue(); + assertThat(otherView.mine()).isFalse(); + assertThat(authorView.clientMessageId()).isEqualTo("client-message-1"); + assertThat(otherView.clientMessageId()).isNull(); + assertThat(authorView) + .hasNoNullFieldsOrProperties() + .extracting( + PolicyChatMessageResponse::policyId, + PolicyChatMessageResponse::authorName, + PolicyChatMessageResponse::content) + .containsExactly(10L, "작성자", "메시지"); + JsonMapper objectMapper = JsonMapper.builder().addModule(new JavaTimeModule()).build(); + JsonNode authorJson = objectMapper.valueToTree(authorView); + JsonNode otherJson = objectMapper.valueToTree(otherView); + assertThat(authorJson.path("clientMessageId").asText()).isEqualTo("client-message-1"); + assertThat(otherJson.has("clientMessageId")).isFalse(); + } + + @Test + void 트랜잭션_커밋_후에만_STOMP로_전송한다() { + registry.register("s1", "m1", 1L, "1", 10L, SubscriptionType.MESSAGES); + TransactionSynchronizationManager.initSynchronization(); + publisher.publishAfterCommit(message()); + + verify(messagingTemplate, never()).convertAndSendToUser(any(), any(), any()); + TransactionSynchronizationManager.getSynchronizations().stream() + .forEach(TransactionSynchronization::afterCommit); + verify(messagingTemplate) + .convertAndSendToUser( + "1", + "/queue/policies/10/chat/messages", + new PolicyChatMessageResponse( + 11L, + 10L, + "작성자", + "메시지", + LocalDateTime.of(2026, 7, 19, 12, 0), + true, + "client-message-1")); + } + + private PolicyChatMessageResponse responseFor( + String expectedUser, ArgumentCaptor users, ArgumentCaptor payloads) { + int index = users.getAllValues().indexOf(expectedUser); + return (PolicyChatMessageResponse) payloads.getAllValues().get(index); + } + + private PolicyChatOutboundMessage message() { + return new PolicyChatOutboundMessage( + 11L, + 10L, + 1L, + "작성자", + "메시지", + LocalDateTime.of(2026, 7, 19, 12, 0), + "client-message-1"); + } +} From 74afca65483719f34ce505553ff7173a10b57387 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Mon, 20 Jul 2026 21:21:27 +0900 Subject: [PATCH 39/93] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EC=A6=9D=EA=B0=80=20=EB=B0=8F=20Redis=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EC=A0=81=EC=9A=A9=20(#97)=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 게시글 조회 시 조회수 증가 및 Redis 중복 방지 적용 * refac: 리뷰 피드백 반영 (다중 IP 분리 및 벌크 쿼리 조건 추가) --- .../post/controller/PostController.java | 33 +++++++++++- .../com/bop/youthpick/post/entity/Post.java | 4 ++ .../post/repository/PostRepository.java | 8 +++ .../youthpick/post/service/PostService.java | 9 +++- .../post/service/PostViewLogStore.java | 30 +++++++++++ .../post/controller/PostControllerTest.java | 3 +- .../post/service/PostServiceTest.java | 23 ++++++-- .../post/service/PostViewLogStoreTest.java | 54 +++++++++++++++++++ 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/post/service/PostViewLogStore.java create mode 100644 src/test/java/com/bop/youthpick/post/service/PostViewLogStoreTest.java diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java index c63f68f..9fd8ccf 100644 --- a/src/main/java/com/bop/youthpick/post/controller/PostController.java +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -7,6 +7,7 @@ import com.bop.youthpick.post.dto.PostSummaryResponse; import com.bop.youthpick.post.dto.PostUpdateRequest; import com.bop.youthpick.post.service.PostService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -43,8 +44,36 @@ public ApiResponse> findAll( // 리스폰스엔티티 } @GetMapping("/{postId}") - public ApiResponse findById(@PathVariable Long postId) { - return ApiResponse.ok(postService.findById(postId)); + public ApiResponse findById( + @PathVariable Long postId, + @CurrentUser(required = false) Long userId, + HttpServletRequest request) { + String ipAddress = getClientIp(request); + return ApiResponse.ok(postService.findById(postId, userId, ipAddress)); + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; } @PatchMapping("/{postId}") diff --git a/src/main/java/com/bop/youthpick/post/entity/Post.java b/src/main/java/com/bop/youthpick/post/entity/Post.java index ca271f9..56a6604 100644 --- a/src/main/java/com/bop/youthpick/post/entity/Post.java +++ b/src/main/java/com/bop/youthpick/post/entity/Post.java @@ -65,4 +65,8 @@ public void update(Policy policy, PostCategory category, String title, String co public void softDelete() { this.deletedAt = LocalDateTime.now(); } + + public void incrementViewCount() { + this.viewCount += 1; + } } diff --git a/src/main/java/com/bop/youthpick/post/repository/PostRepository.java b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java index daf54d1..67e6aed 100644 --- a/src/main/java/com/bop/youthpick/post/repository/PostRepository.java +++ b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java @@ -7,6 +7,9 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostRepository extends JpaRepository, JpaSpecificationExecutor { @@ -15,4 +18,9 @@ public interface PostRepository extends JpaRepository, JpaSpecificat @EntityGraph(attributePaths = {"user", "policy"}) Page findAllByDeletedAtIsNull(Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query( + "update Post p set p.viewCount = p.viewCount + 1 where p.id = :id and p.deletedAt is null") + int incrementViewCount(@Param("id") Long id); } diff --git a/src/main/java/com/bop/youthpick/post/service/PostService.java b/src/main/java/com/bop/youthpick/post/service/PostService.java index 48cf968..9e74615 100644 --- a/src/main/java/com/bop/youthpick/post/service/PostService.java +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -30,6 +30,7 @@ public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final PolicyRepository policyRepository; + private final PostViewLogStore postViewLogStore; @Transactional public PostDetailResponse create(Long userId, PostCreateRequest request) { @@ -49,8 +50,12 @@ public Page findAll(Pageable pageable) { return postRepository.findAllByDeletedAtIsNull(pageable).map(PostSummaryResponse::from); } - @Transactional(readOnly = true) - public PostDetailResponse findById(Long postId) { + @Transactional + public PostDetailResponse findById(Long postId, Long userId, String ipAddress) { + String identifier = userId != null ? "user:" + userId : "ip:" + ipAddress; + if (postViewLogStore.isFirstView(postId, identifier)) { + postRepository.incrementViewCount(postId); + } return PostDetailResponse.from(findPost(postId)); } diff --git a/src/main/java/com/bop/youthpick/post/service/PostViewLogStore.java b/src/main/java/com/bop/youthpick/post/service/PostViewLogStore.java new file mode 100644 index 0000000..29fec8f --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/service/PostViewLogStore.java @@ -0,0 +1,30 @@ +package com.bop.youthpick.post.service; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** Redis를 활용하여 동일 유저/IP의 게시글 중복 조회를 방지하는 Store */ +@Component +@RequiredArgsConstructor +public class PostViewLogStore { + + private final StringRedisTemplate redisTemplate; + + private static final String KEY_PREFIX = "post:view:"; + private static final Duration LOCK_DURATION = Duration.ofDays(1); // 24시간 중복 방지 + + /** + * 해당 게시글에 대해 주어진 식별자가 처음으로 조회하는지 여부를 검증하고, 처음이라면 24시간 동안 유효한 중복 방지 락 키를 생성한다. + * + * @param postId 게시글 ID + * @param identifier 식별자 (user:{userId} 또는 ip:{ipAddress}) + * @return 첫 조회일 경우 true, 이미 조회한 이력이 있는 경우 false + */ + public boolean isFirstView(Long postId, String identifier) { + String key = KEY_PREFIX + postId + ":" + identifier; + Boolean isFirst = redisTemplate.opsForValue().setIfAbsent(key, "Y", LOCK_DURATION); + return Boolean.TRUE.equals(isFirst); + } +} diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java index c18ed25..4e646b5 100644 --- a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -103,7 +103,8 @@ void setUp() { @Test void 게시글_상세를_조회한다() throws Exception { - when(postService.findById(3L)).thenReturn(detail(3L, "FREE", "제목", "내용")); + when(postService.findById(eq(3L), eq(1L), any())) + .thenReturn(detail(3L, "FREE", "제목", "내용")); mockMvc.perform(get("/api/v1/posts/{postId}", 3L)) .andExpect(status().isOk()) diff --git a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java index c9ed4d0..6786e61 100644 --- a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -37,12 +37,14 @@ class PostServiceTest { @Mock private PostRepository postRepository; @Mock private UserRepository userRepository; @Mock private PolicyRepository policyRepository; + @Mock private PostViewLogStore postViewLogStore; private PostService postService; @BeforeEach void setUp() { - postService = new PostService(postRepository, userRepository, policyRepository); + postService = + new PostService(postRepository, userRepository, policyRepository, postViewLogStore); } @Test @@ -92,14 +94,29 @@ void setUp() { } @Test - void 게시글_상세를_조회한다() { + void 처음_조회할_때_게시글_상세를_조회하고_조회수를_증가시킨다() { Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); + when(postViewLogStore.isFirstView(3L, "user:1")).thenReturn(true); when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); - PostDetailResponse response = postService.findById(3L); + PostDetailResponse response = postService.findById(3L, 1L, "127.0.0.1"); assertThat(response.title()).isEqualTo("제목"); assertThat(response.content()).isEqualTo("내용"); + verify(postRepository).incrementViewCount(3L); + } + + @Test + void 이미_조회한_경우_조회수를_증가시키지_않고_상세를_조회한다() { + Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); + when(postViewLogStore.isFirstView(3L, "ip:127.0.0.1")).thenReturn(false); + when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); + + PostDetailResponse response = postService.findById(3L, null, "127.0.0.1"); + + assertThat(response.title()).isEqualTo("제목"); + assertThat(response.content()).isEqualTo("내용"); + verify(postRepository, never()).incrementViewCount(3L); } @Test diff --git a/src/test/java/com/bop/youthpick/post/service/PostViewLogStoreTest.java b/src/test/java/com/bop/youthpick/post/service/PostViewLogStoreTest.java new file mode 100644 index 0000000..b2b4f7d --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/service/PostViewLogStoreTest.java @@ -0,0 +1,54 @@ +package com.bop.youthpick.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class PostViewLogStoreTest { + + @Mock private StringRedisTemplate redisTemplate; + + @Mock private ValueOperations valueOperations; + + private PostViewLogStore postViewLogStore; + + @BeforeEach + void setUp() { + postViewLogStore = new PostViewLogStore(redisTemplate); + } + + @Test + void 처음_조회하면_true를_반환하고_락을_설정한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(eq("post:view:1:user:1"), eq("Y"), any(Duration.class))) + .thenReturn(true); + + boolean result = postViewLogStore.isFirstView(1L, "user:1"); + + assertThat(result).isTrue(); + verify(valueOperations).setIfAbsent(eq("post:view:1:user:1"), eq("Y"), any(Duration.class)); + } + + @Test + void 이미_조회했으면_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(eq("post:view:1:user:1"), eq("Y"), any(Duration.class))) + .thenReturn(false); + + boolean result = postViewLogStore.isFirstView(1L, "user:1"); + + assertThat(result).isFalse(); + } +} From d8e6d619889c4406239ade955e7c8ef5e75b35da Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Mon, 20 Jul 2026 21:22:23 +0900 Subject: [PATCH 40/93] =?UTF-8?q?feat:=20MinIO=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20API=20=EC=B6=94=EA=B0=80=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: MinIO 파일 업로드 및 다운로드 API 추가 * fix: 파일 저장소 타임아웃과 업로드 경계 개선 --- .env.example | 18 +- build.gradle | 1 + docker-compose.yml | 24 ++- .../file/controller/FileController.java | 59 ++++++ .../bop/youthpick/file/dto/FileDownload.java | 6 + .../file/dto/FileUploadResponse.java | 6 + .../file/exception/FileErrorCode.java | 20 ++ .../file/exception/FileException.java | 10 + .../file/service/FileProperties.java | 7 + .../youthpick/file/service/FileService.java | 186 ++++++++++++++++++ .../youthpick/file/service/MinioConfig.java | 23 +++ .../file/service/MinioObjectStorage.java | 91 +++++++++ .../file/service/MinioProperties.java | 14 ++ .../youthpick/file/service/ObjectStorage.java | 17 ++ .../file/service/ObjectStorageException.java | 23 +++ .../youthpick/file/service/StoredObject.java | 7 + .../global/config/SecurityConfig.java | 5 + .../global/error/GlobalExceptionHandler.java | 22 ++- src/main/resources/application-local.yml | 5 + src/main/resources/application.yml | 15 ++ .../file/controller/FileControllerTest.java | 117 +++++++++++ .../file/service/FileServiceTest.java | 152 ++++++++++++++ .../file/service/MinioObjectStorageTest.java | 58 ++++++ .../global/config/SecurityConfigTest.java | 16 ++ src/test/resources/application.yml | 10 + 25 files changed, 907 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/file/controller/FileController.java create mode 100644 src/main/java/com/bop/youthpick/file/dto/FileDownload.java create mode 100644 src/main/java/com/bop/youthpick/file/dto/FileUploadResponse.java create mode 100644 src/main/java/com/bop/youthpick/file/exception/FileErrorCode.java create mode 100644 src/main/java/com/bop/youthpick/file/exception/FileException.java create mode 100644 src/main/java/com/bop/youthpick/file/service/FileProperties.java create mode 100644 src/main/java/com/bop/youthpick/file/service/FileService.java create mode 100644 src/main/java/com/bop/youthpick/file/service/MinioConfig.java create mode 100644 src/main/java/com/bop/youthpick/file/service/MinioObjectStorage.java create mode 100644 src/main/java/com/bop/youthpick/file/service/MinioProperties.java create mode 100644 src/main/java/com/bop/youthpick/file/service/ObjectStorage.java create mode 100644 src/main/java/com/bop/youthpick/file/service/ObjectStorageException.java create mode 100644 src/main/java/com/bop/youthpick/file/service/StoredObject.java create mode 100644 src/test/java/com/bop/youthpick/file/controller/FileControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/file/service/FileServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/file/service/MinioObjectStorageTest.java diff --git a/.env.example b/.env.example index 6609efb..1baa257 100644 --- a/.env.example +++ b/.env.example @@ -2,14 +2,14 @@ # # 사용법: # 1) 이 파일을 .env 로 복사한다: cp .env.example .env -# 2) docker compose up -d (docker compose 가 .env 를 자동으로 읽어 MySQL/Redis 를 띄움) +# 2) docker compose up -d (docker compose 가 .env 를 자동으로 읽어 MySQL/Redis/MinIO 를 띄움) # 3) 앱은 IDE/로컬에서 실행한다. (backend/front 는 컨테이너화하지 않음) # # 주의: # - .env 는 절대 커밋하지 않는다(.gitignore 처리됨). 이 .env.example 만 공유한다. # - Spring 은 .env 를 자동으로 읽지 않는다. 아래 DB_*/REDIS_* 로 "도커 MySQL" 에 붙이려면 # IDE 실행 구성의 Environment variables 에 넣거나, 셸에서 export 후 실행한다. -# - 그냥 설정 없이 앱을 실행하면 application-local.yml 기본값(H2 인메모리 + localhost Redis)으로 동작한다. +# - 그냥 설정 없이 앱을 실행하면 application-local.yml의 로컬 인프라 기본값으로 동작한다. # ===== Database (docker-compose MySQL 컨테이너 설정) ===== DB_NAME=youthpick @@ -28,6 +28,20 @@ JPA_DDL_AUTO=validate REDIS_HOST=localhost REDIS_PORT=6379 +# ===== MinIO ===== +# 앱은 API 포트(기본 9000)에 연결한다. 콘솔은 http://localhost:9001 에서 확인한다. +MINIO_ENDPOINT=http://localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=youthpick-files +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 +FILE_MAX_SIZE=10MB +FILE_MAX_REQUEST_SIZE=11MB +MINIO_CONNECT_TIMEOUT=3s +MINIO_WRITE_TIMEOUT=30s +MINIO_READ_TIMEOUT=30s + # ===== 온통청년 정책 API (배치 수집용 — 비우면 배치만 실패하고 앱은 뜬다) ===== # 발급: https://www.youthcenter.go.kr 회원가입 후 open api 인증키 신청 YOUTH_API_KEY= diff --git a/build.gradle b/build.gradle index 631685c..e1a22e7 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation 'io.minio:minio:9.0.1' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' implementation 'org.flywaydb:flyway-core' diff --git a/docker-compose.yml b/docker-compose.yml index a406229..5b5816b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ -# 로컬 개발용 인프라만 띄운다 (MySQL + Redis). +# 로컬 개발용 인프라만 띄운다 (MySQL + Redis + MinIO). # backend/front 는 컨테이너화하지 않고 각자 로컬(IDE)에서 실행한다. # # 사용법: docker-compose.yml 파일과 같은 레벨의 패키지로 가서 아래 명령어 실행. -# docker compose up -d # .env 를 자동으로 읽어 MySQL/Redis 기동 +# docker compose up -d # .env 를 자동으로 읽어 MySQL/Redis/MinIO 기동 # docker compose ps # 상태 확인 # docker compose down # 중지 (데이터는 볼륨에 보존) # docker compose down -v # 중지 + 데이터 볼륨 삭제(초기화) @@ -44,6 +44,26 @@ services: timeout: 5s retries: 10 + minio: + image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: youthpick-minio + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} + command: server /data --console-address ":9001" + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - youthpick-minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + volumes: youthpick-mysql-data: youthpick-redis-data: + youthpick-minio-data: diff --git a/src/main/java/com/bop/youthpick/file/controller/FileController.java b/src/main/java/com/bop/youthpick/file/controller/FileController.java new file mode 100644 index 0000000..ddb0457 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/controller/FileController.java @@ -0,0 +1,59 @@ +package com.bop.youthpick.file.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.file.dto.FileDownload; +import com.bop.youthpick.file.dto.FileUploadResponse; +import com.bop.youthpick.file.service.FileService; +import com.bop.youthpick.global.common.ApiResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +public class FileController { + + private static final Duration FILE_CACHE_MAX_AGE = Duration.ofDays(365); + + private final FileService fileService; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> upload( + @CurrentUser Long userId, @RequestPart("file") MultipartFile file) { + FileUploadResponse response = fileService.upload(userId, file); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(response)); + } + + @GetMapping("/{fileId}") + public ResponseEntity download(@PathVariable UUID fileId) { + FileDownload file = fileService.download(fileId); + ContentDisposition contentDisposition = + ContentDisposition.inline() + .filename(file.originalFilename(), StandardCharsets.UTF_8) + .build(); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.contentType())) + .contentLength(file.size()) + .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString()) + .header("X-Content-Type-Options", "nosniff") + .cacheControl(CacheControl.maxAge(FILE_CACHE_MAX_AGE).cachePublic().immutable()) + .body(new InputStreamResource(file.inputStream())); + } +} diff --git a/src/main/java/com/bop/youthpick/file/dto/FileDownload.java b/src/main/java/com/bop/youthpick/file/dto/FileDownload.java new file mode 100644 index 0000000..3bfc8d9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/dto/FileDownload.java @@ -0,0 +1,6 @@ +package com.bop.youthpick.file.dto; + +import java.io.InputStream; + +public record FileDownload( + String originalFilename, String contentType, long size, InputStream inputStream) {} diff --git a/src/main/java/com/bop/youthpick/file/dto/FileUploadResponse.java b/src/main/java/com/bop/youthpick/file/dto/FileUploadResponse.java new file mode 100644 index 0000000..d1f6dd9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/dto/FileUploadResponse.java @@ -0,0 +1,6 @@ +package com.bop.youthpick.file.dto; + +import java.util.UUID; + +public record FileUploadResponse( + UUID fileId, String originalFilename, String contentType, long size, String downloadUrl) {} diff --git a/src/main/java/com/bop/youthpick/file/exception/FileErrorCode.java b/src/main/java/com/bop/youthpick/file/exception/FileErrorCode.java new file mode 100644 index 0000000..258637e --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/exception/FileErrorCode.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.file.exception; + +import com.bop.youthpick.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum FileErrorCode implements ErrorCode { + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "C003", "파일을 찾을 수 없습니다."), + EMPTY_FILE(HttpStatus.BAD_REQUEST, "C004", "빈 파일은 업로드할 수 없습니다."), + UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "C005", "지원하지 않는 이미지 형식입니다."), + FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "C006", "파일 크기 제한을 초과했습니다."), + STORAGE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "S002", "파일 저장소를 일시적으로 사용할 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/bop/youthpick/file/exception/FileException.java b/src/main/java/com/bop/youthpick/file/exception/FileException.java new file mode 100644 index 0000000..857aa07 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/exception/FileException.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.file.exception; + +import com.bop.youthpick.global.error.CustomException; + +public class FileException extends CustomException { + + public FileException(FileErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/bop/youthpick/file/service/FileProperties.java b/src/main/java/com/bop/youthpick/file/service/FileProperties.java new file mode 100644 index 0000000..60b3a7d --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/FileProperties.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.file.service; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; + +@ConfigurationProperties(prefix = "youthpick.file") +public record FileProperties(DataSize maxSize) {} diff --git a/src/main/java/com/bop/youthpick/file/service/FileService.java b/src/main/java/com/bop/youthpick/file/service/FileService.java new file mode 100644 index 0000000..3e82ec7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/FileService.java @@ -0,0 +1,186 @@ +package com.bop.youthpick.file.service; + +import com.bop.youthpick.file.dto.FileDownload; +import com.bop.youthpick.file.dto.FileUploadResponse; +import com.bop.youthpick.file.exception.FileErrorCode; +import com.bop.youthpick.file.exception.FileException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class FileService { + + private static final Set ALLOWED_CONTENT_TYPES = + Set.of("image/jpeg", "image/png", "image/gif", "image/webp"); + private static final String ORIGINAL_FILENAME_METADATA = "original-filename"; + private static final int SIGNATURE_LENGTH = 12; + private static final int MAX_FILENAME_LENGTH = 255; + + private final ObjectStorage objectStorage; + private final long maxFileSize; + + public FileService(ObjectStorage objectStorage, FileProperties properties) { + this.objectStorage = objectStorage; + this.maxFileSize = properties.maxSize().toBytes(); + } + + public FileUploadResponse upload(Long userId, MultipartFile file) { + validate(file); + + UUID fileId = UUID.randomUUID(); + String originalFilename = sanitizeFilename(file.getOriginalFilename()); + Map metadata = + Map.of( + ORIGINAL_FILENAME_METADATA, + encodeFilename(originalFilename), + "uploaded-by", + userId.toString()); + + try (InputStream inputStream = file.getInputStream()) { + objectStorage.put( + fileId.toString(), + inputStream, + file.getSize(), + file.getContentType(), + metadata); + } catch (IOException | ObjectStorageException exception) { + throw new FileException(FileErrorCode.STORAGE_UNAVAILABLE); + } + + return new FileUploadResponse( + fileId, + originalFilename, + file.getContentType(), + file.getSize(), + "/api/v1/files/" + fileId); + } + + public FileDownload download(UUID fileId) { + try { + StoredObject storedObject = objectStorage.get(fileId.toString()); + String originalFilename = + decodeFilename(storedObject.metadata().get(ORIGINAL_FILENAME_METADATA)); + return new FileDownload( + originalFilename, + storedObject.contentType(), + storedObject.size(), + storedObject.inputStream()); + } catch (ObjectStorageException exception) { + if (exception.isObjectNotFound()) { + throw new FileException(FileErrorCode.FILE_NOT_FOUND); + } + throw new FileException(FileErrorCode.STORAGE_UNAVAILABLE); + } + } + + private void validate(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new FileException(FileErrorCode.EMPTY_FILE); + } + if (file.getSize() > maxFileSize) { + throw new FileException(FileErrorCode.FILE_SIZE_EXCEEDED); + } + if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType()) || !hasValidSignature(file)) { + throw new FileException(FileErrorCode.UNSUPPORTED_FILE_TYPE); + } + } + + private boolean hasValidSignature(MultipartFile file) { + byte[] signature = new byte[SIGNATURE_LENGTH]; + try (InputStream inputStream = file.getInputStream()) { + int length = inputStream.read(signature); + return switch (file.getContentType()) { + case "image/jpeg" -> isJpeg(signature, length); + case "image/png" -> isPng(signature, length); + case "image/gif" -> isGif(signature, length); + case "image/webp" -> isWebp(signature, length); + default -> false; + }; + } catch (IOException exception) { + return false; + } + } + + private boolean isJpeg(byte[] signature, int length) { + return length >= 3 + && unsigned(signature[0]) == 0xFF + && unsigned(signature[1]) == 0xD8 + && unsigned(signature[2]) == 0xFF; + } + + private boolean isPng(byte[] signature, int length) { + return length >= 8 + && unsigned(signature[0]) == 0x89 + && signature[1] == 'P' + && signature[2] == 'N' + && signature[3] == 'G' + && unsigned(signature[4]) == 0x0D + && unsigned(signature[5]) == 0x0A + && unsigned(signature[6]) == 0x1A + && unsigned(signature[7]) == 0x0A; + } + + private boolean isGif(byte[] signature, int length) { + return length >= 6 + && (asciiEquals(signature, 0, "GIF87a") || asciiEquals(signature, 0, "GIF89a")); + } + + private boolean isWebp(byte[] signature, int length) { + return length >= SIGNATURE_LENGTH + && asciiEquals(signature, 0, "RIFF") + && asciiEquals(signature, 8, "WEBP"); + } + + private boolean asciiEquals(byte[] bytes, int offset, String expected) { + for (int index = 0; index < expected.length(); index++) { + if (bytes[offset + index] != expected.charAt(index)) { + return false; + } + } + return true; + } + + private int unsigned(byte value) { + return value & 0xFF; + } + + private String sanitizeFilename(String filename) { + if (filename == null || filename.isBlank()) { + return "image"; + } + String normalized = filename.replace('\\', '/'); + String basename = normalized.substring(normalized.lastIndexOf('/') + 1); + String sanitized = basename.replaceAll("[\\r\\n\\u0000]", "").trim(); + if (sanitized.isBlank()) { + return "image"; + } + return sanitized.length() > MAX_FILENAME_LENGTH + ? sanitized.substring(0, MAX_FILENAME_LENGTH) + : sanitized; + } + + private String encodeFilename(String filename) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(filename.getBytes(StandardCharsets.UTF_8)); + } + + private String decodeFilename(String encodedFilename) { + if (encodedFilename == null || encodedFilename.isBlank()) { + return "image"; + } + try { + return new String( + Base64.getUrlDecoder().decode(encodedFilename), StandardCharsets.UTF_8); + } catch (IllegalArgumentException exception) { + return "image"; + } + } +} diff --git a/src/main/java/com/bop/youthpick/file/service/MinioConfig.java b/src/main/java/com/bop/youthpick/file/service/MinioConfig.java new file mode 100644 index 0000000..a8d9ac5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/MinioConfig.java @@ -0,0 +1,23 @@ +package com.bop.youthpick.file.service; + +import io.minio.MinioClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Bean + MinioClient minioClient(MinioProperties properties) { + MinioClient minioClient = + MinioClient.builder() + .endpoint(properties.endpoint()) + .credentials(properties.accessKey(), properties.secretKey()) + .build(); + minioClient.setTimeout( + properties.connectTimeout().toMillis(), + properties.writeTimeout().toMillis(), + properties.readTimeout().toMillis()); + return minioClient; + } +} diff --git a/src/main/java/com/bop/youthpick/file/service/MinioObjectStorage.java b/src/main/java/com/bop/youthpick/file/service/MinioObjectStorage.java new file mode 100644 index 0000000..7566b3f --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/MinioObjectStorage.java @@ -0,0 +1,91 @@ +package com.bop.youthpick.file.service; + +import io.minio.BucketExistsArgs; +import io.minio.GetObjectArgs; +import io.minio.GetObjectResponse; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.StatObjectArgs; +import io.minio.StatObjectResponse; +import io.minio.errors.ErrorResponseException; +import io.minio.errors.MinioException; +import java.io.InputStream; +import java.util.Collections; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MinioObjectStorage implements ObjectStorage { + + private final MinioClient minioClient; + private final MinioProperties properties; + private volatile boolean bucketReady; + + @Override + public void put( + String objectKey, + InputStream inputStream, + long size, + String contentType, + Map metadata) + throws ObjectStorageException { + try { + ensureBucket(); + minioClient.putObject( + PutObjectArgs.builder().bucket(properties.bucket()).object(objectKey).stream( + inputStream, size, -1L) + .contentType(contentType) + .userMetadata(metadata) + .build()); + } catch (MinioException | IllegalArgumentException exception) { + throw ObjectStorageException.unavailable(exception); + } + } + + @Override + public StoredObject get(String objectKey) throws ObjectStorageException { + try { + StatObjectResponse stat = + minioClient.statObject( + StatObjectArgs.builder() + .bucket(properties.bucket()) + .object(objectKey) + .build()); + GetObjectResponse response = + minioClient.getObject( + GetObjectArgs.builder() + .bucket(properties.bucket()) + .object(objectKey) + .build()); + String originalFilename = stat.userMetadata().getFirst("original-filename"); + Map metadata = + originalFilename == null + ? Collections.emptyMap() + : Map.of("original-filename", originalFilename); + return new StoredObject(response, stat.size(), stat.contentType(), metadata); + } catch (ErrorResponseException exception) { + if ("NoSuchKey".equals(exception.errorResponse().code())) { + throw ObjectStorageException.objectNotFound(exception); + } + throw ObjectStorageException.unavailable(exception); + } catch (MinioException | IllegalArgumentException exception) { + throw ObjectStorageException.unavailable(exception); + } + } + + private synchronized void ensureBucket() throws MinioException { + if (bucketReady) { + return; + } + boolean exists = + minioClient.bucketExists( + BucketExistsArgs.builder().bucket(properties.bucket()).build()); + if (!exists) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(properties.bucket()).build()); + } + bucketReady = true; + } +} diff --git a/src/main/java/com/bop/youthpick/file/service/MinioProperties.java b/src/main/java/com/bop/youthpick/file/service/MinioProperties.java new file mode 100644 index 0000000..6e6560f --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/MinioProperties.java @@ -0,0 +1,14 @@ +package com.bop.youthpick.file.service; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "youthpick.minio") +public record MinioProperties( + String endpoint, + String accessKey, + String secretKey, + String bucket, + Duration connectTimeout, + Duration writeTimeout, + Duration readTimeout) {} diff --git a/src/main/java/com/bop/youthpick/file/service/ObjectStorage.java b/src/main/java/com/bop/youthpick/file/service/ObjectStorage.java new file mode 100644 index 0000000..5953bb8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/ObjectStorage.java @@ -0,0 +1,17 @@ +package com.bop.youthpick.file.service; + +import java.io.InputStream; +import java.util.Map; + +public interface ObjectStorage { + + void put( + String objectKey, + InputStream inputStream, + long size, + String contentType, + Map metadata) + throws ObjectStorageException; + + StoredObject get(String objectKey) throws ObjectStorageException; +} diff --git a/src/main/java/com/bop/youthpick/file/service/ObjectStorageException.java b/src/main/java/com/bop/youthpick/file/service/ObjectStorageException.java new file mode 100644 index 0000000..da6a76f --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/ObjectStorageException.java @@ -0,0 +1,23 @@ +package com.bop.youthpick.file.service; + +public class ObjectStorageException extends Exception { + + private final boolean objectNotFound; + + private ObjectStorageException(boolean objectNotFound, Throwable cause) { + super(cause); + this.objectNotFound = objectNotFound; + } + + public static ObjectStorageException objectNotFound(Throwable cause) { + return new ObjectStorageException(true, cause); + } + + public static ObjectStorageException unavailable(Throwable cause) { + return new ObjectStorageException(false, cause); + } + + public boolean isObjectNotFound() { + return objectNotFound; + } +} diff --git a/src/main/java/com/bop/youthpick/file/service/StoredObject.java b/src/main/java/com/bop/youthpick/file/service/StoredObject.java new file mode 100644 index 0000000..90e55aa --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/StoredObject.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.file.service; + +import java.io.InputStream; +import java.util.Map; + +public record StoredObject( + InputStream inputStream, long size, String contentType, Map metadata) {} diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 08cf29e..c011cfb 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -83,6 +83,11 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/api/v1/posts/**") .authenticated() + // 파일 조회 URL은 img src에서 바로 사용하도록 공개하고, 업로드만 회원 전용으로 제한한다. + .requestMatchers(HttpMethod.GET, "/api/v1/files/**") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/files") + .authenticated() // 회원 전용 — 로그인 상태 조회/로그아웃, 회원 탈퇴(컨트롤러 미구현, 경로만 선점), // 마이페이지(관심정책/추천/읽음/프로필), 최근 본 정책 .requestMatchers( diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index bd44956..ddf503c 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -16,6 +16,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; /** * 전역 예외 처리. 모든 컨트롤러 예외를 여기서 {@link ErrorResponse}로 변환한다. 컨트롤러는 try-catch로 에러 응답을 직접 만들지 않는다(여기로 @@ -81,6 +83,23 @@ public ResponseEntity handleMissingServletRequestParameter( .body(ErrorResponse.of(errorCode, List.of(detail))); } + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity handleMissingServletRequestPart( + MissingServletRequestPartException exception) { + ErrorResponse.FieldErrorDetail detail = + new ErrorResponse.FieldErrorDetail( + exception.getRequestPartName(), "", "파일은 필수입니다."); + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, List.of(detail))); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceeded() { + ErrorCode errorCode = GlobalErrorCode.FILE_SIZE_EXCEEDED; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); + } + // @RequestParam 값이 대상 타입(LocalDateTime 등)으로 변환되지 않는 경우 @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleMethodArgumentTypeMismatch( @@ -140,7 +159,8 @@ enum GlobalErrorCode implements ErrorCode { INTERNAL_SERVER_ERROR( HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", "지원하지 않는 HTTP 메서드입니다."); + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", "지원하지 않는 HTTP 메서드입니다."), + FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "C006", "파일 크기 제한을 초과했습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 1b6d4b0..ee634da 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -20,6 +20,11 @@ spring: port: ${REDIS_PORT:6379} youthpick: + minio: + # 로컬 compose의 기본값과 짝이다. 배포 환경은 application.yml의 환경변수 필수 설정을 사용한다. + endpoint: ${MINIO_ENDPOINT:http://localhost:9000} + access-key: ${MINIO_ACCESS_KEY:minioadmin} + secret-key: ${MINIO_SECRET_KEY:minioadmin} jwt: # 서명 secret은 DB 계정과 달리 토큰 위조 가능성과 직결되므로 fallback을 두지 않는다. # 로컬에서도 .env(또는 셸 export)로 JWT_SECRET을 반드시 설정한다. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 857e0ca..70f1fc0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,8 +23,23 @@ spring: # 프론트가 페이지 번호를 1부터 쓰므로(page=1이 첫 페이지) 맞춘다. # PageMeta.from()도 응답의 page를 +1해서 요청/응답 모두 1-based로 통일한다. one-indexed-parameters: true + servlet: + multipart: + max-file-size: ${FILE_MAX_SIZE:10MB} + # multipart boundary/header 오버헤드를 고려해 실제 파일 제한보다 여유를 둔다. + max-request-size: ${FILE_MAX_REQUEST_SIZE:11MB} youthpick: + file: + max-size: ${FILE_MAX_SIZE:10MB} + minio: + endpoint: ${MINIO_ENDPOINT} + access-key: ${MINIO_ACCESS_KEY} + secret-key: ${MINIO_SECRET_KEY} + bucket: ${MINIO_BUCKET:youthpick-files} + connect-timeout: ${MINIO_CONNECT_TIMEOUT:3s} + write-timeout: ${MINIO_WRITE_TIMEOUT:30s} + read-timeout: ${MINIO_READ_TIMEOUT:30s} auth: refresh-cookie: # 운영 안전 기본값: 프론트/백엔드가 서로 다른 도메인(크로스사이트)이라 SameSite=None + Secure가 필수. diff --git a/src/test/java/com/bop/youthpick/file/controller/FileControllerTest.java b/src/test/java/com/bop/youthpick/file/controller/FileControllerTest.java new file mode 100644 index 0000000..7c2820a --- /dev/null +++ b/src/test/java/com/bop/youthpick/file/controller/FileControllerTest.java @@ -0,0 +1,117 @@ +package com.bop.youthpick.file.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.file.dto.FileDownload; +import com.bop.youthpick.file.dto.FileUploadResponse; +import com.bop.youthpick.file.service.FileService; +import com.bop.youthpick.global.error.GlobalExceptionHandler; +import java.io.ByteArrayInputStream; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@ExtendWith(MockitoExtension.class) +class FileControllerTest { + + @Mock private FileService fileService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = + MockMvcBuilders.standaloneSetup(new FileController(fileService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new TestCurrentUserArgumentResolver()) + .build(); + } + + @Test + void 파일을_업로드하면_201과_백엔드_URL을_반환한다() throws Exception { + UUID fileId = UUID.randomUUID(); + MockMultipartFile file = + new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1}); + when(fileService.upload(eq(1L), any())) + .thenReturn( + new FileUploadResponse( + fileId, "photo.png", "image/png", 1, "/api/v1/files/" + fileId)); + + mockMvc.perform(multipart("/api/v1/files").file(file)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.fileId").value(fileId.toString())) + .andExpect(jsonPath("$.data.downloadUrl").value("/api/v1/files/" + fileId)); + } + + @Test + void multipart에_파일이_없으면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(multipart("/api/v1/files")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")) + .andExpect(jsonPath("$.errors[0].field").value("file")); + } + + @Test + void 파일을_inline으로_스트리밍한다() throws Exception { + UUID fileId = UUID.randomUUID(); + byte[] bytes = new byte[] {1, 2, 3}; + when(fileService.download(fileId)) + .thenReturn( + new FileDownload( + "사진.png", + "image/png", + bytes.length, + new ByteArrayInputStream(bytes))); + + mockMvc.perform(get("/api/v1/files/{fileId}", fileId)) + .andExpect(status().isOk()) + .andExpect(content().bytes(bytes)) + .andExpect(content().contentType("image/png")) + .andExpect( + header().string( + HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"=?UTF-8?Q?=EC=82=AC=EC=A7=84.png?=\"; filename*=UTF-8''%EC%82%AC%EC%A7%84.png")) + .andExpect(header().string("X-Content-Type-Options", "nosniff")) + .andExpect( + header().string( + HttpHeaders.CACHE_CONTROL, + "max-age=31536000, public, immutable")); + } + + private static class TestCurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return 1L; + } + } +} diff --git a/src/test/java/com/bop/youthpick/file/service/FileServiceTest.java b/src/test/java/com/bop/youthpick/file/service/FileServiceTest.java new file mode 100644 index 0000000..d0ca135 --- /dev/null +++ b/src/test/java/com/bop/youthpick/file/service/FileServiceTest.java @@ -0,0 +1,152 @@ +package com.bop.youthpick.file.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.file.dto.FileDownload; +import com.bop.youthpick.file.dto.FileUploadResponse; +import com.bop.youthpick.file.exception.FileErrorCode; +import com.bop.youthpick.file.exception.FileException; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.util.unit.DataSize; + +@ExtendWith(MockitoExtension.class) +class FileServiceTest { + + private static final byte[] PNG = + new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}; + + @Mock private ObjectStorage objectStorage; + + private FileService fileService; + + @BeforeEach + void setUp() { + fileService = new FileService(objectStorage, new FileProperties(DataSize.ofMegabytes(10))); + } + + @Test + void PNG_이미지를_업로드하고_백엔드_다운로드_URL을_반환한다() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "프로필.png", "image/png", PNG); + + FileUploadResponse response = fileService.upload(7L, file); + + assertThat(response.originalFilename()).isEqualTo("프로필.png"); + assertThat(response.contentType()).isEqualTo("image/png"); + assertThat(response.downloadUrl()).isEqualTo("/api/v1/files/" + response.fileId()); + verify(objectStorage) + .put(anyString(), any(InputStream.class), anyLong(), anyString(), anyMap()); + } + + @Test + void 경로가_포함된_파일명은_마지막_이름만_저장한다() { + MockMultipartFile file = + new MockMultipartFile("file", "../unsafe/photo.png", "image/png", PNG); + + FileUploadResponse response = fileService.upload(7L, file); + + assertThat(response.originalFilename()).isEqualTo("photo.png"); + } + + @Test + void MIME만_이미지로_위장한_파일은_거부한다() { + MockMultipartFile file = + new MockMultipartFile( + "file", + "fake.png", + "image/png", + "not-an-image".getBytes(StandardCharsets.UTF_8)); + + assertFileError(file, FileErrorCode.UNSUPPORTED_FILE_TYPE); + } + + @Test + void 설정된_최대_크기를_초과하면_거부한다() { + fileService = new FileService(objectStorage, new FileProperties(DataSize.ofBytes(8))); + MockMultipartFile file = new MockMultipartFile("file", "large.png", "image/png", PNG); + + assertFileError(file, FileErrorCode.FILE_SIZE_EXCEEDED); + } + + @Test + void 설정된_최대_크기와_같은_파일은_허용한다() { + fileService = + new FileService(objectStorage, new FileProperties(DataSize.ofBytes(PNG.length))); + MockMultipartFile file = new MockMultipartFile("file", "boundary.png", "image/png", PNG); + + FileUploadResponse response = fileService.upload(7L, file); + + assertThat(response.size()).isEqualTo(PNG.length); + } + + @Test + void 저장소_업로드_장애를_S002로_변환한다() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", PNG); + doThrow(ObjectStorageException.unavailable(new IllegalStateException())) + .when(objectStorage) + .put(anyString(), any(), anyLong(), anyString(), anyMap()); + + assertThatThrownBy(() -> fileService.upload(7L, file)) + .isInstanceOf(FileException.class) + .extracting(exception -> ((FileException) exception).getErrorCode()) + .isEqualTo(FileErrorCode.STORAGE_UNAVAILABLE); + } + + @Test + void 파일을_다운로드하면_원본명과_스트림을_반환한다() throws Exception { + UUID fileId = UUID.randomUUID(); + String encodedFilename = + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("사진.png".getBytes(StandardCharsets.UTF_8)); + when(objectStorage.get(fileId.toString())) + .thenReturn( + new StoredObject( + new ByteArrayInputStream(PNG), + PNG.length, + "image/png", + Map.of("original-filename", encodedFilename))); + + FileDownload response = fileService.download(fileId); + + assertThat(response.originalFilename()).isEqualTo("사진.png"); + assertThat(response.inputStream().readAllBytes()).isEqualTo(PNG); + } + + @Test + void 존재하지_않는_파일은_C003으로_변환한다() throws Exception { + UUID fileId = UUID.randomUUID(); + when(objectStorage.get(fileId.toString())) + .thenThrow(ObjectStorageException.objectNotFound(new IllegalStateException())); + + assertThatThrownBy(() -> fileService.download(fileId)) + .isInstanceOf(FileException.class) + .extracting(exception -> ((FileException) exception).getErrorCode()) + .isEqualTo(FileErrorCode.FILE_NOT_FOUND); + } + + private void assertFileError(MockMultipartFile file, FileErrorCode errorCode) { + assertThatThrownBy(() -> fileService.upload(7L, file)) + .isInstanceOf(FileException.class) + .extracting(exception -> ((FileException) exception).getErrorCode()) + .isEqualTo(errorCode); + } +} diff --git a/src/test/java/com/bop/youthpick/file/service/MinioObjectStorageTest.java b/src/test/java/com/bop/youthpick/file/service/MinioObjectStorageTest.java new file mode 100644 index 0000000..18361af --- /dev/null +++ b/src/test/java/com/bop/youthpick/file/service/MinioObjectStorageTest.java @@ -0,0 +1,58 @@ +package com.bop.youthpick.file.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.minio.GetObjectResponse; +import io.minio.Http; +import io.minio.MinioClient; +import io.minio.StatObjectResponse; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MinioObjectStorageTest { + + @Mock private MinioClient minioClient; + + private MinioObjectStorage objectStorage; + + @BeforeEach + void setUp() { + MinioProperties properties = + new MinioProperties( + "http://localhost:9000", + "test-access-key", + "test-secret-key", + "test-bucket", + Duration.ofSeconds(3), + Duration.ofSeconds(30), + Duration.ofSeconds(30)); + objectStorage = new MinioObjectStorage(minioClient, properties); + } + + @Test + void SDK_9의_사용자_메타데이터에서_원본_파일명을_가져온다() throws Exception { + String encodedFilename = "7Iqk66qo7YGsLmdpZg"; + StatObjectResponse stat = mock(StatObjectResponse.class); + GetObjectResponse response = mock(GetObjectResponse.class); + when(stat.size()).thenReturn(11L); + when(stat.contentType()).thenReturn("image/gif"); + when(stat.userMetadata()) + .thenReturn(new Http.Headers(Map.of("original-filename", encodedFilename))); + when(minioClient.statObject(any())).thenReturn(stat); + when(minioClient.getObject(any())).thenReturn(response); + + StoredObject storedObject = objectStorage.get("file-id"); + + assertThat(storedObject.metadata()).containsEntry("original-filename", encodedFilename); + assertThat(storedObject.inputStream()).isSameAs(response); + } +} diff --git a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java index 0ef19aa..70fad22 100644 --- a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java +++ b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java @@ -116,6 +116,22 @@ class SecurityConfigTest { mockMvc.perform(post("/api/v1/posts")).andExpect(status().isUnauthorized()); } + @Test + void 파일_업로드는_인증없이_접근하면_401() throws Exception { + mockMvc.perform(post("/api/v1/files")).andExpect(status().isUnauthorized()); + } + + @Test + void 파일_조회는_인증없이_접근할_수_있다() throws Exception { + int status = + mockMvc.perform(get("/api/v1/files/00000000-0000-0000-0000-000000000000")) + .andReturn() + .getResponse() + .getStatus(); + + assertThat(status).isNotIn(401, 403); + } + @Test void 회원_탈퇴_경로에_인증없이_접근하면_401() throws Exception { mockMvc.perform(delete("/api/v1/users")).andExpect(status().isUnauthorized()); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 80b5460..968e935 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -20,6 +20,16 @@ spring: one-indexed-parameters: true youthpick: + file: + max-size: 10MB + minio: + endpoint: http://localhost:9000 + access-key: test-access-key + secret-key: test-secret-key + bucket: youthpick-test-files + connect-timeout: 3s + write-timeout: 30s + read-timeout: 30s jwt: secret: test-secret-key-please-be-long-enough-for-hs256 access-token-expiration: 30m From d01ad85556720b6269345372348756875661680b Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Mon, 20 Jul 2026 21:37:53 +0900 Subject: [PATCH 41/93] =?UTF-8?q?feat:=20user=5Fprofiles=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=ED=95=AD=EB=AA=A9=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A7=80=EC=97=AD=20API=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?01)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: user_profiles 온보딩 항목 확장 (#26) 취업상태/학력을 선택값에서 필수값으로 바꾸고, 결혼여부/전공/특화조건/연소득 컬럼을 추가한다. major/income은 policies.major_codes/income_max_amount와 형식·단위를 통일해 추후 맞춤정책 매칭에 바로 쓸 수 있게 한다. * test: 온보딩 프로필 제출 테스트에 신규 항목 반영 (#26) 필수화된 취업상태/학력 누락 케이스와 결혼여부/전공/특화조건/연소득 필드를 테스트에 반영한다. * feat: 지역 목록 공개 조회 API 추가 온보딩 프로필의 거주지역 선택은 실제 region_code가 필요한데, 이를 내려주는 공개 엔드포인트가 없어 관리자 전용 /api/v1/admin/regions만 존재했다. 기존 RegionRepository/RegionResponse를 재사용해 GET /api/v1/regions(비회원 허용)를 추가한다. Co-Authored-By: Claude Sonnet 5 * fix: user_profiles 온보딩 신규 컬럼 Flyway 마이그레이션 누락 추가 #26에서 entity와 docs/schema.sql은 갱신했지만 실제 Flyway 마이그레이션이 빠져있어 employment_status/education_level을 NOT NULL로 바꾸고 merry_status/major/ special_condition/income 컬럼을 추가하는 변경이 DB에 전혀 반영되지 않았다. 로컬 기동 시 Hibernate schema-validation이 missing column [income]으로 즉시 실패해 발견했다. Co-Authored-By: Claude Sonnet 5 * fix: 관리자 유저 프로필 응답에 #26 온보딩 신규 필드 반영 AdminUserProfileResponse가 #26에서 user_profiles에 추가된 merry_status/major/ special_condition/income을 아직 반환하지 않고 있었다. OnboardingProfileResponse와 같은 필드명·타입 규칙(major/specialCondition은 List)으로 추가한다. Closes #102 Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: woojin Co-authored-by: Claude Sonnet 5 --- docs/schema.sql | 8 +++- .../user/dto/AdminUserProfileResponse.java | 8 ++++ .../global/config/SecurityConfig.java | 1 + .../policy/controller/RegionController.java | 25 ++++++++++++ .../user/dto/OnboardingProfileRequest.java | 14 +++++-- .../user/dto/OnboardingProfileResponse.java | 8 ++++ .../youthpick/user/entity/UserProfile.java | 23 ++++++++++- .../user/service/OnboardingService.java | 4 ++ ...1__add_user_profiles_onboarding_fields.sql | 13 +++++++ .../controller/AdminUserControllerTest.java | 8 ++++ .../user/service/AdminUserServiceTest.java | 8 ++++ .../controller/RegionControllerTest.java | 38 +++++++++++++++++++ .../controller/OnboardingControllerTest.java | 35 ++++++++++++++++- .../user/service/OnboardingServiceTest.java | 9 +++++ 14 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/controller/RegionController.java create mode 100644 src/main/resources/db/migration/V11__add_user_profiles_onboarding_fields.sql create mode 100644 src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java diff --git a/docs/schema.sql b/docs/schema.sql index 473a03b..d59fb31 100644 --- a/docs/schema.sql +++ b/docs/schema.sql @@ -61,8 +61,12 @@ CREATE TABLE user_profiles ( user_id BIGINT NOT NULL, birth_year INT NOT NULL COMMENT '출생연도 (REC 나이 20점)', region_code VARCHAR(10) NOT NULL COMMENT '거주 시군구코드 (REC 지역 25점)', - employment_status VARCHAR(16) NULL COMMENT 'jobCd 1개 (REC 취업 15점)', - education_level VARCHAR(16) NULL COMMENT 'schoolCd 1개 (REC 학력 10점)', + employment_status VARCHAR(16) NOT NULL COMMENT 'jobCd 1개 (REC 취업 15점)', + education_level VARCHAR(16) NOT NULL COMMENT 'schoolCd 1개 (REC 학력 10점)', + merry_status VARCHAR(16) NULL COMMENT '결혼여부 코드 (선택)', + major VARCHAR(255) NULL COMMENT '전공 콤마목록, plcyMajorCd와 형식 통일 (선택)', + special_condition VARCHAR(500) NULL COMMENT '특화조건 콤마목록 (선택)', + income INT NULL COMMENT '연소득(만원), policies.income_max_amount와 단위 통일 (선택)', categories VARCHAR(500) NULL COMMENT '관심분야 콤마목록 (REC 분야 20점)', keywords VARCHAR(700) NULL COMMENT '관심키워드 콤마목록 (REC 키워드 10점)', status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', diff --git a/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java index 95adea5..8c6621e 100644 --- a/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java @@ -9,6 +9,10 @@ public record AdminUserProfileResponse( Integer birthYear, String employmentStatus, String educationLevel, + String merryStatus, + List major, + List specialCondition, + Integer income, List categories, List keywords, String status, @@ -19,6 +23,10 @@ public static AdminUserProfileResponse from(UserProfile profile) { profile.getBirthYear(), profile.getEmploymentStatus(), profile.getEducationLevel(), + profile.getMerryStatus(), + splitToList(profile.getMajor()), + splitToList(profile.getSpecialCondition()), + profile.getIncome(), splitToList(profile.getCategories()), splitToList(profile.getKeywords()), profile.getStatus(), diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index c011cfb..c1c2feb 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -75,6 +75,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/v1/auth/token/refresh", "/api/v1/policy-comparisons/**", "/api/v1/meta/profile-options", + "/api/v1/regions", "/api/v1/policies/**", "/api/v1/health") .permitAll() diff --git a/src/main/java/com/bop/youthpick/policy/controller/RegionController.java b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java new file mode 100644 index 0000000..0226ebc --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 지역 마스터 전체 목록 조회 (비회원 허용). 온보딩 프로필의 거주지역 선택지로 쓰인다. */ +@RestController +@RequestMapping("/api/v1/regions") +@RequiredArgsConstructor +public class RegionController { + + private final RegionRepository regionRepository; + + @GetMapping + public ApiResponse> list() { + return ApiResponse.ok( + regionRepository.findAll().stream().map(RegionResponse::from).toList()); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java index b37c147..6cd1c7b 100644 --- a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java +++ b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java @@ -8,8 +8,8 @@ import java.util.List; /** - * categories/keywords는 온보딩 화면에서 체크박스로 다중 선택되는 값이라 콤마 문자열이 아니라 배열로 받는다. 콤마 결합(저장 포맷)은 서비스 계층 책임이다. - * categories는 대분류 5개 중 최대 3개, keywords는 전체 19개 중 선택. + * categories/keywords/major/specialCondition은 온보딩 화면에서 체크박스로 다중 선택되는 값이라 콤마 문자열이 아니라 배열로 받는다. 콤마 + * 결합(저장 포맷)은 서비스 계층 책임이다. categories는 대분류 5개 중 최대 3개, keywords는 전체 19개 중 선택. */ public record OnboardingProfileRequest( @NotNull(message = "출생연도는 필수입니다.") @@ -18,7 +18,13 @@ public record OnboardingProfileRequest( Integer birthYear, @NotBlank(message = "거주 지역은 필수입니다.") @Size(max = 10, message = "지역 코드 형식이 올바르지 않습니다.") String regionCode, - @Size(max = 16, message = "취업상태 코드 형식이 올바르지 않습니다.") String employmentStatus, - @Size(max = 16, message = "학력 코드 형식이 올바르지 않습니다.") String educationLevel, + @NotBlank(message = "취업상태는 필수입니다.") @Size(max = 16, message = "취업상태 코드 형식이 올바르지 않습니다.") + String employmentStatus, + @NotBlank(message = "학력은 필수입니다.") @Size(max = 16, message = "학력 코드 형식이 올바르지 않습니다.") + String educationLevel, + @Size(max = 16, message = "결혼여부 코드 형식이 올바르지 않습니다.") String merryStatus, + List<@NotBlank String> major, + List<@NotBlank String> specialCondition, + @Min(value = 0, message = "연소득이 올바르지 않습니다.") Integer income, @Size(max = 3, message = "관심분야는 최대 3개까지 선택할 수 있습니다.") List<@NotBlank String> categories, @Size(max = 19, message = "관심키워드는 최대 19개까지 선택할 수 있습니다.") List<@NotBlank String> keywords) {} diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java index 93c8f42..b80cdeb 100644 --- a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java +++ b/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java @@ -11,6 +11,10 @@ public record OnboardingProfileResponse( String regionCode, String employmentStatus, String educationLevel, + String merryStatus, + List major, + List specialCondition, + Integer income, List categories, List keywords, String status) { @@ -22,6 +26,10 @@ public static OnboardingProfileResponse from(UserProfile profile) { profile.getRegion().getCode(), profile.getEmploymentStatus(), profile.getEducationLevel(), + profile.getMerryStatus(), + splitToList(profile.getMajor()), + splitToList(profile.getSpecialCondition()), + profile.getIncome(), splitToList(profile.getCategories()), splitToList(profile.getKeywords()), profile.getStatus()); diff --git a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java index d90b111..2728a96 100644 --- a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -43,12 +43,23 @@ public class UserProfile extends BaseEntity { @JoinColumn(name = "region_code", nullable = false) private Region region; - @Column(name = "employment_status", length = 16) + @Column(name = "employment_status", length = 16, nullable = false) private String employmentStatus; - @Column(name = "education_level", length = 16) + @Column(name = "education_level", length = 16, nullable = false) private String educationLevel; + @Column(name = "merry_status", length = 16) + private String merryStatus; + + @Column(length = 255) + private String major; + + @Column(name = "special_condition", length = 500) + private String specialCondition; + + @Column private Integer income; + @Column(length = 500) private String categories; @@ -67,6 +78,10 @@ public static UserProfile create( Integer birthYear, String employmentStatus, String educationLevel, + String merryStatus, + String major, + String specialCondition, + Integer income, String categories, String keywords) { UserProfile profile = new UserProfile(); @@ -75,6 +90,10 @@ public static UserProfile create( profile.birthYear = birthYear; profile.employmentStatus = employmentStatus; profile.educationLevel = educationLevel; + profile.merryStatus = merryStatus; + profile.major = major; + profile.specialCondition = specialCondition; + profile.income = income; profile.categories = categories; profile.keywords = keywords; return profile; diff --git a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java index 6603182..6d9ab3d 100644 --- a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java +++ b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java @@ -48,6 +48,10 @@ public UserProfile submit(Long userId, OnboardingProfileRequest request) { request.birthYear(), request.employmentStatus(), request.educationLevel(), + request.merryStatus(), + joinToCommaString(request.major()), + joinToCommaString(request.specialCondition()), + request.income(), joinToCommaString(request.categories()), joinToCommaString(request.keywords())); diff --git a/src/main/resources/db/migration/V11__add_user_profiles_onboarding_fields.sql b/src/main/resources/db/migration/V11__add_user_profiles_onboarding_fields.sql new file mode 100644 index 0000000..243e2f7 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_user_profiles_onboarding_fields.sql @@ -0,0 +1,13 @@ +-- ============================================================ +-- V11 — user_profiles 온보딩 항목 확장 (#26) +-- 취업상태/학력을 선택값에서 필수값으로 바꾸고, 결혼여부/전공/특화조건/연소득 컬럼을 추가한다. +-- major/income은 policies.major_codes/income_max_amount와 형식·단위를 통일해 맞춤정책 매칭에 쓴다. +-- ============================================================ + +ALTER TABLE user_profiles + MODIFY COLUMN employment_status VARCHAR(16) NOT NULL COMMENT 'jobCd 1개 (REC 취업 15점)', + MODIFY COLUMN education_level VARCHAR(16) NOT NULL COMMENT 'schoolCd 1개 (REC 학력 10점)', + ADD COLUMN merry_status VARCHAR(16) NULL COMMENT '결혼여부 코드 (선택)' AFTER education_level, + ADD COLUMN major VARCHAR(255) NULL COMMENT '전공 콤마목록, plcyMajorCd와 형식 통일 (선택)' AFTER merry_status, + ADD COLUMN special_condition VARCHAR(500) NULL COMMENT '특화조건 콤마목록 (선택)' AFTER major, + ADD COLUMN income INT NULL COMMENT '연소득(만원), policies.income_max_amount와 단위 통일 (선택)' AFTER special_condition; diff --git a/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java index 8d328fb..f22733d 100644 --- a/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java @@ -69,6 +69,10 @@ class AdminUserControllerTest { 2000, "EMPLOYED", "UNIVERSITY", + "SINGLE", + List.of("COMPUTER_SCIENCE"), + List.of("LOW_INCOME"), + 3000, List.of("취업"), List.of("청년"), "COMPLETED", @@ -78,6 +82,10 @@ class AdminUserControllerTest { mockMvc.perform(get("/api/v1/admin/users/{userId}/profile", 1L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.userId").value(1)) + .andExpect(jsonPath("$.data.merryStatus").value("SINGLE")) + .andExpect(jsonPath("$.data.major[0]").value("COMPUTER_SCIENCE")) + .andExpect(jsonPath("$.data.specialCondition[0]").value("LOW_INCOME")) + .andExpect(jsonPath("$.data.income").value(3000)) .andExpect(jsonPath("$.data.regionLabel").value("서울특별시 강남구")); } diff --git a/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java index d08388d..35c555f 100644 --- a/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java @@ -71,11 +71,19 @@ void setUp() { when(profile.getRegion()).thenReturn(region); when(region.getSidoName()).thenReturn("서울특별시"); when(region.getName()).thenReturn("강남구"); + when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); + when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); + when(profile.getIncome()).thenReturn(3000); var result = adminUserService.getProfile(USER_ID); assertThat(result.userId()).isEqualTo(USER_ID); assertThat(result.regionLabel()).isEqualTo("서울특별시 강남구"); + assertThat(result.merryStatus()).isEqualTo("SINGLE"); + assertThat(result.major()).containsExactly("COMPUTER_SCIENCE"); + assertThat(result.specialCondition()).containsExactly("LOW_INCOME"); + assertThat(result.income()).isEqualTo(3000); } @Test diff --git a/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java new file mode 100644 index 0000000..e7882c3 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java @@ -0,0 +1,38 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.util.List; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = RegionController.class) +@AutoConfigureMockMvc(addFilters = false) +class RegionControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private RegionRepository regionRepository; + + @Test + void 지역_목록은_200과_전체_목록을_반환한다() throws Exception { + Region region = Region.create("11110", "서울특별시", "종로구"); + when(regionRepository.findAll()).thenReturn(List.of(region)); + + mockMvc.perform(get("/api/v1/regions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].regionCode").value("11110")) + .andExpect(jsonPath("$.data[0].provinceName").value("서울특별시")) + .andExpect(jsonPath("$.data[0].districtName").value("종로구")) + .andExpect(jsonPath("$.meta").doesNotExist()); + } +} diff --git a/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java index 48dc15c..a3bde43 100644 --- a/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java @@ -35,6 +35,10 @@ class OnboardingControllerTest { "regionCode": "11110", "employmentStatus": "EMPLOYED", "educationLevel": "UNIVERSITY", + "merryStatus": "SINGLE", + "major": ["COMPUTER_SCIENCE"], + "specialCondition": ["LOW_INCOME"], + "income": 3000, "categories": ["취업", "주거"], "keywords": ["청년", "공모전"] } @@ -53,6 +57,10 @@ class OnboardingControllerTest { when(profile.getBirthYear()).thenReturn(2000); when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); + when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); + when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); + when(profile.getIncome()).thenReturn(3000); when(profile.getCategories()).thenReturn("취업,주거"); when(profile.getKeywords()).thenReturn("청년,공모전"); when(profile.getStatus()).thenReturn("COMPLETED"); @@ -66,6 +74,10 @@ class OnboardingControllerTest { .andExpect(jsonPath("$.data.id").value(10)) .andExpect(jsonPath("$.data.userId").value(1)) .andExpect(jsonPath("$.data.regionCode").value("11110")) + .andExpect(jsonPath("$.data.merryStatus").value("SINGLE")) + .andExpect(jsonPath("$.data.major[0]").value("COMPUTER_SCIENCE")) + .andExpect(jsonPath("$.data.specialCondition[0]").value("LOW_INCOME")) + .andExpect(jsonPath("$.data.income").value(3000)) .andExpect(jsonPath("$.data.categories[0]").value("취업")) .andExpect(jsonPath("$.data.categories[1]").value("주거")); } @@ -75,7 +87,28 @@ class OnboardingControllerTest { String invalidBody = """ { - "regionCode": "11110" + "regionCode": "11110", + "employmentStatus": "EMPLOYED", + "educationLevel": "UNIVERSITY" + } + """; + + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidBody)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 취업상태가_없으면_400과_C001을_반환한다() throws Exception { + String invalidBody = + """ + { + "birthYear": 2000, + "regionCode": "11110", + "educationLevel": "UNIVERSITY" } """; diff --git a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java index f60e415..c619200 100644 --- a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java +++ b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java @@ -44,6 +44,10 @@ class OnboardingServiceTest { "11110", "EMPLOYED", "UNIVERSITY", + "SINGLE", + List.of("COMPUTER_SCIENCE"), + List.of("LOW_INCOME"), + 3000, List.of("취업", "주거"), List.of("청년", "공모전")); @@ -66,6 +70,11 @@ void setUp() { assertThat(result.getBirthYear()).isEqualTo(REQUEST.birthYear()); assertThat(result.getEmploymentStatus()).isEqualTo(REQUEST.employmentStatus()); + assertThat(result.getEducationLevel()).isEqualTo(REQUEST.educationLevel()); + assertThat(result.getMerryStatus()).isEqualTo(REQUEST.merryStatus()); + assertThat(result.getMajor()).isEqualTo("COMPUTER_SCIENCE"); + assertThat(result.getSpecialCondition()).isEqualTo("LOW_INCOME"); + assertThat(result.getIncome()).isEqualTo(REQUEST.income()); assertThat(result.getCategories()).isEqualTo("취업,주거"); assertThat(result.getKeywords()).isEqualTo("청년,공모전"); } From f14e3c1e077e0ed3bb29fc51621581d0a0c95c16 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Mon, 20 Jul 2026 21:38:17 +0900 Subject: [PATCH 42/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A7=80=EC=97=AD=20=EB=9D=BC=EB=B2=A8=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=EC=9D=84=20=EB=B0=B1=EC=97=94=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EC=A7=80=EC=97=AD=20=EB=AA=A9=EB=A1=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=91=EB=8B=B5=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolicyCardResponse.regionLabel이 "전국"/"OO 외 N" 같은 한글 표시 문구를 백엔드가 직접 조립해 내려보내고 있었는데, 정책 상세는 이미 원본 지역 목록을 내려주고 프론트(policyMapper.regionsToLabel)가 같은 포맷팅을 자체 구현해 쓰고 있어 로직이 중복돼 있었다. 게다가 "전국" 판정 기준이 서로 달라(카드: 전 시도 커버 여부 카운트 비교, 상세: 지역 목록이 비어있는지) 시도를 명시적으로 전부 커버한 정책이 카드와 상세에서 다르게 표시될 수 있었다. PolicyCardResponse.provinces: List으로 바꿔 원본 시도명 목록(중복 제거·정렬)만 내려주고, 표시 문구 조립은 프론트로 완전히 옮긴다. region=전국 필터 자체의 동작(#93)은 건드리지 않는다. Closes #98 Co-authored-by: Claude Sonnet 5 --- .../policy/dto/PolicyCardResponse.java | 14 +++++++---- .../policy/service/PolicyService.java | 23 ++++--------------- .../policy/service/PolicyServiceTest.java | 22 ++++++++++-------- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java index 4ebfde2..ff733a2 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java @@ -2,8 +2,14 @@ import com.bop.youthpick.policy.entity.Policy; import java.time.LocalDate; +import java.util.List; -/** 정책 목록(카드) 응답. 상세 필드는 {@link PolicyDetailResponse}로 분리 — 프론트 카드가 쓰는 필드만 내린다. */ +/** + * 정책 목록(카드) 응답. 상세 필드는 {@link PolicyDetailResponse}로 분리 — 프론트 카드가 쓰는 필드만 내린다. + * + *

{@code provinces}는 정책이 적용되는 시도명 목록(중복 제거·정렬)이다. 지역 매핑이 없으면 빈 리스트 — "전국/OO 외 N" 같은 표시 문구 조립은 + * 프론트 책임이며, 상세({@link PolicyDetailResponse#regions()})와 같은 규칙을 쓴다. + */ public record PolicyCardResponse( Long id, String title, @@ -12,9 +18,9 @@ public record PolicyCardResponse( Integer minAge, Integer maxAge, LocalDate applicationEndDate, - String regionLabel) { + List provinces) { - public static PolicyCardResponse from(Policy policy, String regionLabel) { + public static PolicyCardResponse from(Policy policy, List provinces) { return new PolicyCardResponse( policy.getId(), policy.getTitle(), @@ -23,6 +29,6 @@ public static PolicyCardResponse from(Policy policy, String regionLabel) { policy.getMinAge(), policy.getMaxAge(), policy.getApplicationEndDate(), - regionLabel); + provinces); } } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index cb2c63f..0304be9 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -93,9 +93,10 @@ public Page getCards( policy -> PolicyCardResponse.from( policy, - toRegionLabel( - sidoNamesByPolicyId.getOrDefault(policy.getId(), List.of()), - totalSidoCount))); + sidoNamesByPolicyId.getOrDefault(policy.getId(), List.of()).stream() + .distinct() + .sorted() + .toList())); } @Nullable @@ -118,22 +119,6 @@ private static String toLikePattern(@Nullable String keyword) { return "%" + escaped + "%"; } - /** 지역 없음 → null, 전 시도 커버 → '전국', 시도 1개 → 시도명, 여러 시도 → '가나다 첫 시도 외 N'. */ - @Nullable - private static String toRegionLabel(List sidoNames, long totalSidoCount) { - List distinct = sidoNames.stream().distinct().sorted().toList(); - if (distinct.isEmpty()) { - return null; - } - if (totalSidoCount > 0 && distinct.size() >= totalSidoCount) { - return "전국"; - } - if (distinct.size() == 1) { - return distinct.get(0); - } - return distinct.get(0) + " 외 " + (distinct.size() - 1); - } - /** * 정책 상세 조회. 삭제(soft delete)·숨김 정책은 존재하지 않는 것으로 취급한다. 로그인 사용자({@code userId != null})의 조회는 최근 본 * 정책으로 기록하되, 기록은 부가 동작이라 실패해도 조회는 정상 응답한다. diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index 38788ca..0f58ecf 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -129,12 +129,12 @@ void setUp() { } @Test - void 목록은_VISIBLE_미삭제만_id_내림차순으로_조회하고_카드와_지역라벨을_조립한다() { + void 목록은_VISIBLE_미삭제만_id_내림차순으로_조회하고_카드에_지역_시도명_목록을_담는다() { Policy single = newPolicy(1L, "한 시도 정책"); ReflectionTestUtils.setField(single, "category", "주거"); Policy noRegion = newPolicy(2L, "지역 없음 정책"); Policy multi = newPolicy(3L, "두 시도 정책"); - Policy nationwide = newPolicy(4L, "전국 정책"); + Policy allSido = newPolicy(4L, "전 시도 정책"); ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); when(policyRepository.findCards( eq(PolicyVisibility.VISIBLE), @@ -148,19 +148,19 @@ void setUp() { pageableCaptor.capture())) .thenReturn( new PageImpl<>( - List.of(single, noRegion, multi, nationwide), + List.of(single, noRegion, multi, allSido), PageRequest.of(0, 20), 4)); - // 시도 전체 3개 기준 — single 1개, multi 2개(가나다 첫 시도 '부산광역시'), nationwide 3개(전국) + // 전국/외 N 같은 표시 문구는 더 이상 조립하지 않는다 — 시도명 목록(중복 제거·정렬)만 그대로 내려준다. when(policyRegionRepository.findWithRegionByPolicyIdIn(List.of(1L, 2L, 3L, 4L))) .thenReturn( List.of( newPolicyRegion(single, "11680", "서울특별시", "강남구"), newPolicyRegion(multi, "11680", "서울특별시", "강남구"), newPolicyRegion(multi, "26110", "부산광역시", "중구"), - newPolicyRegion(nationwide, "11680", "서울특별시", "강남구"), - newPolicyRegion(nationwide, "26110", "부산광역시", "중구"), - newPolicyRegion(nationwide, "27110", "대구광역시", "중구"))); + newPolicyRegion(allSido, "11680", "서울특별시", "강남구"), + newPolicyRegion(allSido, "26110", "부산광역시", "중구"), + newPolicyRegion(allSido, "27110", "대구광역시", "중구"))); when(regionRepository.countDistinctSidoNames()).thenReturn(3L); Page page = @@ -173,8 +173,12 @@ void setUp() { assertThat(card.title()).isEqualTo("한 시도 정책"); assertThat(card.category()).isEqualTo("주거"); assertThat(page.getContent()) - .extracting(PolicyCardResponse::regionLabel) - .containsExactly("서울특별시", null, "부산광역시 외 1", "전국"); + .extracting(PolicyCardResponse::provinces) + .containsExactly( + List.of("서울특별시"), + List.of(), + List.of("부산광역시", "서울특별시"), + List.of("대구광역시", "부산광역시", "서울특별시")); } @Test From 16985966c1ac08225498080691b2fff0dcd23a28 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Mon, 20 Jul 2026 22:15:01 +0900 Subject: [PATCH 43/93] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20GET=20API=20=EC=B6=94=EA=B0=80=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 온보딩 제출(POST)만 있고 조회 API가 없어 프론트가 서버에서 프로필을 하이드레이션하지 못했다. GET /api/v1/me/profile을 추가해 인증 사용자의 저장된 프로필을 조회할 수 있게 한다. Closes #103 --- .../user/controller/MyProfileController.java | 25 +++++ .../user/service/OnboardingService.java | 5 + .../controller/MyProfileControllerTest.java | 96 +++++++++++++++++++ .../user/service/OnboardingServiceTest.java | 19 ++++ 4 files changed, 145 insertions(+) create mode 100644 src/main/java/com/bop/youthpick/user/controller/MyProfileController.java create mode 100644 src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java diff --git a/src/main/java/com/bop/youthpick/user/controller/MyProfileController.java b/src/main/java/com/bop/youthpick/user/controller/MyProfileController.java new file mode 100644 index 0000000..c4f6314 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/controller/MyProfileController.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.user.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.user.dto.OnboardingProfileResponse; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.service.OnboardingService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/me") +@RequiredArgsConstructor +public class MyProfileController { + + private final OnboardingService onboardingService; + + @GetMapping("/profile") + public ApiResponse getProfile(@CurrentUser Long userId) { + UserProfile profile = onboardingService.getMyProfile(userId); + return ApiResponse.ok(profile == null ? null : OnboardingProfileResponse.from(profile)); + } +} diff --git a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java index 6d9ab3d..17cc898 100644 --- a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java +++ b/src/main/java/com/bop/youthpick/user/service/OnboardingService.java @@ -25,6 +25,11 @@ public class OnboardingService { private final UserProfileRepository userProfileRepository; private final RegionRepository regionRepository; + @Transactional(readOnly = true) + public UserProfile getMyProfile(Long userId) { + return userProfileRepository.findByUserId(userId).orElse(null); + } + @Transactional public UserProfile submit(Long userId, OnboardingProfileRequest request) { User user = diff --git a/src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java new file mode 100644 index 0000000..b8a2a96 --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java @@ -0,0 +1,96 @@ +package com.bop.youthpick.user.controller; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.dto.AuthPrincipal; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.service.OnboardingService; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = MyProfileController.class) +@AutoConfigureMockMvc(addFilters = false) +class MyProfileControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private OnboardingService onboardingService; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void 온보딩된_사용자면_200과_프로필을_반환한다() throws Exception { + UserProfile profile = mock(UserProfile.class); + User user = mock(User.class); + Region region = mock(Region.class); + when(user.getId()).thenReturn(1L); + when(region.getCode()).thenReturn("11110"); + when(profile.getId()).thenReturn(10L); + when(profile.getUser()).thenReturn(user); + when(profile.getRegion()).thenReturn(region); + when(profile.getBirthYear()).thenReturn(2000); + when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); + when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); + when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); + when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); + when(profile.getIncome()).thenReturn(3000); + when(profile.getCategories()).thenReturn("취업,주거"); + when(profile.getKeywords()).thenReturn("청년,공모전"); + when(profile.getStatus()).thenReturn("COMPLETED"); + when(onboardingService.getMyProfile(1L)).thenReturn(profile); + authenticateAs(1L); + + mockMvc.perform(get("/api/v1/me/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(10)) + .andExpect(jsonPath("$.data.regionCode").value("11110")) + .andExpect(jsonPath("$.data.income").value(3000)); + } + + @Test + void 온보딩하지_않은_사용자면_200과_null_데이터를_반환한다() throws Exception { + when(onboardingService.getMyProfile(1L)).thenReturn(null); + authenticateAs(1L); + + mockMvc.perform(get("/api/v1/me/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void 미인증_요청이면_401과_A001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/me/profile")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + + /** addFilters=false로 시큐리티 필터를 건너뛰므로 SecurityContextHolder를 직접 채운다. */ + private void authenticateAs(Long userId) { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(userId, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java index c619200..dbaef01 100644 --- a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java +++ b/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java @@ -115,6 +115,25 @@ void setUp() { .isEqualTo(UserError.PROFILE_ALREADY_EXISTS); } + @Test + void 프로필이_있으면_조회한다() { + UserProfile profile = mock(UserProfile.class); + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.of(profile)); + + UserProfile result = onboardingService.getMyProfile(USER_ID); + + assertThat(result).isEqualTo(profile); + } + + @Test + void 프로필이_없으면_null을_반환한다() { + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + UserProfile result = onboardingService.getMyProfile(USER_ID); + + assertThat(result).isNull(); + } + @Test void 존재하지_않는_지역코드면_REGION_NOT_FOUND_예외를_던진다() { when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); From acf49a09f90d1e038e1989b916a80c11c6bb9dd3 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Tue, 21 Jul 2026 00:22:22 +0900 Subject: [PATCH 44/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=9E=AC=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20soft=20de?= =?UTF-8?q?lete=20=ED=96=89=20=EC=9E=AC=ED=99=9C=EC=84=B1=ED=99=94=20(#107?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PolicyApplicationRepository.java | 13 ++- .../service/PolicyApplicationService.java | 4 +- .../PolicyApplicationRepositoryTest.java | 80 +++++++++++++++++++ .../service/PolicyApplicationServiceTest.java | 39 ++++++--- 4 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java index e9390e8..a9b668b 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java @@ -7,6 +7,8 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PolicyApplicationRepository extends JpaRepository, @@ -22,7 +24,12 @@ public interface PolicyApplicationRepository @EntityGraph(attributePaths = "policy") Page findByUser_IdAndDeletedAtIsNull(Long userId, Pageable pageable); - // PolicyApplicationService.register()의 첫 줄에서 사용 — soft-delete 여부와 무관하게 (user, policy) 조합의 - // 기존 행이 있는지부터 확인한다(테이블의 UNIQUE(user_id, policy_id) 제약과 짝을 이루는 조회). - Optional findByUser_IdAndPolicy_Id(Long userId, Long policyId); + // @SQLRestriction("deleted_at IS NULL")은 모든 엔티티 조회에 붙을 수 있으므로, 재등록 판단은 native query로 + // 명시적으로 우회한다. soft-delete된 기존 행까지 포함해야 UNIQUE(user_id, policy_id) 충돌 없이 reactivate할 수 있다. + @Query( + value = + "SELECT * FROM policy_applications WHERE user_id = :userId AND policy_id = :policyId LIMIT 1", + nativeQuery = true) + Optional findIncludingDeletedByUserIdAndPolicyId( + @Param("userId") Long userId, @Param("policyId") Long policyId); } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java index d341b89..2d0f858 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java @@ -46,7 +46,7 @@ public PolicyApplication register( LocalDateTime endAt) { PolicyApplication existing = policyApplicationRepository - .findByUser_IdAndPolicy_Id(userId, policyId) + .findIncludingDeletedByUserIdAndPolicyId(userId, policyId) .orElse(null); String normalizedMemo = blankToNull(memo); @@ -92,7 +92,7 @@ public PolicyApplication register( // 동시에 같은 정책 신청 요청이 들어와 유니크 제약조건 위반이 발생한 경우 // 중복 신청 예외로 변환한다. } catch (DataIntegrityViolationException e) { - // findByUser_IdAndPolicy_Id 확인 이후 동시 요청이 먼저 저장한 경우 + // including-deleted 조회 확인 이후 동시 요청이 먼저 저장한 경우 // (uk_policy_applications_user_policy UNIQUE 위반). 같은 도메인 에러로 통일한다. throw new CustomException(PolicyErrorCode.POLICY_ALREADY_EXISTS); } diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java new file mode 100644 index 0000000..792bf53 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java @@ -0,0 +1,80 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.service.PolicyApplicationService; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import({JpaAuditingConfig.class, PolicyApplicationService.class}) +class PolicyApplicationRepositoryTest { + + @Autowired private PolicyApplicationRepository policyApplicationRepository; + @Autowired private PolicyApplicationService policyApplicationService; + @Autowired private UserRepository userRepository; + @Autowired private TestEntityManager entityManager; + + @Test + void soft_delete된_신청을_SQLRestriction과_무관하게_재등록용으로_조회한다() { + User user = userRepository.save(User.createSocialUser("kakao", "kakao-1", null, "닉네임")); + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", "P001"); + ReflectionTestUtils.setField(policy, "title", "청년 일자리 지원"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + entityManager.persistAndFlush(policy); + + PolicyApplication application = + PolicyApplication.register(user, policy, ApplicationStatus.INTERESTED, null, null); + entityManager.persistAndFlush(application); + application.delete(); + entityManager.flush(); + entityManager.clear(); + + Optional found = + policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + user.getId(), policy.getId()); + + assertThat(found).isPresent(); + assertThat(found.orElseThrow().isDeleted()).isTrue(); + } + + @Test + void 관심_해제후_같은_정책을_재등록하면_기존_행을_재활성화한다() { + User user = userRepository.save(User.createSocialUser("kakao", "kakao-2", null, "닉네임")); + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", "P002"); + ReflectionTestUtils.setField(policy, "title", "청년 주거 지원"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + entityManager.persistAndFlush(policy); + + PolicyApplication registered = + policyApplicationService.register( + user.getId(), policy.getId(), ApplicationStatus.INTERESTED, null, null); + Long applicationId = registered.getId(); + policyApplicationService.delete(applicationId, user.getId()); + entityManager.flush(); + entityManager.clear(); + + PolicyApplication reactivated = + policyApplicationService.register( + user.getId(), policy.getId(), ApplicationStatus.INTERESTED, null, null); + + assertThat(reactivated.getId()).isEqualTo(applicationId); + assertThat(reactivated.isDeleted()).isFalse(); + assertThat(reactivated.getStatus()).isEqualTo(ApplicationStatus.INTERESTED); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java index b2dcbb9..b40aee9 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java @@ -62,7 +62,8 @@ void setUp() { @Test void 신규_신청관리를_정상적으로_등록한다() { - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); @@ -81,7 +82,8 @@ void setUp() { void 마감일을_지정하지_않으면_정책의_신청_마감일을_기본값으로_사용한다() { Policy policy = mock(Policy.class); when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); @@ -98,7 +100,8 @@ void setUp() { @Test void 마감일을_직접_지정하면_정책_마감일_대신_그_값을_사용한다() { Policy policy = mock(Policy.class); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); @@ -117,7 +120,8 @@ void setUp() { void 마감일이_정책_마감일과_같으면_등록을_허용한다() { Policy policy = mock(Policy.class); when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); @@ -136,7 +140,8 @@ void setUp() { void 마감일이_정책_마감일을_넘으면_END_AT_AFTER_POLICY_DEADLINE_예외를_던진다() { Policy policy = mock(Policy.class); when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); @@ -157,7 +162,8 @@ void setUp() { @Test void 메모가_빈_문자열이면_null로_저장한다() { - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); @@ -180,7 +186,8 @@ void setUp() { ApplicationStatus.INTERESTED, null, null); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.of(existing)); assertThatThrownBy( @@ -202,7 +209,8 @@ void setUp() { null, null); existing.delete(); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.of(existing)); PolicyApplication result = @@ -223,7 +231,8 @@ void setUp() { PolicyApplication.register( mock(User.class), policy, ApplicationStatus.INTERESTED, null, null); existing.delete(); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.of(existing)); PolicyApplication result = @@ -243,7 +252,8 @@ void setUp() { null, null); existing.delete(); - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.of(existing)); policyApplicationService.register( @@ -254,7 +264,8 @@ void setUp() { @Test void 저장_시점에_동시요청으로_유니크_제약이_위반되면_POLICY_ALREADY_EXISTS_예외를_던진다() { - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); @@ -277,7 +288,8 @@ void setUp() { @Test void 존재하지_않는_사용자면_USER_NOT_FOUND_예외를_던진다() { - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); @@ -296,7 +308,8 @@ void setUp() { @Test void 존재하지_않는_정책이면_POLICY_NOT_FOUND_예외를_던진다() { - when(policyApplicationRepository.findByUser_IdAndPolicy_Id(USER_ID, POLICY_ID)) + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.empty()); From 45844155df48292e6cd735d10a2a4f73ea672e36 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Tue, 21 Jul 2026 01:46:52 +0900 Subject: [PATCH 45/93] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B2=A8=EB=B6=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 게시글 이미지 첨부 저장 * fix: 게시글 수정 시 첨부 이미지 교체 * fix: 게시글 첨부 검증 및 스키마 정합성 보완 --- .../repository/AttachmentRepository.java | 2 + .../youthpick/post/dto/PostCreateRequest.java | 11 +++- .../youthpick/post/dto/PostUpdateRequest.java | 11 +++- .../bop/youthpick/post/entity/Attachment.java | 9 ++- .../youthpick/post/service/PostService.java | 26 ++++++-- .../V12__drop_attachments_deleted_at.sql | 4 ++ .../post/controller/PostControllerTest.java | 18 ++++++ .../post/service/PostServiceTest.java | 59 +++++++++++++++++-- 8 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/migration/V12__drop_attachments_deleted_at.sql diff --git a/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java b/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java index b80da9c..b697a6d 100644 --- a/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java +++ b/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java @@ -7,4 +7,6 @@ public interface AttachmentRepository extends JpaRepository { List findByPostId(Long postId); + + void deleteByPostId(Long postId); } diff --git a/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java b/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java index 57bb008..52c6a24 100644 --- a/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java +++ b/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import java.util.List; public record PostCreateRequest( @NotBlank(message = "카테고리는 필수입니다.") @@ -14,4 +15,12 @@ public record PostCreateRequest( @NotBlank(message = "제목은 필수입니다.") @Size(max = 100, message = "제목은 100자 이하여야 합니다.") String title, @NotBlank(message = "내용은 필수입니다.") String content, - @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId) {} // 필수값이 아님. 잡담은 유무 다 가능 + @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId, + @Size(max = 10, message = "이미지는 최대 10개까지 첨부할 수 있습니다.") + List< + @Pattern( + regexp = + "/api/v1/files/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + message = "업로드된 이미지 URL 형식이 아닙니다.") + String> + attachmentUrls) {} // 필수값이 아님. 잡담은 유무 다 가능 diff --git a/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java b/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java index 5ae5956..cd54cdb 100644 --- a/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java +++ b/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import java.util.List; public record PostUpdateRequest( @NotBlank(message = "카테고리는 필수입니다.") @@ -14,4 +15,12 @@ public record PostUpdateRequest( @NotBlank(message = "제목은 필수입니다.") @Size(max = 100, message = "제목은 100자 이하여야 합니다.") String title, @NotBlank(message = "내용은 필수입니다.") String content, - @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId) {} + @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId, + @Size(max = 10, message = "이미지는 최대 10개까지 첨부할 수 있습니다.") + List< + @Pattern( + regexp = + "/api/v1/files/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + message = "업로드된 이미지 URL 형식이 아닙니다.") + String> + attachmentUrls) {} diff --git a/src/main/java/com/bop/youthpick/post/entity/Attachment.java b/src/main/java/com/bop/youthpick/post/entity/Attachment.java index 8d5e380..cfc6989 100644 --- a/src/main/java/com/bop/youthpick/post/entity/Attachment.java +++ b/src/main/java/com/bop/youthpick/post/entity/Attachment.java @@ -2,7 +2,6 @@ import com.bop.youthpick.global.entity.BaseEntity; import jakarta.persistence.*; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -31,6 +30,10 @@ public class Attachment extends BaseEntity { @Column(name = "file_size") private Long fileSize; - @Column(name = "deleted_at") - private LocalDateTime deletedAt; + public static Attachment create(Post post, String fileUrl) { + Attachment attachment = new Attachment(); + attachment.post = post; + attachment.fileUrl = fileUrl; + return attachment; + } } diff --git a/src/main/java/com/bop/youthpick/post/service/PostService.java b/src/main/java/com/bop/youthpick/post/service/PostService.java index 9e74615..bc5eb63 100644 --- a/src/main/java/com/bop/youthpick/post/service/PostService.java +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -1,5 +1,6 @@ package com.bop.youthpick.post.service; +import com.bop.youthpick.board.repository.AttachmentRepository; import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.exception.PolicyErrorCode; @@ -8,6 +9,7 @@ import com.bop.youthpick.post.dto.PostDetailResponse; import com.bop.youthpick.post.dto.PostSummaryResponse; import com.bop.youthpick.post.dto.PostUpdateRequest; +import com.bop.youthpick.post.entity.Attachment; import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.post.entity.PostCategory; import com.bop.youthpick.post.exception.BoardErrorCode; @@ -17,6 +19,7 @@ import com.bop.youthpick.user.exception.UserError; import com.bop.youthpick.user.exception.UserException; import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -28,6 +31,7 @@ public class PostService { private final PostRepository postRepository; + private final AttachmentRepository attachmentRepository; private final UserRepository userRepository; private final PolicyRepository policyRepository; private final PostViewLogStore postViewLogStore; @@ -39,10 +43,11 @@ public PostDetailResponse create(Long userId, PostCreateRequest request) { // valueOf()는 모든 enum이 제공받는 정적 메서드. 문자열과 이름이 일치하는 enum 값을 찾아줌 Policy policy = resolvePolicy(category, request.policyId()); - Post post = Post.create(user, policy, category, request.title(), request.content()); - // request record로 선언됐으면 거기 안에 있는 변수들을 메서드로 쓰면 자동으로 똑같이 반환해줌 - return PostDetailResponse.from(postRepository.save(post)); - // from도 누가준 선물인지 모르겠어.. 암튼 위에서 받은 Post객체를 레포지토리에 저장해서 디테일리스폰스 객체로 보내준다는뜻같음 + Post post = + postRepository.save( + Post.create(user, policy, category, request.title(), request.content())); + saveAttachments(post, request.attachmentUrls()); + return PostDetailResponse.from(post); } @Transactional(readOnly = true) @@ -66,6 +71,8 @@ public PostDetailResponse update(Long userId, Long postId, PostUpdateRequest req PostCategory category = PostCategory.valueOf(request.category()); Policy policy = resolvePolicy(category, request.policyId()); post.update(policy, category, request.title(), request.content()); + attachmentRepository.deleteByPostId(postId); + saveAttachments(post, request.attachmentUrls()); return PostDetailResponse.from(post); } // PostDetailRequest가 record라면 Java가 이 메서드들(category() 등)을 자동으로 만듦 @@ -76,6 +83,17 @@ public void delete(Long userId, Long postId) { post.softDelete(); } + private void saveAttachments(Post post, List attachmentUrls) { + if (attachmentUrls == null || attachmentUrls.isEmpty()) { + return; + } + attachmentRepository.saveAll( + attachmentUrls.stream() + .distinct() + .map(url -> Attachment.create(post, url)) + .toList()); + } + private User findUser(Long userId) { return userRepository .findByIdAndDeletedAtIsNull(userId) diff --git a/src/main/resources/db/migration/V12__drop_attachments_deleted_at.sql b/src/main/resources/db/migration/V12__drop_attachments_deleted_at.sql new file mode 100644 index 0000000..df08990 --- /dev/null +++ b/src/main/resources/db/migration/V12__drop_attachments_deleted_at.sql @@ -0,0 +1,4 @@ +-- V12 — attachments.deleted_at 제거 +-- 첨부파일은 게시글 수정 시 최신 목록으로 물리 교체하며, 개별 첨부의 soft delete를 사용하지 않는다. +ALTER TABLE attachments + DROP COLUMN deleted_at; diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java index 4e646b5..95bd0f6 100644 --- a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -89,6 +89,24 @@ void setUp() { .andExpect(jsonPath("$.code").value("C001")); } + @Test + void 첨부_URL의_UUID_형식이_아니면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + post("/api/v1/posts") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "category": "FREE", + "title": "제목", + "content": "내용", + "attachmentUrls": ["/api/v1/files/------------------------------------"] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + @Test void 게시글_목록과_페이지_정보를_조회한다() throws Exception { PostSummaryResponse summary = diff --git a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java index 6786e61..90d06ac 100644 --- a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -8,12 +8,14 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.bop.youthpick.board.repository.AttachmentRepository; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.repository.PolicyRepository; import com.bop.youthpick.post.dto.PostCreateRequest; import com.bop.youthpick.post.dto.PostDetailResponse; import com.bop.youthpick.post.dto.PostSummaryResponse; import com.bop.youthpick.post.dto.PostUpdateRequest; +import com.bop.youthpick.post.entity.Attachment; import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.post.entity.PostCategory; import com.bop.youthpick.post.exception.BoardErrorCode; @@ -21,6 +23,7 @@ import com.bop.youthpick.post.repository.PostRepository; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +38,7 @@ class PostServiceTest { @Mock private PostRepository postRepository; + @Mock private AttachmentRepository attachmentRepository; @Mock private UserRepository userRepository; @Mock private PolicyRepository policyRepository; @Mock private PostViewLogStore postViewLogStore; @@ -44,13 +48,18 @@ class PostServiceTest { @BeforeEach void setUp() { postService = - new PostService(postRepository, userRepository, policyRepository, postViewLogStore); + new PostService( + postRepository, + attachmentRepository, + userRepository, + policyRepository, + postViewLogStore); } @Test void 자유글을_생성한다() { User user = user(1L); - PostCreateRequest request = new PostCreateRequest("FREE", "제목", "내용", null); + PostCreateRequest request = new PostCreateRequest("FREE", "제목", "내용", null, null); when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); when(postRepository.save(any(Post.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -63,11 +72,41 @@ void setUp() { verify(policyRepository, never()).findById(any()); } + @Test + void 게시글을_생성할_때_업로드된_이미지를_첨부로_저장한다() { + User user = user(1L); + PostCreateRequest request = + new PostCreateRequest( + "FREE", + "이미지 글", + "", + null, + List.of( + "/api/v1/files/2e5c2c2f-22c7-43a9-8d2c-8902a29b2b21", + "/api/v1/files/2e5c2c2f-22c7-43a9-8d2c-8902a29b2b21")); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + when(postRepository.save(any(Post.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + postService.create(1L, request); + + verify(attachmentRepository) + .saveAll( + org.mockito.ArgumentMatchers.argThat( + attachments -> { + assertThat(attachments) + .extracting(Attachment::getFileUrl) + .containsExactly( + "/api/v1/files/2e5c2c2f-22c7-43a9-8d2c-8902a29b2b21"); + return true; + })); + } + @Test void 후기를_생성할_때_정책을_연결한다() { User user = user(1L); Policy policy = policy(10L, "청년 정책"); - PostCreateRequest request = new PostCreateRequest("REVIEW", "후기", "내용", 10L); + PostCreateRequest request = new PostCreateRequest("REVIEW", "후기", "내용", 10L, null); when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); when(policyRepository.findById(10L)).thenReturn(Optional.of(policy)); when(postRepository.save(any(Post.class))) @@ -120,15 +159,23 @@ void setUp() { } @Test - void 작성자가_게시글을_수정한다() { + void 작성자가_게시글을_수정하면_기존_이미지_첨부를_최신_목록으로_교체한다() { Post post = Post.create(user(1L), null, PostCategory.FREE, "수정 전", "수정 전 내용"); - PostUpdateRequest request = new PostUpdateRequest("FREE", "수정 후", "수정 후 내용", null); + PostUpdateRequest request = + new PostUpdateRequest( + "FREE", + "수정 후", + "수정 후 내용", + null, + List.of("/api/v1/files/2e5c2c2f-22c7-43a9-8d2c-8902a29b2b21")); when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); PostDetailResponse response = postService.update(1L, 3L, request); assertThat(response.title()).isEqualTo("수정 후"); assertThat(response.content()).isEqualTo("수정 후 내용"); + verify(attachmentRepository).deleteByPostId(3L); + verify(attachmentRepository).saveAll(any()); } @Test @@ -145,7 +192,7 @@ void setUp() { @Test void 다른_사용자는_게시글을_수정할_수_없다() { Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); - PostUpdateRequest request = new PostUpdateRequest("FREE", "수정", "수정", null); + PostUpdateRequest request = new PostUpdateRequest("FREE", "수정", "수정", null, null); when(postRepository.findByIdAndDeletedAtIsNull(3L)).thenReturn(Optional.of(post)); assertThatThrownBy(() -> postService.update(2L, 3L, request)) From 66ec075ad625e60a3de40ac80ae66a5d9cb83585 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Tue, 21 Jul 2026 11:05:44 +0900 Subject: [PATCH 46/93] =?UTF-8?q?refac:=20OnboardingController=EB=A5=BC=20?= =?UTF-8?q?UserProfileController=EB=A1=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B8=B0=EC=A4=80=20=EB=A6=AC?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refac: OnboardingController를 UserProfileController로 도메인 엔티티 기준 리네이밍 Onboarding*으로 명명된 Controller/Service/DTO가 실제로는 UserProfile 엔티티를 다루므로 패키지 도메인 네이밍 컨벤션에 맞춰 이름을 바꾼다. 엔드포인트 경로와 동작은 변경하지 않는다. Closes #111 * refac: MyProfileController를 UserProfileController로 병합 GET /api/v1/me/profile을 처리하던 MyProfileController가 UserProfileController와 같은 UserProfileService를 공유하는 등 사실상 같은 도메인 컨트롤러였으므로 하나로 합친다. 클래스 레벨 @RequestMapping 대신 메서드별 전체 경로를 명시해 서로 다른 두 base path(/api/v1/users, /api/v1/me)를 유지한다. 엔드포인트 경로/요청/응답은 변경하지 않는다. --- .../policy/exception/PolicyErrorCode.java | 2 +- .../user/controller/MyProfileController.java | 25 ----- .../user/controller/OnboardingController.java | 33 ------- .../controller/UserProfileController.java | 39 ++++++++ ...leRequest.java => UserProfileRequest.java} | 2 +- ...Response.java => UserProfileResponse.java} | 6 +- ...ngService.java => UserProfileService.java} | 6 +- .../controller/MyProfileControllerTest.java | 96 ------------------- ...st.java => UserProfileControllerTest.java} | 80 +++++++++++++++- ...eTest.java => UserProfileServiceTest.java} | 28 +++--- 10 files changed, 136 insertions(+), 181 deletions(-) delete mode 100644 src/main/java/com/bop/youthpick/user/controller/MyProfileController.java delete mode 100644 src/main/java/com/bop/youthpick/user/controller/OnboardingController.java create mode 100644 src/main/java/com/bop/youthpick/user/controller/UserProfileController.java rename src/main/java/com/bop/youthpick/user/dto/{OnboardingProfileRequest.java => UserProfileRequest.java} (98%) rename src/main/java/com/bop/youthpick/user/dto/{OnboardingProfileResponse.java => UserProfileResponse.java} (89%) rename src/main/java/com/bop/youthpick/user/service/{OnboardingService.java => UserProfileService.java} (94%) delete mode 100644 src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java rename src/test/java/com/bop/youthpick/user/controller/{OnboardingControllerTest.java => UserProfileControllerTest.java} (59%) rename src/test/java/com/bop/youthpick/user/service/{OnboardingServiceTest.java => UserProfileServiceTest.java} (86%) diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java index 9a2ac73..6427c10 100644 --- a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -13,7 +13,7 @@ public enum PolicyErrorCode implements ErrorCode { // PolicyApplicationService.register()에서 두 번 쓰인다: ①활성 상태인 기존 신청이 있을 때(중복 등록 시도), // ②동시 요청으로 UNIQUE(user, policy) 제약을 위반한 DataIntegrityViolationException을 잡았을 때 — 같은 도메인 에러로 통일. POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT), - // policy-application 기능이 아니라 user 도메인의 OnboardingService에서 쓰인다(관심 지역 코드 검증) — + // policy-application 기능이 아니라 user 도메인의 UserProfileService에서 쓰인다(관심 지역 코드 검증) — // "정책" 접두어(P)를 쓰지만 실제로는 지역 코드 조회 실패에 재사용되는 코드다. REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND), // PolicyApplicationService.findActive(id)가 못 찾을 때(changeStatus/updateMemo/updateEndAt/delete 공통 diff --git a/src/main/java/com/bop/youthpick/user/controller/MyProfileController.java b/src/main/java/com/bop/youthpick/user/controller/MyProfileController.java deleted file mode 100644 index c4f6314..0000000 --- a/src/main/java/com/bop/youthpick/user/controller/MyProfileController.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.bop.youthpick.user.controller; - -import com.bop.youthpick.auth.service.CurrentUser; -import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.user.dto.OnboardingProfileResponse; -import com.bop.youthpick.user.entity.UserProfile; -import com.bop.youthpick.user.service.OnboardingService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/me") -@RequiredArgsConstructor -public class MyProfileController { - - private final OnboardingService onboardingService; - - @GetMapping("/profile") - public ApiResponse getProfile(@CurrentUser Long userId) { - UserProfile profile = onboardingService.getMyProfile(userId); - return ApiResponse.ok(profile == null ? null : OnboardingProfileResponse.from(profile)); - } -} diff --git a/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java b/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java deleted file mode 100644 index 84459cb..0000000 --- a/src/main/java/com/bop/youthpick/user/controller/OnboardingController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.bop.youthpick.user.controller; - -import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.user.dto.OnboardingProfileRequest; -import com.bop.youthpick.user.dto.OnboardingProfileResponse; -import com.bop.youthpick.user.entity.UserProfile; -import com.bop.youthpick.user.service.OnboardingService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -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.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/users") -@RequiredArgsConstructor -public class OnboardingController { - - private final OnboardingService onboardingService; - - // TODO: 인증 도입 후 @PathVariable userId를 인증 principal 기반으로 교체한다. - @PostMapping("/{userId}/profile") - public ResponseEntity> submit( - @PathVariable Long userId, @Valid @RequestBody OnboardingProfileRequest request) { - UserProfile profile = onboardingService.submit(userId, request); - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.ok(OnboardingProfileResponse.from(profile))); - } -} diff --git a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java new file mode 100644 index 0000000..38a1de5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java @@ -0,0 +1,39 @@ +package com.bop.youthpick.user.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.user.dto.UserProfileRequest; +import com.bop.youthpick.user.dto.UserProfileResponse; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.service.UserProfileService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequiredArgsConstructor +public class UserProfileController { + + private final UserProfileService userProfileService; + + // TODO: 인증 도입 후 @PathVariable userId를 인증 principal 기반으로 교체한다. + @PostMapping("/api/v1/users/{userId}/profile") + public ResponseEntity> submit( + @PathVariable Long userId, @Valid @RequestBody UserProfileRequest request) { + UserProfile profile = userProfileService.submit(userId, request); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(UserProfileResponse.from(profile))); + } + + @GetMapping("/api/v1/me/profile") + public ApiResponse getMyProfile(@CurrentUser Long userId) { + UserProfile profile = userProfileService.getMyProfile(userId); + return ApiResponse.ok(profile == null ? null : UserProfileResponse.from(profile)); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java similarity index 98% rename from src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java rename to src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java index 6cd1c7b..45bfc38 100644 --- a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileRequest.java +++ b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java @@ -11,7 +11,7 @@ * categories/keywords/major/specialCondition은 온보딩 화면에서 체크박스로 다중 선택되는 값이라 콤마 문자열이 아니라 배열로 받는다. 콤마 * 결합(저장 포맷)은 서비스 계층 책임이다. categories는 대분류 5개 중 최대 3개, keywords는 전체 19개 중 선택. */ -public record OnboardingProfileRequest( +public record UserProfileRequest( @NotNull(message = "출생연도는 필수입니다.") @Min(value = 1950, message = "출생연도가 올바르지 않습니다.") @Max(value = 2015, message = "출생연도가 올바르지 않습니다.") diff --git a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java b/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java similarity index 89% rename from src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java rename to src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java index b80cdeb..02ea5e1 100644 --- a/src/main/java/com/bop/youthpick/user/dto/OnboardingProfileResponse.java +++ b/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java @@ -4,7 +4,7 @@ import java.util.Arrays; import java.util.List; -public record OnboardingProfileResponse( +public record UserProfileResponse( Long id, Long userId, Integer birthYear, @@ -18,8 +18,8 @@ public record OnboardingProfileResponse( List categories, List keywords, String status) { - public static OnboardingProfileResponse from(UserProfile profile) { - return new OnboardingProfileResponse( + public static UserProfileResponse from(UserProfile profile) { + return new UserProfileResponse( profile.getId(), profile.getUser().getId(), profile.getBirthYear(), diff --git a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java similarity index 94% rename from src/main/java/com/bop/youthpick/user/service/OnboardingService.java rename to src/main/java/com/bop/youthpick/user/service/UserProfileService.java index 17cc898..f8d9e7e 100644 --- a/src/main/java/com/bop/youthpick/user/service/OnboardingService.java +++ b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java @@ -4,7 +4,7 @@ import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.RegionRepository; -import com.bop.youthpick.user.dto.OnboardingProfileRequest; +import com.bop.youthpick.user.dto.UserProfileRequest; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.entity.UserProfile; import com.bop.youthpick.user.exception.UserError; @@ -19,7 +19,7 @@ @Service @RequiredArgsConstructor -public class OnboardingService { +public class UserProfileService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; @@ -31,7 +31,7 @@ public UserProfile getMyProfile(Long userId) { } @Transactional - public UserProfile submit(Long userId, OnboardingProfileRequest request) { + public UserProfile submit(Long userId, UserProfileRequest request) { User user = userRepository .findById(userId) diff --git a/src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java deleted file mode 100644 index b8a2a96..0000000 --- a/src/test/java/com/bop/youthpick/user/controller/MyProfileControllerTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.bop.youthpick.user.controller; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.bop.youthpick.auth.dto.AuthPrincipal; -import com.bop.youthpick.policy.entity.Region; -import com.bop.youthpick.user.entity.User; -import com.bop.youthpick.user.entity.UserProfile; -import com.bop.youthpick.user.service.OnboardingService; -import java.util.List; -import org.junit.jupiter.api.AfterEach; -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.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -@WebMvcTest(controllers = MyProfileController.class) -@AutoConfigureMockMvc(addFilters = false) -class MyProfileControllerTest { - - @Autowired private MockMvc mockMvc; - - @MockitoBean private OnboardingService onboardingService; - - @AfterEach - void clearContext() { - SecurityContextHolder.clearContext(); - } - - @Test - void 온보딩된_사용자면_200과_프로필을_반환한다() throws Exception { - UserProfile profile = mock(UserProfile.class); - User user = mock(User.class); - Region region = mock(Region.class); - when(user.getId()).thenReturn(1L); - when(region.getCode()).thenReturn("11110"); - when(profile.getId()).thenReturn(10L); - when(profile.getUser()).thenReturn(user); - when(profile.getRegion()).thenReturn(region); - when(profile.getBirthYear()).thenReturn(2000); - when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); - when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); - when(profile.getMerryStatus()).thenReturn("SINGLE"); - when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); - when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); - when(profile.getIncome()).thenReturn(3000); - when(profile.getCategories()).thenReturn("취업,주거"); - when(profile.getKeywords()).thenReturn("청년,공모전"); - when(profile.getStatus()).thenReturn("COMPLETED"); - when(onboardingService.getMyProfile(1L)).thenReturn(profile); - authenticateAs(1L); - - mockMvc.perform(get("/api/v1/me/profile")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.id").value(10)) - .andExpect(jsonPath("$.data.regionCode").value("11110")) - .andExpect(jsonPath("$.data.income").value(3000)); - } - - @Test - void 온보딩하지_않은_사용자면_200과_null_데이터를_반환한다() throws Exception { - when(onboardingService.getMyProfile(1L)).thenReturn(null); - authenticateAs(1L); - - mockMvc.perform(get("/api/v1/me/profile")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data").doesNotExist()); - } - - @Test - void 미인증_요청이면_401과_A001을_반환한다() throws Exception { - mockMvc.perform(get("/api/v1/me/profile")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.code").value("A001")); - } - - /** addFilters=false로 시큐리티 필터를 건너뛰므로 SecurityContextHolder를 직접 채운다. */ - private void authenticateAs(Long userId) { - Authentication authentication = - new UsernamePasswordAuthenticationToken( - new AuthPrincipal(userId, "USER"), - null, - List.of(new SimpleGrantedAuthority("ROLE_USER"))); - SecurityContextHolder.getContext().setAuthentication(authentication); - } -} diff --git a/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java similarity index 59% rename from src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java rename to src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java index a3bde43..2431953 100644 --- a/src/test/java/com/bop/youthpick/user/controller/OnboardingControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java @@ -4,29 +4,37 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.bop.youthpick.auth.dto.AuthPrincipal; import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.entity.UserProfile; -import com.bop.youthpick.user.service.OnboardingService; +import com.bop.youthpick.user.service.UserProfileService; +import java.util.List; +import org.junit.jupiter.api.AfterEach; 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.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(controllers = OnboardingController.class) +@WebMvcTest(controllers = UserProfileController.class) @AutoConfigureMockMvc(addFilters = false) -class OnboardingControllerTest { +class UserProfileControllerTest { @Autowired private MockMvc mockMvc; - @MockitoBean private OnboardingService onboardingService; + @MockitoBean private UserProfileService userProfileService; private static final String VALID_BODY = """ @@ -44,6 +52,11 @@ class OnboardingControllerTest { } """; + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + @Test void 유효한_요청이면_201과_생성된_프로필을_반환한다() throws Exception { UserProfile profile = mock(UserProfile.class); @@ -64,7 +77,7 @@ class OnboardingControllerTest { when(profile.getCategories()).thenReturn("취업,주거"); when(profile.getKeywords()).thenReturn("청년,공모전"); when(profile.getStatus()).thenReturn("COMPLETED"); - when(onboardingService.submit(eq(1L), any())).thenReturn(profile); + when(userProfileService.submit(eq(1L), any())).thenReturn(profile); mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) @@ -119,4 +132,61 @@ class OnboardingControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("C001")); } + + @Test + void 온보딩된_사용자면_200과_프로필을_반환한다() throws Exception { + UserProfile profile = mock(UserProfile.class); + User user = mock(User.class); + Region region = mock(Region.class); + when(user.getId()).thenReturn(1L); + when(region.getCode()).thenReturn("11110"); + when(profile.getId()).thenReturn(10L); + when(profile.getUser()).thenReturn(user); + when(profile.getRegion()).thenReturn(region); + when(profile.getBirthYear()).thenReturn(2000); + when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); + when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); + when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); + when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); + when(profile.getIncome()).thenReturn(3000); + when(profile.getCategories()).thenReturn("취업,주거"); + when(profile.getKeywords()).thenReturn("청년,공모전"); + when(profile.getStatus()).thenReturn("COMPLETED"); + when(userProfileService.getMyProfile(1L)).thenReturn(profile); + authenticateAs(1L); + + mockMvc.perform(get("/api/v1/me/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(10)) + .andExpect(jsonPath("$.data.regionCode").value("11110")) + .andExpect(jsonPath("$.data.income").value(3000)); + } + + @Test + void 온보딩하지_않은_사용자면_200과_null_데이터를_반환한다() throws Exception { + when(userProfileService.getMyProfile(1L)).thenReturn(null); + authenticateAs(1L); + + mockMvc.perform(get("/api/v1/me/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void 미인증_요청이면_401과_A001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/me/profile")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + + /** addFilters=false로 시큐리티 필터를 건너뛰므로 SecurityContextHolder를 직접 채운다. */ + private void authenticateAs(Long userId) { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + new AuthPrincipal(userId, "USER"), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } } diff --git a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java similarity index 86% rename from src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java rename to src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java index dbaef01..84ae0f2 100644 --- a/src/test/java/com/bop/youthpick/user/service/OnboardingServiceTest.java +++ b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java @@ -10,7 +10,7 @@ import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.RegionRepository; -import com.bop.youthpick.user.dto.OnboardingProfileRequest; +import com.bop.youthpick.user.dto.UserProfileRequest; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.entity.UserProfile; import com.bop.youthpick.user.exception.UserError; @@ -27,7 +27,7 @@ import org.springframework.dao.DataIntegrityViolationException; @ExtendWith(MockitoExtension.class) -class OnboardingServiceTest { +class UserProfileServiceTest { @Mock private UserRepository userRepository; @@ -35,11 +35,11 @@ class OnboardingServiceTest { @Mock private RegionRepository regionRepository; - private OnboardingService onboardingService; + private UserProfileService userProfileService; private static final Long USER_ID = 1L; - private static final OnboardingProfileRequest REQUEST = - new OnboardingProfileRequest( + private static final UserProfileRequest REQUEST = + new UserProfileRequest( 2000, "11110", "EMPLOYED", @@ -53,8 +53,8 @@ class OnboardingServiceTest { @BeforeEach void setUp() { - onboardingService = - new OnboardingService(userRepository, userProfileRepository, regionRepository); + userProfileService = + new UserProfileService(userRepository, userProfileRepository, regionRepository); } @Test @@ -66,7 +66,7 @@ void setUp() { when(userProfileRepository.save(any(UserProfile.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - UserProfile result = onboardingService.submit(USER_ID, REQUEST); + UserProfile result = userProfileService.submit(USER_ID, REQUEST); assertThat(result.getBirthYear()).isEqualTo(REQUEST.birthYear()); assertThat(result.getEmploymentStatus()).isEqualTo(REQUEST.employmentStatus()); @@ -83,7 +83,7 @@ void setUp() { void 존재하지_않는_사용자면_USER_NOT_FOUND_예외를_던진다() { when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + assertThatThrownBy(() -> userProfileService.submit(USER_ID, REQUEST)) .isInstanceOf(UserException.class) .extracting(ex -> ((UserException) ex).getErrorCode()) .isEqualTo(UserError.USER_NOT_FOUND); @@ -94,7 +94,7 @@ void setUp() { when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(true); - assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + assertThatThrownBy(() -> userProfileService.submit(USER_ID, REQUEST)) .isInstanceOf(UserException.class) .extracting(ex -> ((UserException) ex).getErrorCode()) .isEqualTo(UserError.PROFILE_ALREADY_EXISTS); @@ -109,7 +109,7 @@ void setUp() { when(userProfileRepository.save(any(UserProfile.class))) .thenThrow(new DataIntegrityViolationException("uk_user_profiles_user")); - assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + assertThatThrownBy(() -> userProfileService.submit(USER_ID, REQUEST)) .isInstanceOf(UserException.class) .extracting(ex -> ((UserException) ex).getErrorCode()) .isEqualTo(UserError.PROFILE_ALREADY_EXISTS); @@ -120,7 +120,7 @@ void setUp() { UserProfile profile = mock(UserProfile.class); when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.of(profile)); - UserProfile result = onboardingService.getMyProfile(USER_ID); + UserProfile result = userProfileService.getMyProfile(USER_ID); assertThat(result).isEqualTo(profile); } @@ -129,7 +129,7 @@ void setUp() { void 프로필이_없으면_null을_반환한다() { when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); - UserProfile result = onboardingService.getMyProfile(USER_ID); + UserProfile result = userProfileService.getMyProfile(USER_ID); assertThat(result).isNull(); } @@ -140,7 +140,7 @@ void setUp() { when(userProfileRepository.existsByUserId(USER_ID)).thenReturn(false); when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.empty()); - assertThatThrownBy(() -> onboardingService.submit(USER_ID, REQUEST)) + assertThatThrownBy(() -> userProfileService.submit(USER_ID, REQUEST)) .isInstanceOf(CustomException.class) .extracting(ex -> ((CustomException) ex).getErrorCode()) .isEqualTo(PolicyErrorCode.REGION_NOT_FOUND); From be8a5bc8d6d080a5442fe3d7c5ded02c54f7b5ef Mon Sep 17 00:00:00 2001 From: Chaeyeon Han <91395969+imi21123@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:33:32 +0900 Subject: [PATCH 47/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=20API=20=EC=B6=94=EA=B0=80=20(policy-comparisons)=20(?= =?UTF-8?q?#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 정책 비교 API 추가 (policy-comparisons) 정책 2~3개를 나란히 비교하는 공개 API를 추가한다. DB에 저장하지 않는 stateless 설계로, comparisonId는 비교 대상 policyId를 오름차순 정렬해 "-"로 이어붙인 문자열이며 조회 시 다시 분해해 그 시점의 최신 정책 정보를 내려준다. 응답 항목은 정책 상세(PolicyDetailResponse)의 자격요건 필드를 기준으로 비교에 필요한 18종만 담는다. 형식이 잘못된 comparisonId는 P008 COMPARISON_NOT_FOUND로 처리한다. Co-Authored-By: Claude Opus 4.8 * test: 정책 비교 API 테스트 추가 comparisonId 인코딩(정렬·중복 제거)과 디코딩(형식 오류, policyId 2개 미만), 존재하지 않는 정책 참조, 요청 개수 범위 검증을 A/E/X 관점으로 확인한다. Co-Authored-By: Claude Opus 4.8 * docs: 정책 비교 API 응답 필드 명세 추가 comparisonId 형식, 요청 파라미터 제약, 에러코드, policies[] 응답 필드 18종을 docs/api-spec.md에 반영한다. Co-Authored-By: Claude Opus 4.8 * fix: 정책 비교 조회에 정책 개수 상한 검증 추가 create는 @Size(max = 3)로 최대 3개만 허용하는데 find의 decode는 하한(2개)만 검사해, "1-2-3-4-5"처럼 생성할 수 없는 comparisonId도 조회되고 임의로 긴 ID로 큰 IN 쿼리를 유발할 수 있었다. decode에도 같은 상한을 적용해 COMPARISON_NOT_FOUND로 거부한다. Co-Authored-By: Claude Opus 4.8 * refac: 정책 비교 응답을 상세 응답의 부분집합으로 정리하고 지역 추가 비교 응답이 상세 응답(PolicyDetailResponse)이 의도적으로 감추는 자격 판정용 내부 코드 5종(jobCodes/schoolCodes/majorCodes/specializationCodes/maritalStatusCode)을 노출하고 있었다. 같은 정책인데 상세로 보면 안 보이고 비교로 보면 보이는 비일관을 없애기 위해 제거한다. 디코딩 매핑표가 없어 프론트도 화면에 쓰지 않던 값이다. 반대로 비교에만 빠져 있던 지역(regions)을 추가한다. 지역은 전국 정책이면 정책당 최대 256행이라 fetch join 배치 조회로 N+1을 피한다. Co-Authored-By: Claude Opus 4.8 * refac: 정책 비교를 단일 GET 조회로 전환하고 comparisonId 제거 비교는 저장하는 것이 없는 순수 조회인데 생성(POST) + 식별자 재조회 구조를 쓰고 있었다. create()에 @Transactional(readOnly = true)가 붙어 있고 프론트도 useMutation이 아니라 useQuery로 호출하는 등, 양쪽 다 이미 조회로 취급하면서 HTTP 메서드만 POST였다. comparisonId는 요청에 담긴 policyIds를 정렬해 이어붙인 값이라 정보를 더하지 않았고, 실제로 프론트에서 한 번도 읽히지 않는 사문이었다(프론트는 같은 값을 쿼리 캐시 키로 따로 계산). GET /api/v1/policy-comparisons?policyIds=1&policyIds=2 하나로 합치면 식별자도 재조회 엔드포인트도 필요 없고, 요청 URL 자체가 공유 링크가 된다. - POST, GET /{comparisonId}, 요청 DTO, 응답 래퍼(comparisonId) 제거. 응답은 정책 배열. - 개수(2~3개) 검증은 컨트롤러 Bean Validation(C001)으로 이관, encode/decode 제거. - P008을 COMPARISON_NOT_FOUND(404) -> INVALID_COMPARISON_REQUEST(400, 중복 정책)로 전환. - 요청 순서를 보존해 응답한다(비교표 열 순서 = 사용자가 고른 순서). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: imi21123 Co-authored-by: Claude Opus 4.8 --- docs/api-spec.md | 44 ++++-- .../PolicyComparisonController.java | 36 +++++ .../dto/PolicyComparisonItemResponse.java | 47 +++++++ .../policy/exception/PolicyErrorCode.java | 5 +- .../service/PolicyComparisonService.java | 67 ++++++++++ .../PolicyComparisonControllerTest.java | 107 +++++++++++++++ .../service/PolicyComparisonServiceTest.java | 125 ++++++++++++++++++ 7 files changed, 416 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java create mode 100644 src/test/java/com/bop/youthpick/policy/controller/PolicyComparisonControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java diff --git a/docs/api-spec.md b/docs/api-spec.md index 46d2371..50431c5 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -53,8 +53,7 @@ Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 A | 최근 본 정책 | 최근 본 정책 목록 | `GET` | `/api/v1/policy-recent-views` | 회원 | query: page 기본 0, size 기본 20 | | 읽음 상태 | 읽음 상태 목록 조회 | `GET` | `/api/v1/policy-read-states` | 회원 | query: policyIds 반복 | | 읽음 상태 | 정책 읽음 처리 | `PUT` | `/api/v1/policy-read-states/{policyId}` | 회원 | path: policyId | -| 정책 비교 | 정책 비교 생성 | `POST` | `/api/v1/policy-comparisons` | 비회원 | body: policyIds[] | -| 정책 비교 | 정책 비교 조회 | `GET` | `/api/v1/policy-comparisons/{comparisonId}` | 비회원 | path: comparisonId | +| 정책 비교 | 정책 비교 조회 | `GET` | `/api/v1/policy-comparisons` | 비회원 | query: policyIds 반복(2~3개) | | 정책 동기화 | 정책 수집 이력 목록 | `GET` | `/api/v1/policy-batch-histories` | 관리자 | query: page 기본 0, size 기본 20 | | 정책 동기화 | 정책 수집 실행 | `POST` | `/api/v1/policy-batch-histories` | 관리자 | body: mode(FULL 또는 DELTA) | | 정책 동기화 | 검색 인덱스 재생성 | `POST` | `/api/v1/search-indexes/rebuild` | 관리자 | 없음 | @@ -407,27 +406,44 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 ## 정책 비교 -### 정책 비교 생성 +비교 결과를 저장하지 않고 요청받은 정책들을 그때그때 조회해 내려주는 **순수 조회**다. 그래서 생성(`POST`) + 식별자(`comparisonId`) 재조회 구조가 아니라 쿼리 파라미터를 받는 **단일 `GET`**이다. 저장하는 것이 없으므로 `POST`는 만들 리소스가 없고, 식별자 역시 요청에 담긴 `policyIds`를 그대로 다시 표현한 값이라 정보를 더하지 않는다. 공유가 필요하면 이 요청 URL 자체가 공유 링크가 된다. -여러 정책 ID를 기준으로 비교 결과를 생성한다. - -| 항목 | 내용 | -|---|---| -| 메서드 | `POST` | -| 경로 | `/api/v1/policy-comparisons` | -| 권한 | 비회원 | -| 파라미터 | body: policyIds[] | +응답은 항상 조회 시점의 최신 정책 정보이며, 요청에 담긴 `policyIds` 순서를 그대로 보존한다(비교표 열 순서 = 사용자가 고른 순서). ### 정책 비교 조회 -생성된 정책 비교 결과를 비교 ID로 조회한다. +여러 정책을 나란히 비교할 수 있도록 항목별 값을 함께 조회한다. | 항목 | 내용 | |---|---| | 메서드 | `GET` | -| 경로 | `/api/v1/policy-comparisons/{comparisonId}` | +| 경로 | `/api/v1/policy-comparisons` | | 권한 | 비회원 | -| 파라미터 | path: comparisonId | +| 파라미터 | query: `policyIds` 반복 — 서로 다른 정책 2~3개 (예: `?policyIds=1&policyIds=2`) | +| 에러 | 개수가 2~3개를 벗어나거나 숫자가 아니거나 누락되면 `C001`, 중복된 정책이 있으면 `P008 INVALID_COMPARISON_REQUEST`, 존재하지 않는 정책이 섞여 있으면 `P001 POLICY_NOT_FOUND` | + +### 응답 필드 + +`data`는 비교 대상 정책 배열이다. 각 원소는 `Policy` 엔티티 전체가 아니라 비교에 필요한 필드만 담은 값이며, **정책 상세 조회 응답의 부분집합으로 유지한다** — 같은 정책을 상세로 볼 때 감춰지는 값이 비교로 볼 때만 드러나면 안 되기 때문이다. 따라서 자격 판정용 내부 코드(`jobCodes`, `schoolCodes`, `majorCodes`, `specializationCodes`, `maritalStatusCode`)와 보류 필드, raw payload는 상세와 동일하게 노출하지 않는다. + +| 필드 | 타입 | 내용 | +|---|---|---| +| `policyId` | number | 정책 ID | +| `title` | string | 정책명 | +| `category` | string | 대분류 | +| `organizationName` | string | 주관기관 | +| `minAge` / `maxAge` | number \| null | 지원 나이 범위 (null = 제한없음) | +| `incomeConditionCode` | string \| null | 소득 조건 구분 코드 | +| `incomeMaxAmount` | number \| null | 연소득 상한(만원) | +| `incomeEtcContent` | string \| null | 소득 조건 기타 설명 | +| `additionalQualification` | string \| null | 추가 자격조건 원문 | +| `participationRestriction` | string \| null | 참여 제한사항 원문 | +| `applicationEndDate` | string(`YYYY-MM-DD`) \| null | 신청 마감일 | +| `applicationUrl` | string \| null | 신청 바로가기 링크 | +| `regions[]` | array | 지원 지역 목록. 비어 있으면 지역 조건 없음 | +| `regions[].regionCode` | string | 법정동 코드 | +| `regions[].provinceName` | string | 시도명 | +| `regions[].districtName` | string | 시군구명 | ## 정책 동기화 diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java new file mode 100644 index 0000000..4eb1e6a --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java @@ -0,0 +1,36 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.PolicyComparisonItemResponse; +import com.bop.youthpick.policy.service.PolicyComparisonService; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 정책 비교 (비회원 허용). 비교 결과는 저장하지 않고 요청받은 정책들을 그때그때 조회해 내려주는 순수 조회다 — 그래서 생성(POST) + 식별자 재조회가 아니라 쿼리 + * 파라미터를 받는 단일 GET이다. 공유가 필요하면 이 요청 URL 자체가 공유 링크가 된다. + */ +@Validated +@RestController +@RequestMapping("/api/v1/policy-comparisons") +@RequiredArgsConstructor +public class PolicyComparisonController { + + private final PolicyComparisonService policyComparisonService; + + @GetMapping + public ApiResponse> compare( + @RequestParam + @NotEmpty(message = "비교할 정책 목록은 필수입니다.") + @Size(min = 2, max = 3, message = "정책 비교는 2개에서 3개까지 가능합니다.") + List policyIds) { + return ApiResponse.ok(policyComparisonService.compare(policyIds)); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java new file mode 100644 index 0000000..2f0bf69 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java @@ -0,0 +1,47 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Policy; +import java.time.LocalDate; +import java.util.List; + +/** + * 비교표 한 열(정책 한 건)의 응답. 자격요건 원문 노출용 필드를 나열해 프론트가 항목별로 나란히 비교할 수 있게 한다. + * + *

필드 구성은 {@link PolicyDetailResponse}의 부분집합으로 유지한다 — 같은 정책을 상세로 볼 때는 감춰지는 값이 비교로 볼 때만 드러나면 안 되기 + * 때문이다. 따라서 자격 판정용 내부 코드(jobCodes/schoolCodes/majorCodes/specializationCodes/maritalStatusCode)와 보류 + * 필드, raw payload는 상세와 동일하게 노출하지 않는다. + */ +public record PolicyComparisonItemResponse( + Long policyId, + String title, + String category, + String organizationName, + Integer minAge, + Integer maxAge, + String incomeConditionCode, + Integer incomeMaxAmount, + String incomeEtcContent, + String additionalQualification, + String participationRestriction, + LocalDate applicationEndDate, + String applicationUrl, + List regions) { + + public static PolicyComparisonItemResponse from(Policy policy, List regions) { + return new PolicyComparisonItemResponse( + policy.getId(), + policy.getTitle(), + policy.getCategory(), + policy.getOrganizationName(), + policy.getMinAge(), + policy.getMaxAge(), + policy.getIncomeConditionCode(), + policy.getIncomeMaxAmount(), + policy.getIncomeEtcContent(), + policy.getAdditionalQualification(), + policy.getParticipationRestriction(), + policy.getApplicationEndDate(), + policy.getApplicationUrl(), + regions); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java index 6427c10..23efa3e 100644 --- a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -30,7 +30,10 @@ public enum PolicyErrorCode implements ErrorCode { // 값이라 평소엔 발생하지 않지만, ApplicationStatus enum과 두 곳의 @Pattern 문자열이 어긋나는 경우를 대비한 // 방어선이다(ApplicationStatus.java 클래스 주석 참고). 이게 없으면 valueOf()의 IllegalArgumentException이 // GlobalExceptionHandler의 Exception.class 핸들러로 떨어져 500(S001)으로 샌다. - INVALID_APPLICATION_STATUS("P007", "유효하지 않은 상태값입니다.", HttpStatus.BAD_REQUEST); + INVALID_APPLICATION_STATUS("P007", "유효하지 않은 상태값입니다.", HttpStatus.BAD_REQUEST), + // PolicyComparisonService.compare()에서 policyIds에 중복이 있을 때 던진다. 개수(2~3개) 제약은 컨트롤러의 + // Bean Validation이 C001로 먼저 걸러주므로, 여기서는 "서로 다른 정책"이라는 의미적 제약만 담당한다. + INVALID_COMPARISON_REQUEST("P008", "정책 비교는 서로 다른 정책을 선택해야 합니다.", HttpStatus.BAD_REQUEST); private final String code; private final String message; diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java new file mode 100644 index 0000000..6ade5e3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java @@ -0,0 +1,67 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyComparisonItemResponse; +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 정책 비교. 비교 결과를 저장하지 않고 요청받은 정책들을 그때그때 조회해 내려주는 순수 조회다 — 응답은 항상 조회 시점의 최신 정책 정보다. 개수 제약(2~3개)은 + * 컨트롤러의 Bean Validation이 검증하고, 여기서는 "서로 다른 정책"이라는 의미적 제약만 본다. + */ +@Service +@RequiredArgsConstructor +public class PolicyComparisonService { + + private final PolicyRepository policyRepository; + private final PolicyRegionRepository policyRegionRepository; + + /** + * 요청 순서를 그대로 보존해 비교 대상 정책을 조회한다(비교표 열 순서 = 사용자가 고른 순서). policyIds에 중복이 있으면 비교가 성립하지 않으므로 + * INVALID_COMPARISON_REQUEST, 존재하지 않는 정책이 섞여 있으면 POLICY_NOT_FOUND를 던진다. + */ + @Transactional(readOnly = true) + public List compare(List policyIds) { + if (policyIds.stream().distinct().count() != policyIds.size()) { + throw new CustomException(PolicyErrorCode.INVALID_COMPARISON_REQUEST); + } + + List policies = policyRepository.findAllById(policyIds); + if (policies.size() != policyIds.size()) { + throw new CustomException(PolicyErrorCode.POLICY_NOT_FOUND); + } + + Map policyById = + policies.stream().collect(Collectors.toMap(Policy::getId, Function.identity())); + Map> regionsByPolicyId = findRegionsByPolicyId(policyIds); + return policyIds.stream() + .map( + policyId -> + PolicyComparisonItemResponse.from( + policyById.get(policyId), + regionsByPolicyId.getOrDefault(policyId, List.of()))) + .toList(); + } + + /** 지역은 전국 정책이면 정책당 최대 256행이라, fetch join 배치 조회로 한 번에 가져와 N+1을 피한다. */ + private Map> findRegionsByPolicyId(List policyIds) { + return policyRegionRepository.findWithRegionByPolicyIdIn(policyIds).stream() + .collect( + Collectors.groupingBy( + policyRegion -> policyRegion.getPolicy().getId(), + Collectors.mapping( + policyRegion -> + RegionResponse.from(policyRegion.getRegion()), + Collectors.toList()))); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyComparisonControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyComparisonControllerTest.java new file mode 100644 index 0000000..a5f46dc --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyComparisonControllerTest.java @@ -0,0 +1,107 @@ +package com.bop.youthpick.policy.controller; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyComparisonItemResponse; +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.service.PolicyComparisonService; +import java.util.List; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = PolicyComparisonController.class) +@AutoConfigureMockMvc(addFilters = false) +class PolicyComparisonControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private PolicyComparisonService policyComparisonService; + + private static final PolicyComparisonItemResponse ITEM = + new PolicyComparisonItemResponse( + 1L, + "청년 월세 지원", + "주거", + "국토교통부", + 19, + 34, + null, + null, + null, + null, + null, + null, + null, + List.of(new RegionResponse("11680", "서울특별시", "강남구"))); + + @Test + void 유효한_요청이면_200과_비교결과를_반환한다() throws Exception { + when(policyComparisonService.compare(List.of(1L, 2L))).thenReturn(List.of(ITEM)); + + mockMvc.perform(get("/api/v1/policy-comparisons").param("policyIds", "1", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].policyId").value(1)) + .andExpect(jsonPath("$.data[0].regions[0].provinceName").value("서울특별시")) + // 자격 판정용 내부 코드는 상세 응답과 동일하게 노출하지 않는다. + .andExpect(jsonPath("$.data[0].jobCodes").doesNotExist()) + .andExpect(jsonPath("$.data[0].maritalStatusCode").doesNotExist()); + } + + @Test + void 정책ID가_1개면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/policy-comparisons").param("policyIds", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 정책ID가_4개면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/policy-comparisons").param("policyIds", "1", "2", "3", "4")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void policyIds_파라미터를_생략하면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/policy-comparisons")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 숫자가_아닌_policyId면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/policy-comparisons").param("policyIds", "1", "abc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 중복된_정책이면_400과_P008을_반환한다() throws Exception { + when(policyComparisonService.compare(anyList())) + .thenThrow(new CustomException(PolicyErrorCode.INVALID_COMPARISON_REQUEST)); + + mockMvc.perform(get("/api/v1/policy-comparisons").param("policyIds", "1", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("P008")); + } + + @Test + void 존재하지_않는_정책이_섞여있으면_404와_P001을_반환한다() throws Exception { + when(policyComparisonService.compare(anyList())) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + + mockMvc.perform(get("/api/v1/policy-comparisons").param("policyIds", "1", "2")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("P001")); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java new file mode 100644 index 0000000..512e40d --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java @@ -0,0 +1,125 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.dto.PolicyComparisonItemResponse; +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeanUtils; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyComparisonServiceTest { + + @Mock private PolicyRepository policyRepository; + @Mock private PolicyRegionRepository policyRegionRepository; + + private PolicyComparisonService policyComparisonService; + + @BeforeEach + void setUp() { + policyComparisonService = + new PolicyComparisonService(policyRepository, policyRegionRepository); + } + + @Test + void 요청한_순서_그대로_비교_대상_정책을_반환한다() { + Policy first = newPolicy(1L, "청년 월세 지원"); + Policy second = newPolicy(2L, "청년 취업 장려금"); + // repository가 요청과 다른 순서로 반환해도 응답은 요청 순서(2, 1)를 따라야 한다. + when(policyRepository.findAllById(List.of(2L, 1L))).thenReturn(List.of(first, second)); + + List policies = + policyComparisonService.compare(List.of(2L, 1L)); + + assertThat(policies).extracting("policyId").containsExactly(2L, 1L); + assertThat(policies).extracting("title").containsExactly("청년 취업 장려금", "청년 월세 지원"); + } + + @Test + void 정책별_지역을_묶어서_담고_지역이_없는_정책은_빈_목록으로_내려준다() { + Policy first = newPolicy(1L, "청년 월세 지원"); + Policy second = newPolicy(2L, "청년 취업 장려금"); + when(policyRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(first, second)); + when(policyRegionRepository.findWithRegionByPolicyIdIn(List.of(1L, 2L))) + .thenReturn( + List.of( + newPolicyRegion(first, "11680", "서울특별시", "강남구"), + newPolicyRegion(first, "26110", "부산광역시", "중구"))); + + List policies = + policyComparisonService.compare(List.of(1L, 2L)); + + assertThat(policies.get(0).regions()) + .containsExactly( + new RegionResponse("11680", "서울특별시", "강남구"), + new RegionResponse("26110", "부산광역시", "중구")); + assertThat(policies.get(1).regions()).isEmpty(); + } + + @Test + void policyIds에_중복이_있으면_INVALID_COMPARISON_REQUEST_예외를_던진다() { + assertThatThrownBy(() -> policyComparisonService.compare(List.of(1L, 1L))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue( + "errorCode", PolicyErrorCode.INVALID_COMPARISON_REQUEST); + verify(policyRepository, never()).findAllById(List.of(1L, 1L)); + } + + @Test + void 존재하지_않는_정책이_섞여있으면_POLICY_NOT_FOUND_예외를_던진다() { + Policy first = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(first)); + + assertThatThrownBy(() -> policyComparisonService.compare(List.of(1L, 2L))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", PolicyErrorCode.POLICY_NOT_FOUND); + } + + @Test + void 정책_3개도_정상_비교된다() { + Policy first = newPolicy(1L, "청년 월세 지원"); + Policy second = newPolicy(2L, "청년 취업 장려금"); + Policy third = newPolicy(3L, "청년 도약계좌"); + when(policyRepository.findAllById(List.of(1L, 2L, 3L))) + .thenReturn(List.of(first, second, third)); + + List policies = + policyComparisonService.compare(List.of(1L, 2L, 3L)); + + assertThat(policies).extracting("policyId").containsExactly(1L, 2L, 3L); + } + + private Policy newPolicy(Long id, String title) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "id", id); + ReflectionTestUtils.setField(policy, "policyNo", "R2026" + id); + ReflectionTestUtils.setField(policy, "title", title); + return policy; + } + + private PolicyRegion newPolicyRegion( + Policy policy, String regionCode, String sidoName, String name) { + Region region = BeanUtils.instantiateClass(Region.class); + ReflectionTestUtils.setField(region, "code", regionCode); + ReflectionTestUtils.setField(region, "sidoName", sidoName); + ReflectionTestUtils.setField(region, "name", name); + return PolicyRegion.create(policy, region); + } +} From 4e82a60170ca1161051610e5a4ffc33930dbc185 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Tue, 21 Jul 2026 12:11:10 +0900 Subject: [PATCH 48/93] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=96=B4/=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=99=80=20=EC=A0=95=EB=A0=AC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/posts에 category/query 선택 파라미터를 추가하고 PostSpecifications(JpaSpecificationExecutor 조합)로 필터링한다. 정렬은 Pageable의 sort 파라미터를 그대로 활용한다(createdAt/viewCount 모두 실제 컬럼). 목록 조회 시 user/policy N+1을 막기 위해 findAll(Specification, Pageable)을 @EntityGraph로 재선언했다. 댓글순/좋아요순 정렬은 범위 밖 — Post 엔티티에 댓글수/좋아요수 컬럼이 없고, 좋아요는 백엔드에 저장되지 않는 완전 프론트 mock이다. Closes #113 --- .../post/controller/PostController.java | 8 +- .../post/repository/PostRepository.java | 6 +- .../post/repository/PostSpecifications.java | 39 +++++++++ .../youthpick/post/service/PostService.java | 7 +- .../post/controller/PostControllerTest.java | 17 +++- .../repository/PostSpecificationsTest.java | 82 +++++++++++++++++++ .../post/service/PostServiceTest.java | 6 +- 7 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/post/repository/PostSpecifications.java create mode 100644 src/test/java/com/bop/youthpick/post/repository/PostSpecificationsTest.java diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java index 9fd8ccf..4b9a5a7 100644 --- a/src/main/java/com/bop/youthpick/post/controller/PostController.java +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -34,12 +34,14 @@ public ResponseEntity> create( .body(ApiResponse.ok(response)); // 성공했을때 http status 201 } - @GetMapping - public ApiResponse> findAll( // 리스폰스엔티티로 감싸야함 수정필요 + @GetMapping // 리스폰스엔티티로 감싸야함 수정필요 + public ApiResponse> findAll( + @RequestParam(required = false) String category, + @RequestParam(required = false) String query, @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - Page page = postService.findAll(pageable); + Page page = postService.findAll(category, query, pageable); return ApiResponse.ok(page.getContent(), page); } diff --git a/src/main/java/com/bop/youthpick/post/repository/PostRepository.java b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java index 67e6aed..9f914ce 100644 --- a/src/main/java/com/bop/youthpick/post/repository/PostRepository.java +++ b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java @@ -4,6 +4,7 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -16,8 +17,11 @@ public interface PostRepository extends JpaRepository, JpaSpecificat @EntityGraph(attributePaths = {"user", "policy"}) // jpql을 써서 구현하는 방법도 있음 Optional findByIdAndDeletedAtIsNull(Long id); // 쿼리메서드 + // JpaSpecificationExecutor의 기본 findAll(Specification, Pageable)을 재선언해 + // @EntityGraph로 목록 조회 시 user/policy N+1을 막는다(PostSummaryResponse가 둘 다 참조). + @Override @EntityGraph(attributePaths = {"user", "policy"}) - Page findAllByDeletedAtIsNull(Pageable pageable); + Page findAll(Specification spec, Pageable pageable); @Modifying(clearAutomatically = true) @Query( diff --git a/src/main/java/com/bop/youthpick/post/repository/PostSpecifications.java b/src/main/java/com/bop/youthpick/post/repository/PostSpecifications.java new file mode 100644 index 0000000..0cee8b7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/repository/PostSpecifications.java @@ -0,0 +1,39 @@ +package com.bop.youthpick.post.repository; + +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.entity.PostCategory; +import org.springframework.data.jpa.domain.Specification; + +/** 게시글 목록 조회의 선택적 필터(category/검색어)를 조합한다. */ +public final class PostSpecifications { + + private PostSpecifications() {} + + public static Specification search(String category, String query) { + return Specification.allOf( + notDeleted(), categoryEquals(category), titleOrContentContains(query)); + } + + private static Specification notDeleted() { + return (root, cq, cb) -> cb.isNull(root.get("deletedAt")); + } + + private static Specification categoryEquals(String category) { + if (category == null || category.isBlank()) { + return null; + } + PostCategory value = PostCategory.valueOf(category); + return (root, cq, cb) -> cb.equal(root.get("category"), value); + } + + private static Specification titleOrContentContains(String query) { + if (query == null || query.isBlank()) { + return null; + } + String pattern = "%" + query.toLowerCase() + "%"; + return (root, cq, cb) -> + cb.or( + cb.like(cb.lower(root.get("title")), pattern), + cb.like(cb.lower(root.get("content")), pattern)); + } +} diff --git a/src/main/java/com/bop/youthpick/post/service/PostService.java b/src/main/java/com/bop/youthpick/post/service/PostService.java index bc5eb63..30c9983 100644 --- a/src/main/java/com/bop/youthpick/post/service/PostService.java +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -15,6 +15,7 @@ import com.bop.youthpick.post.exception.BoardErrorCode; import com.bop.youthpick.post.exception.BoardException; import com.bop.youthpick.post.repository.PostRepository; +import com.bop.youthpick.post.repository.PostSpecifications; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.exception.UserError; import com.bop.youthpick.user.exception.UserException; @@ -23,6 +24,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -51,8 +53,9 @@ public PostDetailResponse create(Long userId, PostCreateRequest request) { } @Transactional(readOnly = true) - public Page findAll(Pageable pageable) { - return postRepository.findAllByDeletedAtIsNull(pageable).map(PostSummaryResponse::from); + public Page findAll(String category, String query, Pageable pageable) { + Specification spec = PostSpecifications.search(category, query); + return postRepository.findAll(spec, pageable).map(PostSummaryResponse::from); } @Transactional diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java index 95bd0f6..2117d5d 100644 --- a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -111,7 +111,8 @@ void setUp() { void 게시글_목록과_페이지_정보를_조회한다() throws Exception { PostSummaryResponse summary = new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "제목", 0, null); - when(postService.findAll(any())).thenReturn(new PageImpl<>(List.of(summary))); + when(postService.findAll(eq(null), eq(null), any())) + .thenReturn(new PageImpl<>(List.of(summary))); mockMvc.perform(get("/api/v1/posts")) .andExpect(status().isOk()) @@ -119,6 +120,20 @@ void setUp() { .andExpect(jsonPath("$.meta.totalCount").value(1)); } + @Test + void 카테고리와_검색어_파라미터를_그대로_서비스에_전달한다() throws Exception { + PostSummaryResponse summary = + new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "잡담글", 0, null); + when(postService.findAll(eq("FREE"), eq("잡담"), any())) + .thenReturn(new PageImpl<>(List.of(summary))); + + mockMvc.perform(get("/api/v1/posts").param("category", "FREE").param("query", "잡담")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].title").value("잡담글")); + + verify(postService).findAll(eq("FREE"), eq("잡담"), any()); + } + @Test void 게시글_상세를_조회한다() throws Exception { when(postService.findById(eq(3L), eq(1L), any())) diff --git a/src/test/java/com/bop/youthpick/post/repository/PostSpecificationsTest.java b/src/test/java/com/bop/youthpick/post/repository/PostSpecificationsTest.java new file mode 100644 index 0000000..8102534 --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/repository/PostSpecificationsTest.java @@ -0,0 +1,82 @@ +package com.bop.youthpick.post.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.entity.PostCategory; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PostSpecificationsTest { + + @Autowired private PostRepository postRepository; + + @Autowired private UserRepository userRepository; + + @BeforeEach + void setUp() { + User author = userRepository.save(User.createSocialUser("kakao", "kakao-1", null, "닉네임")); + postRepository.save(Post.create(author, null, PostCategory.QUESTION, "정책 질문 있어요", "궁금합니다")); + postRepository.save(Post.create(author, null, PostCategory.REVIEW, "정책 후기", "도움이 됐어요")); + postRepository.save(Post.create(author, null, PostCategory.FREE, "잡담", "오늘 날씨 좋네요")); + } + + @Test + void category로_필터링한다() { + List result = postRepository.findAll(PostSpecifications.search("QUESTION", null)); + + assertThat(result).extracting(Post::getTitle).containsExactly("정책 질문 있어요"); + } + + @Test + void 제목에_검색어가_포함된_게시글을_찾는다() { + List result = postRepository.findAll(PostSpecifications.search(null, "질문")); + + assertThat(result).extracting(Post::getTitle).containsExactly("정책 질문 있어요"); + } + + @Test + void 본문에_검색어가_포함된_게시글도_찾는다() { + List result = postRepository.findAll(PostSpecifications.search(null, "날씨")); + + assertThat(result).extracting(Post::getTitle).containsExactly("잡담"); + } + + @Test + void 검색어는_대소문자를_구분하지_않는다() { + postRepository.save( + Post.create( + userRepository.findAll().getFirst(), + null, + PostCategory.FREE, + "Hello World", + "내용")); + + List result = postRepository.findAll(PostSpecifications.search(null, "HELLO")); + + assertThat(result).extracting(Post::getTitle).containsExactly("Hello World"); + } + + @Test + void 카테고리와_검색어를_함께_적용한다() { + List result = postRepository.findAll(PostSpecifications.search("FREE", "잡담")); + + assertThat(result).extracting(Post::getTitle).containsExactly("잡담"); + } + + @Test + void 필터가_없으면_전체를_반환한다() { + List result = postRepository.findAll(PostSpecifications.search(null, null)); + + assertThat(result).hasSize(3); + } +} diff --git a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java index 90d06ac..54acf7f 100644 --- a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -33,6 +34,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; @ExtendWith(MockitoExtension.class) class PostServiceTest { @@ -123,10 +125,10 @@ void setUp() { void 삭제되지_않은_게시글_목록을_조회한다() { Post post = Post.create(user(1L), null, PostCategory.FREE, "제목", "내용"); PageRequest pageable = PageRequest.of(0, 20); - when(postRepository.findAllByDeletedAtIsNull(pageable)) + when(postRepository.findAll(any(Specification.class), eq(pageable))) .thenReturn(new PageImpl<>(java.util.List.of(post), pageable, 1)); - Page result = postService.findAll(pageable); + Page result = postService.findAll(null, null, pageable); assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().getFirst().title()).isEqualTo("제목"); From f184f1f3893035fc893a730b22579f03a041d7d2 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Tue, 21 Jul 2026 14:46:08 +0900 Subject: [PATCH 49/93] =?UTF-8?q?fix:=20region=3D=EC=A0=84=EA=B5=AD?= =?UTF-8?q?=EC=9D=B4=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=A2=81=ED=9E=88?= =?UTF-8?q?=EB=8D=98=20=EB=8F=99=EC=9E=91=20=EC=A0=9C=EA=B1=B0=20(#93)=20(?= =?UTF-8?q?#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지역 드롭다운의 '전국'은 "지역 무관" 선택지인데, 전 시도를 커버하는 정책만 반환하도록 구현돼 있어 사용자 기대와 반대 결과가 나갔다. 이슈 #86 명세와 docs/api-spec.md 어디에도 없는 동작이라 제거한다. - PolicyService: nationwideOnly/nationwideThreshold 분기 제거, region=전국은 시도명 필터 미적용(sidoName=null)으로 처리 - PolicyRepository: totalSidoCount 파라미터와 상관 서브쿼리 제거 (후보 행마다 실행되던 count(distinct) 집계가 사라짐) - PolicyServiceTest: 전국 테스트를 "필터 미적용" 검증으로 정정하고 일반 시도명이 그대로 전달되는 케이스 추가 응답의 regionLabel 전국 표시 로직은 범위 밖 — 그대로 둔다. Claude-Session: https://claude.ai/code/session_01VYc5qk1weVzxPEJk4Zza5A Co-authored-by: Claude Opus 4.8 (1M context) --- .../policy/repository/PolicyRepository.java | 10 ++----- .../policy/service/PolicyService.java | 9 +++--- .../policy/service/PolicyServiceTest.java | 28 ++++++++++++++----- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index 09bf225..450b2cf 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -31,9 +31,9 @@ public interface PolicyRepository /** * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함. category는 표준 - * 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE 부분일치(escape '!'). region은 시도명 - * EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음), totalSidoCount가 채워지면(region=전국) 전 시도를 커버하는 정책만. age는 요청 - * 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. + * 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE 부분일치(escape '!'). sidoName은 시도명 + * EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 포함된다. age는 요청 구간과 정책 자격 구간의 + * 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. */ @Query( "select p from Policy p where p.visibility = :visibility and p.deletedAt is null" @@ -48,9 +48,6 @@ public interface PolicyRepository + " and (:sidoName is null or exists (" + " select pr from PolicyRegion pr" + " where pr.policy = p and pr.region.sidoName = :sidoName))" - + " and (:totalSidoCount is null or (" - + " select count(distinct pr2.region.sidoName) from PolicyRegion pr2" - + " where pr2.policy = p) >= :totalSidoCount)" + " and (:ageMin is null or p.maxAge is null or p.maxAge = 0 or p.maxAge >= :ageMin)" + " and (:ageMax is null or p.minAge is null or p.minAge = 0 or p.minAge <= :ageMax)") Page findCards( @@ -59,7 +56,6 @@ Page findCards( @Param("category") String category, @Param("keyword") String keyword, @Param("sidoName") String sidoName, - @Param("totalSidoCount") Long totalSidoCount, @Param("ageMin") Integer ageMin, @Param("ageMax") Integer ageMax, Pageable pageable); diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 0304be9..7494950 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -41,7 +41,7 @@ public class PolicyService { /** * 정책 목록(카드) 조회 (비회원 허용). 삭제·숨김·신청 마감 지난 정책은 제외하고 최신순(id 내림차순, 서버 고정 정렬)으로 내린다. category(표준 5분류) - * exact match, keyword는 5개 필드 LIKE 부분일치, region은 시도명 필터('전국'이면 전 시도를 커버하는 정책만), age는 [ageMin, + * exact match, keyword는 5개 필드 LIKE 부분일치, region은 시도명 필터('전국'은 지역 무관이라 필터 미적용), age는 [ageMin, * ageMax] 구간과 정책 자격 구간의 겹침(overlap)만 필터한다. 지역 라벨은 페이지 단위 배치 조회(fetch join)로 조립해 N+1을 피한다. */ @Transactional(readOnly = true) @@ -60,10 +60,10 @@ public Page getCards( String categoryFilter = trimToNull(category); String keywordPattern = toLikePattern(keyword); String regionFilter = trimToNull(region); - boolean nationwideOnly = NATIONWIDE_REGION.equals(regionFilter); - String sidoName = nationwideOnly ? null : regionFilter; + // '전국'은 지역 드롭다운의 "지역 무관" 선택지 — 시도명 필터를 걸지 않는다. + // 전국 대상 정책은 policy_regions에 전 시도가 들어 있어 개별 시도 조회에도 이미 포함된다. + String sidoName = NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter; long totalSidoCount = regionRepository.countDistinctSidoNames(); - Long nationwideThreshold = nationwideOnly ? totalSidoCount : null; Page page = policyRepository.findCards( @@ -72,7 +72,6 @@ public Page getCards( categoryFilter, keywordPattern, sidoName, - nationwideThreshold, ageMin, ageMax, sorted); diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index 0f58ecf..6e27250 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -144,7 +144,6 @@ void setUp() { isNull(), isNull(), isNull(), - isNull(), pageableCaptor.capture())) .thenReturn( new PageImpl<>( @@ -192,7 +191,6 @@ void setUp() { isNull(), isNull(), isNull(), - isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); when(regionRepository.countDistinctSidoNames()).thenReturn(16L); @@ -203,16 +201,14 @@ void setUp() { } @Test - void region이_전국이면_시도명_대신_전_시도_커버_조건으로_필터한다() { + void region이_전국이면_지역_무관이라_시도명_필터를_걸지_않는다() { ArgumentCaptor sidoNameCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor totalSidoCountCaptor = ArgumentCaptor.forClass(Long.class); when(policyRepository.findCards( eq(PolicyVisibility.VISIBLE), any(LocalDate.class), isNull(), isNull(), sidoNameCaptor.capture(), - totalSidoCountCaptor.capture(), isNull(), isNull(), any(Pageable.class))) @@ -222,7 +218,26 @@ void setUp() { policyService.getCards(null, null, "전국", null, null, PageRequest.of(0, 20)); assertThat(sidoNameCaptor.getValue()).isNull(); - assertThat(totalSidoCountCaptor.getValue()).isEqualTo(16L); + } + + @Test + void region이_시도명이면_그대로_시도명_필터로_전달한다() { + ArgumentCaptor sidoNameCaptor = ArgumentCaptor.forClass(String.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + sidoNameCaptor.capture(), + isNull(), + isNull(), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + when(regionRepository.countDistinctSidoNames()).thenReturn(16L); + + policyService.getCards(null, null, "서울특별시", null, null, PageRequest.of(0, 20)); + + assertThat(sidoNameCaptor.getValue()).isEqualTo("서울특별시"); } @Test @@ -235,7 +250,6 @@ void setUp() { isNull(), isNull(), isNull(), - isNull(), ageMinCaptor.capture(), ageMaxCaptor.capture(), any(Pageable.class))) From fdc7c1b3c8e331fac4e115349495a062c4469315 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Tue, 21 Jul 2026 15:09:55 +0900 Subject: [PATCH 50/93] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=88=98=EC=A0=95(PATCH)=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 지역 목록을 한글 가나다순으로 정렬 RegionRepository.findAllByOrderBySidoNameAscNameAsc()를 추가해 RegionController(온보딩 거주지역 선택지)와 AdminRegionService가 법정동코드 삽입 순서 대신 시도명 -> 시군구명 가나다순으로 응답하게 한다. Closes #115 * feat: 온보딩 프로필 수정(PATCH) API 추가 PATCH /api/v1/me/profile을 추가한다. UserProfileService.update()가 기존 프로필을 찾아 UserProfile.update()로 필드를 갱신하고, 프로필이 없으면 새 에러코드 U004 PROFILE_NOT_FOUND(404)를 던진다. 기존에는 생성 전용 POST /api/v1/users/{userId}/profile만 있어서 "프로필 수정" 화면이 이미 프로필이 있는 사용자에게 항상 PROFILE_ALREADY_EXISTS(409/U003)로 거부당했다. Closes #119 --- .../policy/service/AdminRegionService.java | 4 +- .../policy/controller/RegionController.java | 4 +- .../policy/repository/RegionRepository.java | 4 ++ .../controller/UserProfileController.java | 8 +++ .../youthpick/user/entity/UserProfile.java | 23 ++++++++ .../youthpick/user/exception/UserError.java | 3 +- .../user/service/UserProfileService.java | 27 +++++++++ .../controller/RegionControllerTest.java | 2 +- .../repository/RegionRepositoryTest.java | 35 ++++++++++++ .../controller/UserProfileControllerTest.java | 43 ++++++++++++++ .../user/service/UserProfileServiceTest.java | 56 +++++++++++++++++++ 11 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/bop/youthpick/policy/repository/RegionRepositoryTest.java diff --git a/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java index d6f47e2..63ef082 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java @@ -15,6 +15,8 @@ public class AdminRegionService { @Transactional(readOnly = true) public List findAll() { - return regionRepository.findAll().stream().map(RegionResponse::from).toList(); + return regionRepository.findAllByOrderBySidoNameAscNameAsc().stream() + .map(RegionResponse::from) + .toList(); } } diff --git a/src/main/java/com/bop/youthpick/policy/controller/RegionController.java b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java index 0226ebc..6148008 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/RegionController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java @@ -20,6 +20,8 @@ public class RegionController { @GetMapping public ApiResponse> list() { return ApiResponse.ok( - regionRepository.findAll().stream().map(RegionResponse::from).toList()); + regionRepository.findAllByOrderBySidoNameAscNameAsc().stream() + .map(RegionResponse::from) + .toList()); } } diff --git a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java index 7151ca3..612d6ff 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java @@ -1,6 +1,7 @@ package com.bop.youthpick.policy.repository; import com.bop.youthpick.policy.entity.Region; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -9,4 +10,7 @@ public interface RegionRepository extends JpaRepository { /** 시도 개수 — 정책이 전 시도를 커버하면 지역 라벨을 '전국'으로 접는 판정에 쓴다. */ @Query("select count(distinct r.sidoName) from Region r") long countDistinctSidoNames(); + + /** 시도명 → 시군구명 순 한글 가나다 정렬. 온보딩/관리자 지역 선택지가 이 순서를 그대로 쓴다. */ + List findAllByOrderBySidoNameAscNameAsc(); } diff --git a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java index 38a1de5..01f4a4a 100644 --- a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java +++ b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java @@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -36,4 +37,11 @@ public ApiResponse getMyProfile(@CurrentUser Long userId) { UserProfile profile = userProfileService.getMyProfile(userId); return ApiResponse.ok(profile == null ? null : UserProfileResponse.from(profile)); } + + @PatchMapping("/api/v1/me/profile") + public ApiResponse updateMyProfile( + @CurrentUser Long userId, @Valid @RequestBody UserProfileRequest request) { + UserProfile profile = userProfileService.update(userId, request); + return ApiResponse.ok(UserProfileResponse.from(profile)); + } } diff --git a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java index 2728a96..5e74a35 100644 --- a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -98,4 +98,27 @@ public static UserProfile create( profile.keywords = keywords; return profile; } + + public void update( + Region region, + Integer birthYear, + String employmentStatus, + String educationLevel, + String merryStatus, + String major, + String specialCondition, + Integer income, + String categories, + String keywords) { + this.region = region; + this.birthYear = birthYear; + this.employmentStatus = employmentStatus; + this.educationLevel = educationLevel; + this.merryStatus = merryStatus; + this.major = major; + this.specialCondition = specialCondition; + this.income = income; + this.categories = categories; + this.keywords = keywords; + } } diff --git a/src/main/java/com/bop/youthpick/user/exception/UserError.java b/src/main/java/com/bop/youthpick/user/exception/UserError.java index f5093b4..7518134 100644 --- a/src/main/java/com/bop/youthpick/user/exception/UserError.java +++ b/src/main/java/com/bop/youthpick/user/exception/UserError.java @@ -11,7 +11,8 @@ public enum UserError implements ErrorCode { // --- 사용자(user) --- USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), DUPLICATE_USERNAME(HttpStatus.CONFLICT, "U002", "이미 사용 중인 아이디입니다."), - PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 온보딩 프로필이 등록되어 있습니다."); + PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 온보딩 프로필이 등록되어 있습니다."), + PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "U004", "온보딩 프로필을 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/bop/youthpick/user/service/UserProfileService.java b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java index f8d9e7e..c07f95c 100644 --- a/src/main/java/com/bop/youthpick/user/service/UserProfileService.java +++ b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java @@ -69,6 +69,33 @@ public UserProfile submit(Long userId, UserProfileRequest request) { } } + @Transactional + public UserProfile update(Long userId, UserProfileRequest request) { + UserProfile profile = + userProfileRepository + .findByUserId(userId) + .orElseThrow(() -> new UserException(UserError.PROFILE_NOT_FOUND)); + + Region region = + regionRepository + .findById(request.regionCode()) + .orElseThrow(() -> new CustomException(PolicyErrorCode.REGION_NOT_FOUND)); + + profile.update( + region, + request.birthYear(), + request.employmentStatus(), + request.educationLevel(), + request.merryStatus(), + joinToCommaString(request.major()), + joinToCommaString(request.specialCondition()), + request.income(), + joinToCommaString(request.categories()), + joinToCommaString(request.keywords())); + + return profile; + } + private String joinToCommaString(List values) { if (values == null || values.isEmpty()) { return null; diff --git a/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java index e7882c3..c2962d2 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java +++ b/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java @@ -26,7 +26,7 @@ class RegionControllerTest { @Test void 지역_목록은_200과_전체_목록을_반환한다() throws Exception { Region region = Region.create("11110", "서울특별시", "종로구"); - when(regionRepository.findAll()).thenReturn(List.of(region)); + when(regionRepository.findAllByOrderBySidoNameAscNameAsc()).thenReturn(List.of(region)); mockMvc.perform(get("/api/v1/regions")) .andExpect(status().isOk()) diff --git a/src/test/java/com/bop/youthpick/policy/repository/RegionRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/RegionRepositoryTest.java new file mode 100644 index 0000000..c05c37b --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/RegionRepositoryTest.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.policy.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.policy.entity.Region; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DataJpaTest +class RegionRepositoryTest { + + @Autowired private RegionRepository regionRepository; + + @Test + void 시도명_시군구명_순으로_한글_가나다_정렬한다() { + regionRepository.save(Region.create("42000", "강원특별자치도", "강원특별자치도")); + regionRepository.save(Region.create("42150", "강원특별자치도", "춘천시")); + regionRepository.save(Region.create("42130", "강원특별자치도", "강릉시")); + regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + regionRepository.save(Region.create("11740", "서울특별시", "강동구")); + + List result = regionRepository.findAllByOrderBySidoNameAscNameAsc(); + + assertThat(result) + .extracting(Region::getSidoName, Region::getName) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("강원특별자치도", "강릉시"), + org.assertj.core.groups.Tuple.tuple("강원특별자치도", "강원특별자치도"), + org.assertj.core.groups.Tuple.tuple("강원특별자치도", "춘천시"), + org.assertj.core.groups.Tuple.tuple("서울특별시", "강동구"), + org.assertj.core.groups.Tuple.tuple("서울특별시", "서울특별시")); + } +} diff --git a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java index 2431953..55d46a6 100644 --- a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -180,6 +181,48 @@ void clearContext() { .andExpect(jsonPath("$.code").value("A001")); } + @Test + void 유효한_수정_요청이면_200과_수정된_프로필을_반환한다() throws Exception { + UserProfile profile = mock(UserProfile.class); + User user = mock(User.class); + Region region = mock(Region.class); + when(user.getId()).thenReturn(1L); + when(region.getCode()).thenReturn("11110"); + when(profile.getId()).thenReturn(10L); + when(profile.getUser()).thenReturn(user); + when(profile.getRegion()).thenReturn(region); + when(profile.getBirthYear()).thenReturn(2000); + when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); + when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); + when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); + when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); + when(profile.getIncome()).thenReturn(3000); + when(profile.getCategories()).thenReturn("취업,주거"); + when(profile.getKeywords()).thenReturn("청년,공모전"); + when(profile.getStatus()).thenReturn("COMPLETED"); + when(userProfileService.update(eq(1L), any())).thenReturn(profile); + authenticateAs(1L); + + mockMvc.perform( + patch("/api/v1/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(10)) + .andExpect(jsonPath("$.data.regionCode").value("11110")); + } + + @Test + void 미인증_수정_요청이면_401과_A001을_반환한다() throws Exception { + mockMvc.perform( + patch("/api/v1/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + /** addFilters=false로 시큐리티 필터를 건너뛰므로 SecurityContextHolder를 직접 채운다. */ private void authenticateAs(Long userId) { Authentication authentication = diff --git a/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java index 84ae0f2..fc2223f 100644 --- a/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java +++ b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java @@ -145,4 +145,60 @@ void setUp() { .extracting(ex -> ((CustomException) ex).getErrorCode()) .isEqualTo(PolicyErrorCode.REGION_NOT_FOUND); } + + @Test + void 프로필을_정상적으로_수정한다() { + UserProfile existing = + UserProfile.create( + mock(User.class), + mock(Region.class), + 1998, + "UNEMPLOYED", + "HIGHSCHOOL", + null, + null, + null, + null, + null, + null); + Region newRegion = mock(Region.class); + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.of(existing)); + when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.of(newRegion)); + + UserProfile result = userProfileService.update(USER_ID, REQUEST); + + assertThat(result).isSameAs(existing); + assertThat(result.getRegion()).isSameAs(newRegion); + assertThat(result.getBirthYear()).isEqualTo(REQUEST.birthYear()); + assertThat(result.getEmploymentStatus()).isEqualTo(REQUEST.employmentStatus()); + assertThat(result.getEducationLevel()).isEqualTo(REQUEST.educationLevel()); + assertThat(result.getMerryStatus()).isEqualTo(REQUEST.merryStatus()); + assertThat(result.getMajor()).isEqualTo("COMPUTER_SCIENCE"); + assertThat(result.getSpecialCondition()).isEqualTo("LOW_INCOME"); + assertThat(result.getIncome()).isEqualTo(REQUEST.income()); + assertThat(result.getCategories()).isEqualTo("취업,주거"); + assertThat(result.getKeywords()).isEqualTo("청년,공모전"); + } + + @Test + void 수정할_프로필이_없으면_PROFILE_NOT_FOUND_예외를_던진다() { + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userProfileService.update(USER_ID, REQUEST)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.PROFILE_NOT_FOUND); + } + + @Test + void 수정_시_존재하지_않는_지역코드면_REGION_NOT_FOUND_예외를_던진다() { + UserProfile existing = mock(UserProfile.class); + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.of(existing)); + when(regionRepository.findById(REQUEST.regionCode())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userProfileService.update(USER_ID, REQUEST)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.REGION_NOT_FOUND); + } } From 4e92e69623fb2d550b0a00f0493f9be298dc013e Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Tue, 21 Jul 2026 15:10:05 +0900 Subject: [PATCH 51/93] =?UTF-8?q?fix:=20=EC=A7=80=EC=97=AD=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=ED=95=9C=EA=B8=80=20=EA=B0=80=EB=82=98?= =?UTF-8?q?=EB=8B=A4=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A0=AC=20(#1?= =?UTF-8?q?17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegionRepository.findAllByOrderBySidoNameAscNameAsc()를 추가해 RegionController(온보딩 거주지역 선택지)와 AdminRegionService가 법정동코드 삽입 순서 대신 시도명 -> 시군구명 가나다순으로 응답하게 한다. Closes #115 From dc87ee93aa7f60b2e8954d35956a8043466966b3 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Tue, 21 Jul 2026 16:15:12 +0900 Subject: [PATCH 52/93] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=8B=9C=20=EC=A0=84=EA=B5=AD=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=9D=84=20=EB=92=A4=EB=A1=9C=20=EB=AF=B8=EB=A3=A8=EB=8A=94=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20(#120)=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 특정 시도를 골라도 전 시도를 커버하는 '전국' 정책이 최신순 상위를 차지해 그 지역 전용 정책이 묻히던 문제. 배치가 계산한 is_nationwide 파생 컬럼으로 지역 필터가 걸린 조회에만 nationwide asc를 앞세운다. - V13: policies.is_nationwide 파생 컬럼 + 기존 데이터 백필 - 배치가 policy_regions의 시도 수로 판정해 컬럼에 기록(추가 쿼리 없음) - PolicyService: 지역 무필터는 최신순 그대로, 시도 선택 시에만 정렬 추가 - #94에서 소비자가 사라진 죽은 countDistinctSidoNames 조회 제거 (목록 조회마다 쓰이지 않는 count 쿼리가 한 번씩 나가고 있었다) Claude-Session: https://claude.ai/code/session_01LJrujYeM5M3ALofEXB5mCZ Co-authored-by: Claude Opus 4.8 (1M context) --- .../bop/youthpick/policy/entity/Policy.java | 18 ++++++++++ .../policy/repository/RegionRepository.java | 5 --- .../policy/service/PolicyService.java | 24 +++++++++----- .../youthpick/sync/dto/PolicyUpsertItem.java | 5 ++- .../sync/service/PolicySyncService.java | 17 +++++++++- .../sync/service/PolicyUpsertWriter.java | 5 ++- .../V13__add_policies_is_nationwide.sql | 29 ++++++++++++++++ .../policy/service/PolicyServiceTest.java | 33 ++++++++++++------- .../sync/service/PolicySyncServiceTest.java | 28 ++++++++++++++++ .../sync/service/PolicyUpsertWriterTest.java | 14 +++++--- 10 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 src/main/resources/db/migration/V13__add_policies_is_nationwide.sql diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java index aa64933..76bd6d8 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Policy.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -239,8 +239,26 @@ public class Policy extends BaseEntity { @Column(name = "missing_count", nullable = false) private int missingCount; + /** + * [파생] 전 시도 커버 여부 (V13, #120). 지역 필터 조회에서 지역 특화 정책을 먼저 노출하기 위한 정렬 키다. + * + *

{@link #create} 파라미터에 넣지 않는다 — 전처리(PolicyMapper) 시점에는 zipCd가 아직 Region으로 해석되기 전이라 값을 알 수 + * 없다. {@link #updateFrom}에서도 복사하지 않는다 — source는 지역 정보가 없는 전처리 결과라 항상 false여서, 복사하면 갱신되는 정책이 전부 + * false로 덮인다. 반드시 {@link #applyRegionCoverage}로 지역 저장과 함께 갱신한다. + */ + @Column(name = "is_nationwide", nullable = false) + private boolean nationwide; + private static final int MISSING_THRESHOLD = 3; + /** + * 적용 지역 갱신과 반드시 함께 호출한다 — policy_regions와 어긋나면 정렬이 조용히 틀린다. 현재 유일한 호출 지점은 + * PolicyUpsertWriter.writeOne()이며, 지역 행 저장과 같은 트랜잭션 안에 있다. + */ + public void applyRegionCoverage(boolean nationwide) { + this.nationwide = nationwide; + } + /** * id 없는 새 비영속 복사본. 청크 트랜잭션이 롤백돼도 IDENTITY가 이미 부여한 id는 엔티티 객체에 남으므로, 건별 재시도는 원본 재사용 대신 이 복사본으로 * INSERT해야 한다. diff --git a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java index 612d6ff..6c9a2fd 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java @@ -3,14 +3,9 @@ import com.bop.youthpick.policy.entity.Region; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; public interface RegionRepository extends JpaRepository { - /** 시도 개수 — 정책이 전 시도를 커버하면 지역 라벨을 '전국'으로 접는 판정에 쓴다. */ - @Query("select count(distinct r.sidoName) from Region r") - long countDistinctSidoNames(); - /** 시도명 → 시군구명 순 한글 가나다 정렬. 온보딩/관리자 지역 선택지가 이 순서를 그대로 쓴다. */ List findAllByOrderBySidoNameAscNameAsc(); } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 7494950..080e6c8 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -9,7 +9,6 @@ import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; -import com.bop.youthpick.policy.repository.RegionRepository; import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -34,7 +33,6 @@ public class PolicyService { private final PolicyRepository policyRepository; private final PolicyRegionRepository policyRegionRepository; - private final RegionRepository regionRepository; private final PolicyRecentViewService policyRecentViewService; private static final String NATIONWIDE_REGION = "전국"; @@ -52,18 +50,15 @@ public Page getCards( @Nullable Integer ageMin, @Nullable Integer ageMax, Pageable pageable) { - Pageable sorted = - PageRequest.of( - pageable.getPageNumber(), - pageable.getPageSize(), - Sort.by(Sort.Direction.DESC, "id")); String categoryFilter = trimToNull(category); String keywordPattern = toLikePattern(keyword); String regionFilter = trimToNull(region); // '전국'은 지역 드롭다운의 "지역 무관" 선택지 — 시도명 필터를 걸지 않는다. // 전국 대상 정책은 policy_regions에 전 시도가 들어 있어 개별 시도 조회에도 이미 포함된다. String sidoName = NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter; - long totalSidoCount = regionRepository.countDistinctSidoNames(); + + Pageable sorted = + PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortFor(sidoName)); Page page = policyRepository.findCards( @@ -98,6 +93,19 @@ public Page getCards( .toList())); } + /** + * 목록 정렬. 지역 필터가 없으면 최신순만, 특정 시도를 고르면 전국 정책(is_nationwide)을 뒤로 밀어 그 지역 전용 정책이 먼저 보이게 한다. 전국 정책은 + * 어느 시도를 골라도 걸리므로 그대로 두면 지역 선택이 무의미해진다. + * + *

지역 무필터에는 적용하지 않는다 — 대상이 넓은 정책을 뒤로 미룰 이유가 없다. + */ + private static Sort sortFor(@Nullable String sidoName) { + Sort latestFirst = Sort.by(Sort.Direction.DESC, "id"); + return sidoName == null + ? latestFirst + : Sort.by(Sort.Order.asc("nationwide")).and(latestFirst); + } + @Nullable private static String trimToNull(@Nullable String value) { if (value == null) { diff --git a/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java b/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java index bc13e42..3aa691f 100644 --- a/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java +++ b/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java @@ -7,5 +7,8 @@ /** * Writer 입력 1건 — 전처리된 정책(비영속) + 적용 지역. 지역을 {@code PolicyRegion}이 아니라 {@code Region}으로 받는 이유: 변경 * UPDATE 경로에서는 링크를 DB의 관리 엔티티에 다시 걸어야 하므로 링크 생성은 Writer의 몫이다. + * + * @param nationwide {@code regions}가 전 시도를 커버하는지 (V13, #120). Job이 지역 마스터로 판정해 넘긴다 — Writer는 전체 시도 + * 수를 모르기 때문이다. */ -public record PolicyUpsertItem(Policy policy, List regions) {} +public record PolicyUpsertItem(Policy policy, List regions, boolean nationwide) {} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java index 7810fe3..1c997e9 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java @@ -125,6 +125,9 @@ private SyncPlan plan(List items) { Map regionsByCode = regionRepository.findAll().stream() .collect(Collectors.toMap(Region::getCode, Function.identity())); + // 전 시도 커버 판정 기준(#120). 이미 로드한 지역 마스터에서 세므로 추가 쿼리가 없다. + long totalSidoCount = + regionsByCode.values().stream().map(Region::getSidoName).distinct().count(); // 수집 도중 페이지 경계가 밀리면 같은 정책이 두 번 올 수 있다 — plcyNo 기준으로 접는다(먼저 온 것 유지) Set seenPolicyNos = new HashSet<>(); @@ -151,7 +154,8 @@ private SyncPlan plan(List items) { } List regions = policyMapper.resolveRegions(policyNo, item.zipCd(), regionsByCode); - upserts.add(new PolicyUpsertItem(policy, regions)); + upserts.add( + new PolicyUpsertItem(policy, regions, isNationwide(regions, totalSidoCount))); } // API 응답에 아예 안 나타난 정책만 누락 — SKIP(unchanged)된 정책은 존재하는 것 @@ -160,6 +164,17 @@ private SyncPlan plan(List items) { return new SyncPlan(upserts, missingPolicyNos, unchangedCount); } + /** + * 전 시도를 커버하면 전국 정책(#120). 온통청년 zipCd는 지역 한정 정책에도 전국 코드를 담는 경우가 있어, 조회 시 지역 특화 정책을 먼저 노출하려면 이 + * 구분이 필요하다. 지역 수가 아니라 시도 수로 판정한다 — 시군구 개수는 시도마다 달라 임계값을 정할 수 없기 때문이다. + */ + private static boolean isNationwide(List regions, long totalSidoCount) { + if (totalSidoCount <= 0 || regions.isEmpty()) { + return false; + } + return regions.stream().map(Region::getSidoName).distinct().count() >= totalSidoCount; + } + // 앞으로 Insert/Delete할 정책들(신규+변경), 앞으로 누락 처리 할 정책번호들, 아무것도 안할 건수(이력 기록용) private record SyncPlan( List upserts, List missingPolicyNos, int unchangedCount) {} diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java b/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java index 6723fa8..872e3b5 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java @@ -56,7 +56,8 @@ private ChunkResult retryOneByOne(List chunk) { String policyNo = item.policy().getPolicyNo(); // 실패한 청크에서 IDENTITY가 id를 부여한 채 롤백된 엔티티일 수 있어 재사용 금지 — 새 복사본으로 저장 PolicyUpsertItem freshItem = - new PolicyUpsertItem(Policy.copyOf(item.policy()), item.regions()); + new PolicyUpsertItem( + Policy.copyOf(item.policy()), item.regions(), item.nationwide()); try { boolean isNew = transactionTemplate.execute( @@ -117,6 +118,8 @@ private boolean writeOne(PolicyUpsertItem item, Policy existing) { for (var region : item.regions()) { policyRegionRepository.save(PolicyRegion.create(target, region)); } + // 지역 행과 같은 트랜잭션에서 갱신한다 — 따로 두면 둘이 어긋나 정렬이 조용히 틀린다(#120). + target.applyRegionCoverage(item.nationwide()); return isNew; } diff --git a/src/main/resources/db/migration/V13__add_policies_is_nationwide.sql b/src/main/resources/db/migration/V13__add_policies_is_nationwide.sql new file mode 100644 index 0000000..f8cc991 --- /dev/null +++ b/src/main/resources/db/migration/V13__add_policies_is_nationwide.sql @@ -0,0 +1,29 @@ +-- ============================================================ +-- V13 — policies.is_nationwide 추가 (#120) +-- 전 시도를 커버하는 정책 여부. 배치가 policy_regions와 함께 계산해 저장하는 파생 값이다. +-- +-- 용도: 지역 필터 조회 시 지역 특화 정책을 먼저 노출하고 전국 정책을 뒤로 보내는 정렬 키. +-- 온통청년 원본 zipCd가 지역 한정 정책에도 전국 코드를 담고 있어(예: 서산시 정책에 251개 코드), +-- 지역을 선택해도 전국 정책이 상단을 차지하는 문제가 있다. 목록에서 제외하면 해당 지역 청년이 +-- 신청 가능한 전국 정책을 못 보게 되므로, 숨기지 않고 순서만 뒤로 민다. +-- +-- 판정: 정책이 커버하는 시도 수 = regions의 전체 시도 수. 노출 대상 기준 단일 시도 1,331건 / +-- 전 시도 241건 / 중간(2~15시도) 7건인 이봉분포라 임계값 없이 '전 시도'로 가른다. +-- +-- ※ 인덱스는 만들지 않는다. 2값 저장 컬럼은 카디널리티가 낮아 옵티마이저가 거의 쓰지 않는다. +-- 필요해지면 (visibility, is_nationwide) 복합으로 검토한다. +-- ============================================================ + +ALTER TABLE policies + ADD COLUMN is_nationwide BOOLEAN NOT NULL DEFAULT FALSE + COMMENT '[파생] 전 시도 커버 여부 — 배치가 policy_regions 기준으로 계산' + AFTER missing_count; + +-- 기존 데이터 백필 — 배치 재실행 없이 즉시 정렬이 동작하도록 한다. +UPDATE policies p +SET p.is_nationwide = TRUE +WHERE (SELECT COUNT(DISTINCT r.sido_name) + FROM policy_regions pr + JOIN regions r ON r.code = pr.region_code + WHERE pr.policy_id = p.id) + >= (SELECT COUNT(DISTINCT sido_name) FROM regions); diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index 6e27250..28a6d21 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -22,7 +22,6 @@ import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; -import com.bop.youthpick.policy.repository.RegionRepository; import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -46,7 +45,6 @@ class PolicyServiceTest { @Mock private PolicyRepository policyRepository; @Mock private PolicyRegionRepository policyRegionRepository; - @Mock private RegionRepository regionRepository; @Mock private PolicyRecentViewService policyRecentViewService; private PolicyService policyService; @@ -55,10 +53,7 @@ class PolicyServiceTest { void setUp() { policyService = new PolicyService( - policyRepository, - policyRegionRepository, - regionRepository, - policyRecentViewService); + policyRepository, policyRegionRepository, policyRecentViewService); } @Test @@ -160,7 +155,6 @@ void setUp() { newPolicyRegion(allSido, "11680", "서울특별시", "강남구"), newPolicyRegion(allSido, "26110", "부산광역시", "중구"), newPolicyRegion(allSido, "27110", "대구광역시", "중구"))); - when(regionRepository.countDistinctSidoNames()).thenReturn(3L); Page page = policyService.getCards(null, null, null, null, null, PageRequest.of(0, 20)); @@ -193,7 +187,6 @@ void setUp() { isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - when(regionRepository.countDistinctSidoNames()).thenReturn(16L); policyService.getCards(null, "50% 지원_special!", null, null, null, PageRequest.of(0, 20)); @@ -213,13 +206,33 @@ void setUp() { isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - when(regionRepository.countDistinctSidoNames()).thenReturn(16L); policyService.getCards(null, null, "전국", null, null, PageRequest.of(0, 20)); assertThat(sidoNameCaptor.getValue()).isNull(); } + @Test + void 시도를_고르면_전국_정책을_뒤로_밀어_그_지역_전용_정책이_먼저_나오게_정렬한다() { + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + eq("세종특별자치시"), + isNull(), + isNull(), + pageableCaptor.capture())) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + policyService.getCards(null, null, "세종특별자치시", null, null, PageRequest.of(0, 20)); + + // nationwide=false(지역 전용)가 asc로 먼저, 같은 그룹 안에서는 최신순. + assertThat(pageableCaptor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Order.asc("nationwide"), Sort.Order.desc("id"))); + } + @Test void region이_시도명이면_그대로_시도명_필터로_전달한다() { ArgumentCaptor sidoNameCaptor = ArgumentCaptor.forClass(String.class); @@ -233,7 +246,6 @@ void setUp() { isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - when(regionRepository.countDistinctSidoNames()).thenReturn(16L); policyService.getCards(null, null, "서울특별시", null, null, PageRequest.of(0, 20)); @@ -254,7 +266,6 @@ void setUp() { ageMaxCaptor.capture(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - when(regionRepository.countDistinctSidoNames()).thenReturn(16L); policyService.getCards(null, null, null, 19, 34, PageRequest.of(0, 20)); diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java index 4db41c6..1b3a901 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java @@ -127,6 +127,34 @@ void cleanUp() { assertThat(gone.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); } + @Test + void 전_시도를_커버하는_정책만_전국으로_표시한다() throws IOException { + // 지역 마스터의 시도가 서울·부산 둘뿐인 상태 — 둘 다 걸린 정책이 '전국'이다. + regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + regionRepository.save(Region.create("11680", "서울특별시", "강남구")); + regionRepository.save(Region.create("26000", "부산광역시", "부산광역시")); + when(policyApiClient.fetchAll()) + .thenReturn( + List.of( + item( + "{\"plcyNo\":\"P-ALL\",\"plcyNm\":\"전국 정책\"," + + "\"zipCd\":\"11000,26000\"}"), + // 같은 시도 안에서 시군구만 여러 개 — 시도 수로 세므로 전국이 아니다. + item( + "{\"plcyNo\":\"P-SEOUL\",\"plcyNm\":\"서울 정책\"," + + "\"zipCd\":\"11000,11680\"}"), + item("{\"plcyNo\":\"P-NONE\",\"plcyNm\":\"지역 없는 정책\"}"))); + + policySyncService.runFullSync(); + + assertThat(policyRepository.findByPolicyNoIn(List.of("P-ALL")).getFirst().isNationwide()) + .isTrue(); + assertThat(policyRepository.findByPolicyNoIn(List.of("P-SEOUL")).getFirst().isNationwide()) + .isFalse(); + assertThat(policyRepository.findByPolicyNoIn(List.of("P-NONE")).getFirst().isNationwide()) + .isFalse(); + } + @Test void fetch가_실패하면_DB에_손대지_않고_FAILED_이력을_남긴_뒤_예외를_다시_던진다() { policyRepository.save(policy("P-KEEP", "건드리면 안 됨", null)); diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java index b153a3c..7820283 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java @@ -50,7 +50,7 @@ void cleanUp() { writer.upsertAll( List.of( new PolicyUpsertItem( - policy("R2026-001", "새 정책", null), List.of(seoul)))); + policy("R2026-001", "새 정책", null), List.of(seoul), false))); assertThat(result).isEqualTo(new PolicyWriteResult(1, 0, 0)); assertThat(policyRepository.findAll()) @@ -80,7 +80,8 @@ void cleanUp() { "R2026-001", "새 제목", LocalDateTime.of(2026, 7, 1, 0, 0)), - List.of(busan)))); + List.of(busan), + false))); assertThat(result).isEqualTo(new PolicyWriteResult(0, 1, 0)); assertThat(policyRepository.findAll()) @@ -102,9 +103,12 @@ void cleanUp() { PolicyWriteResult result = writer.upsertAll( List.of( - new PolicyUpsertItem(policy("R2026-001", "정상1", null), List.of()), - new PolicyUpsertItem(policy("R2026-002", null, null), List.of()), - new PolicyUpsertItem(policy("R2026-003", "정상2", null), List.of()))); + new PolicyUpsertItem( + policy("R2026-001", "정상1", null), List.of(), false), + new PolicyUpsertItem( + policy("R2026-002", null, null), List.of(), false), + new PolicyUpsertItem( + policy("R2026-003", "정상2", null), List.of(), false))); assertThat(result).isEqualTo(new PolicyWriteResult(2, 0, 1)); assertThat(policyRepository.findAll()) From 2bd85da277347920b1e6a0f8ae90d87e0e1a6a11 Mon Sep 17 00:00:00 2001 From: canfactor Date: Tue, 21 Jul 2026 16:58:57 +0900 Subject: [PATCH 53/93] =?UTF-8?q?refac:=20=EC=A0=95=EC=B1=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EA=B4=80=EB=A6=AC=20register=20=E2=86=92=20create=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20?= =?UTF-8?q?Javadoc=20=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refac: 정책 신청관리 register → create 네이밍 통일 및 Javadoc 주석 정리 - PolicyApplication.register()/PolicyApplicationService.register()/ PolicyApplicationController.register() → create()로 통일 (체크리스트 쪽 PolicyApplicationChecklist.create()와 네이밍 일치) - PolicyApplicationRegisterRequest → PolicyApplicationCreateRequest - 정책 신청관리/체크리스트 도메인의 설명용 // 주석을 Javadoc(/** */)으로 정리, Java/Spring 기본 개념을 나열하던 저가치 주석은 정리하면서 제거 Closes #90 * fix: dev 병합 후 남은 register→create 네이밍 잔재 정리 및 서식 정리 PolicyApplicationRepositoryTest는 이 PR 분기 이후 dev에 새로 추가된 테스트라 병합 시 자동으로 갱신되지 않았다. PolicyApplication.register()/ policyApplicationService.register() 호출을 create()로 맞췄다. * docs: 체크리스트 엔티티/응답 DTO에 남아있던 주석을 Javadoc으로 정리 PolicyApplicationChecklist 엔티티와 PolicyApplicationChecklistResponse.from()이 이 PR의 다른 파일들과 달리 // 주석으로 남아있던 것을 형제 파일(PolicyApplication, PolicyApplicationResponse)과 같은 Javadoc(/** */) 스타일로 맞췄다. 내용 변경 없음. * docs: PolicyErrorCode 주석 정리 범위를 신청관리 항목으로 좁힘 REGION_NOT_FOUND(user 도메인 전용), INVALID_COMPARISON_REQUEST(정책 비교 전용)는 신청관리(policy-application) 기능이 아니므로 Javadoc 변환 대상에서 제외하고 원래의 // 주석으로 되돌렸다. 나머지 6개 신청관리 전용 에러코드만 Javadoc 유지. 내용 변경 없음. * docs: REGION_NOT_FOUND 주석을 원래 상태(주석 없음)로 되돌림 P003(REGION_NOT_FOUND)은 최초 추가 시점(977a64f6)부터 주석이 없었다. 이번 PR 과정에서 Javadoc으로 바꿨다가 다시 // 주석으로 되돌리며 새 설명을 지어 붙였는데, 신청관리(policy-application) 범위 밖 코드에 불필요한 변경을 남기지 않도록 원래 상태(주석 없음)로 복원한다. P008(INVALID_COMPARISON_REQUEST)은 최초 작성자(한채연)가 남긴 // 주석 그대로라 원래 상태와 동일하므로 변경하지 않는다. * refac: 정책 신청관리 코드 중복 제거 (#118) - ApplicationStatus.VALUES_PATTERN을 추가해 PolicyApplicationCreateRequest와 PolicyApplicationController.changeStatus()의 @Pattern 화이트리스트 문자열을 하나로 통합(기존엔 두 곳에 동일 문자열이 하드코딩되어 드리프트 위험이 있었음) - PolicyApplicationChecklistService.findActiveApplication()이 더 이상 PolicyApplicationRepository를 직접 호출하지 않고 PolicyApplicationService.findActive()(package-private로 열어둠)를 재사용하도록 변경. 두 서비스가 같은 조회를 각자 구현하던 중복을 제거 - 위 변경에 맞춰 PolicyApplicationChecklistServiceTest의 mock을 PolicyApplicationRepository에서 PolicyApplicationService로 교체 Closes #116 --- .../PolicyApplicationChecklistController.java | 42 ++++++---- .../PolicyApplicationController.java | 84 ++++++++++--------- .../PolicyApplicationChecklistResponse.java | 6 +- ...licyApplicationCreateChecklistRequest.java | 4 +- .../dto/PolicyApplicationCreateRequest.java | 32 +++++++ .../dto/PolicyApplicationRegisterRequest.java | 33 -------- .../policy/dto/PolicyApplicationResponse.java | 10 ++- .../policy/entity/ApplicationStatus.java | 17 ++-- .../policy/entity/PolicyApplication.java | 46 +++++----- .../entity/PolicyApplicationChecklist.java | 32 ++++--- .../policy/exception/PolicyErrorCode.java | 46 ++++++---- .../PolicyApplicationChecklistRepository.java | 27 +++--- .../PolicyApplicationRepository.java | 21 +++-- .../PolicyApplicationChecklistService.java | 58 +++++++------ .../service/PolicyApplicationService.java | 83 +++++------------- ...icyApplicationChecklistControllerTest.java | 2 +- .../PolicyApplicationControllerTest.java | 22 ++--- .../PolicyApplicationRepositoryTest.java | 10 +-- ...PolicyApplicationChecklistServiceTest.java | 27 +++--- .../service/PolicyApplicationServiceTest.java | 61 +++++++------- 20 files changed, 336 insertions(+), 327 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateRequest.java delete mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java index 8561636..0c3871a 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java @@ -42,10 +42,12 @@ public class PolicyApplicationChecklistController { private final PolicyApplicationChecklistService checklistService; - // POST /api/v1/policy-application-checklists — 특정 신청관리(applicationId)에 체크리스트 항목 추가. - // request.message()가 PolicyApplicationChecklistService.add()의 message 인자로 그대로 넘어가고, - // 서비스 내부에서 PolicyApplicationChecklist.create()가 이걸 content 필드에 담는다 — DTO는 "message", - // 엔티티/리포지토리는 "content"로 이름이 다르니 헷갈리지 않도록 주의(아래 DTO 파일 주석 참고). + /** + * {@code POST /api/v1/policy-application-checklists} — 특정 신청관리(applicationId)에 체크리스트 항목 추가. + * {@code request.message()}가 {@link PolicyApplicationChecklistService#add}의 message 인자로 그대로 + * 넘어가고, 서비스 내부에서 {@code PolicyApplicationChecklist.create()}가 이걸 content 필드에 담는다 — DTO는 + * "message", 엔티티/리포지토리는 "content"로 이름이 다르니 헷갈리지 않도록 주의. + */ @PostMapping public ResponseEntity> add( @CurrentUser Long userId, @@ -56,9 +58,11 @@ public ResponseEntity> add( .body(ApiResponse.ok(PolicyApplicationChecklistResponse.from(checklist))); } - // GET /api/v1/policy-application-checklists/application/{applicationId} — 특정 신청관리에 달린 체크리스트 목록. - // PolicyApplicationChecklistService.getByApplication()이 먼저 findActiveApplication()으로 부모 신청관리의 - // 존재/소유권을 확인한 뒤(쿼리 1회), 체크리스트 목록을 id 오름차순(등록 순서)으로 조회한다(쿼리 1회) — 총 2쿼리. + /** + * {@code GET /api/v1/policy-application-checklists/application/{applicationId}} — 특정 신청관리에 달린 + * 체크리스트 목록. 서비스가 먼저 부모 신청관리의 존재/소유권을 확인한 뒤(쿼리 1회), 체크리스트 목록을 id 오름차순(등록 순서)으로 조회한다(쿼리 1회) — 총 + * 2쿼리. + */ @GetMapping("/application/{applicationId}") public ApiResponse> getByApplication( @CurrentUser Long userId, @@ -69,7 +73,7 @@ public ApiResponse> getByApplication( return ApiResponse.ok(page.getContent(), page); } - // PATCH /api/v1/policy-application-checklists/{id} — 체크리스트 내용(content) 수정. + /** {@code PATCH /api/v1/policy-application-checklists/{id}} — 체크리스트 내용(content) 수정. */ @PatchMapping("/{id}") public ApiResponse update( @CurrentUser Long userId, @@ -80,10 +84,12 @@ public ApiResponse update( return ApiResponse.ok(PolicyApplicationChecklistResponse.from(checklist)); } - // PATCH /api/v1/policy-application-checklists/{id}/check — 체크 표시. 몸값(body) 없이 상태만 토글하므로 - // 반환 데이터가 없고, PolicyApplicationChecklistMessageResponse로 성공 메시지만 내려준다 - // (PolicyApplicationController.delete()가 ApiResponse.ok(null)로 데이터 없음을 표현하는 것과 다른 스타일 — 여긴 메시지 - // body를 쓴다). + /** + * {@code PATCH /api/v1/policy-application-checklists/{id}/check} — 체크 표시. body 없이 상태만 토글하므로 반환 + * 데이터가 없고, {@link PolicyApplicationChecklistMessageResponse}로 성공 메시지만 내려준다({@link + * PolicyApplicationController#delete}가 {@code ApiResponse.ok(null)}로 데이터 없음을 표현하는 것과 다른 스타일 — + * 여긴 메시지 body를 쓴다). + */ @PatchMapping("/{id}/check") public ApiResponse check( @CurrentUser Long userId, @PathVariable Long id) { @@ -91,7 +97,7 @@ public ApiResponse check( return ApiResponse.ok(new PolicyApplicationChecklistMessageResponse("체크 완료")); } - // PATCH /api/v1/policy-application-checklists/{id}/uncheck — check()의 반대. + /** {@code PATCH /api/v1/policy-application-checklists/{id}/uncheck} — {@link #check}의 반대. */ @PatchMapping("/{id}/uncheck") public ApiResponse uncheck( @CurrentUser Long userId, @PathVariable Long id) { @@ -99,10 +105,12 @@ public ApiResponse uncheck( return ApiResponse.ok(new PolicyApplicationChecklistMessageResponse("체크 해제 완료")); } - // DELETE /api/v1/policy-application-checklists/{id} — 체크리스트 항목 하나만 소프트 삭제. - // 부모 신청관리 전체가 삭제/재등록될 때 체크리스트를 한 번에 정리하는 경로는 여기가 아니라 - // PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()이고, 그건 이 컨트롤러가 아니라 - // PolicyApplicationService.register()의 reactivate 분기에서 호출된다(다른 도메인 흐름과의 연결점). + /** + * {@code DELETE /api/v1/policy-application-checklists/{id}} — 체크리스트 항목 하나만 소프트 삭제. 부모 신청관리 전체가 + * 삭제/재등록될 때 체크리스트를 한 번에 정리하는 경로는 여기가 아니라 {@code + * PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()}이고, 그건 이 컨트롤러가 아니라 {@code + * PolicyApplicationService.create()}의 reactivate 분기에서 호출된다(다른 도메인 흐름과의 연결점). + */ @DeleteMapping("/{id}") public ApiResponse delete( @CurrentUser Long userId, @PathVariable Long id) { diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java index d3b322f..c6db9fa 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java @@ -3,7 +3,7 @@ import com.bop.youthpick.auth.service.CurrentUser; import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.policy.dto.PolicyApplicationRegisterRequest; +import com.bop.youthpick.policy.dto.PolicyApplicationCreateRequest; import com.bop.youthpick.policy.dto.PolicyApplicationResponse; import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.PolicyApplication; @@ -46,20 +46,18 @@ public class PolicyApplicationController { private final PolicyApplicationService policyApplicationService; - // POST /api/v1/policy-applications — "관심 등록" 액션의 진입점. - // @Valid가 PolicyApplicationRegisterRequest의 Bean Validation(@NotNull, @Pattern 등)을 먼저 통과시킨 뒤에만 - // 이 메서드 본문이 실행된다. - // status 문자열 → ApplicationStatus enum 변환은 parseStatus()를 거친다 — 평소엔 DTO의 - // @Pattern(PolicyApplicationRegisterRequest.java:12)이 걸러준 값만 들어오지만, 그 화이트리스트가 - // changeStatus()의 @Pattern과 별도로 중복 관리되는 탓에(ApplicationStatus.java 클래스 주석 참고) 어긋날 경우를 - // 대비해 parseStatus()가 500 대신 깔끔한 400(P007)으로 막아준다. - // 신규 등록 vs soft-delete 재활성화 vs 중복 예외 판단은 PolicyApplicationService.register()가 전담한다. + /** + * {@code POST /api/v1/policy-applications} — "관심 등록" 액션의 진입점. {@code status} 문자열 → {@link + * ApplicationStatus} 변환은 {@link #parseStatus}를 거친다 — 평소엔 DTO의 {@code @Pattern}(={@link + * ApplicationStatus#VALUES_PATTERN})이 걸러준 값만 들어오지만, 만에 하나 어긋날 경우를 대비해 {@link #parseStatus}가 500 + * 대신 깔끔한 400(P007)으로 막아준다. 신규 등록 vs soft-delete 재활성화 vs 중복 예외 판단은 {@link + * PolicyApplicationService#create}가 전담한다. + */ @PostMapping - public ResponseEntity> register( - @CurrentUser Long userId, - @Valid @RequestBody PolicyApplicationRegisterRequest request) { + public ResponseEntity> create( + @CurrentUser Long userId, @Valid @RequestBody PolicyApplicationCreateRequest request) { PolicyApplication application = - policyApplicationService.register( + policyApplicationService.create( userId, request.policyId(), parseStatus(request.status()), @@ -69,11 +67,12 @@ public ResponseEntity> register( .body(ApiResponse.ok(PolicyApplicationResponse.from(application))); } - // GET /api/v1/policy-applications — 내 정책 신청 목록 페이지 조회. - // page/size/totalPages를 컨트롤러가 직접 계산하지 않는다 — @PageableDefault(size=20)로 Spring Data가 Pageable을 - // 만들고, - // PolicyApplicationService.getApplications()가 이미 PolicyApplicationResponse로 변환된 Page를 돌려주므로 - // 여기선 그 Page를 (content, meta) 형태로 ApiResponse에 담기만 한다(api-design.md의 Pageable 규칙). + /** + * {@code GET /api/v1/policy-applications} — 내 정책 신청 목록 페이지 조회. page/size/totalPages는 컨트롤러가 직접 + * 계산하지 않는다 — {@link PolicyApplicationService#getApplications}가 이미 {@link + * PolicyApplicationResponse}로 변환된 {@code Page}를 돌려주므로, 여기선 그 Page를 (content, meta) 형태로 {@link + * ApiResponse}에 담기만 한다(api-design.md의 Pageable 규칙). + */ @GetMapping public ApiResponse> getApplications( @CurrentUser Long userId, @PageableDefault(size = 20) Pageable pageable) { @@ -82,29 +81,29 @@ public ApiResponse> getApplications( return ApiResponse.ok(page.getContent(), page); } - // PATCH /api/v1/policy-applications/{id}/status — 상태만 단독으로 바꾸는 엔드포인트. - // register()의 DTO와 같은 4개 상태값 화이트리스트를 여기서도 별도로 유지한다(위 register() 주석 참고 — 드리프트 위험 동일, - // parseStatus()가 같은 방식으로 방어한다). - // 소유권 검증은 여기가 아니라 PolicyApplicationService.changeStatus() 안의 application.verifyOwner()에서 한다 — - // id로 조회한 신청이 진짜 이 userId 소유인지는 서비스 계층 책임이다. + /** + * {@code PATCH /api/v1/policy-applications/{id}/status} — 상태만 단독으로 바꾸는 엔드포인트. {@link #create}의 + * DTO와 같은 {@link ApplicationStatus#VALUES_PATTERN} 화이트리스트를 공유한다({@link #parseStatus}가 방어선 역할은 + * 동일하게 유지). 소유권 검증은 여기가 아니라 {@link PolicyApplicationService#changeStatus} 안의 {@code + * verifyOwner()}에서 한다 — id로 조회한 신청이 진짜 이 userId 소유인지는 서비스 계층 책임이다. + */ @PatchMapping("/{id}/status") public ApiResponse changeStatus( @CurrentUser Long userId, @PathVariable Long id, @RequestParam @NotBlank(message = "상태는 필수입니다.") - @Pattern( - regexp = "INTERESTED|PREPARING|APPLIED|COMPLETED", - message = "유효하지 않은 상태값입니다.") + @Pattern(regexp = ApplicationStatus.VALUES_PATTERN, message = "유효하지 않은 상태값입니다.") String status) { PolicyApplication application = policyApplicationService.changeStatus(id, userId, parseStatus(status)); return ApiResponse.ok(PolicyApplicationResponse.from(application)); } - // PATCH /api/v1/policy-applications/{id}/memo — 메모만 단독 수정. - // 컨트롤러는 @Size로 길이만 검증하고, 빈 문자열/공백을 null로 통일하는 정규화는 - // PolicyApplicationService.blankToNull()(서비스 계층)이 담당한다 — 여기서 값을 가공하지 않는다. + /** + * {@code PATCH /api/v1/policy-applications/{id}/memo} — 메모만 단독 수정. 컨트롤러는 {@code @Size}로 길이만 + * 검증하고, 빈 문자열/공백을 null로 통일하는 정규화는 서비스 계층이 담당한다 — 여기서 값을 가공하지 않는다. + */ @PatchMapping("/{id}/memo") public ApiResponse updateMemo( @CurrentUser Long userId, @@ -115,10 +114,11 @@ public ApiResponse updateMemo( } /** - * memo와 달리 endAt은 생략 시 필수 에러가 아니라 마감일 초기화(clear)로 동작한다. LocalDateTime처럼 String이 아닌 타입은 Spring이 - * "파라미터 생략"과 "빈 문자열(endAt=)"을 바인딩 단계에서 이미 null로 합쳐 버려 required=true로는 이 둘을 구분할 수 없다(둘 다 "필수값 - * 없음" 에러가 됨) — 그래서 memo처럼 필수로 강제하지 않고 의도적으로 생략=초기화로 둔다. 정책 마감일을 넘는지 검증하는 로직은 - * PolicyApplicationService.updateEndAt() → resolveEndAt()/validateWithinPolicyDeadline()에 있다. + * {@code PATCH /api/v1/policy-applications/{id}/end-at} — memo와 달리 endAt은 생략 시 필수 에러가 아니라 마감일 + * 초기화(clear)로 동작한다. {@code LocalDateTime}처럼 String이 아닌 타입은 Spring이 "파라미터 생략"과 "빈 문자열(endAt=)"을 + * 바인딩 단계에서 이미 null로 합쳐 버려 required=true로는 이 둘을 구분할 수 없다(둘 다 "필수값 없음" 에러가 됨) — 그래서 memo처럼 필수로 + * 강제하지 않고 의도적으로 생략=초기화로 둔다. 정책 마감일을 넘는지 검증하는 로직은 {@link PolicyApplicationService#updateEndAt}에 + * 있다. */ @PatchMapping("/{id}/end-at") public ApiResponse updateEndAt( @@ -130,19 +130,23 @@ public ApiResponse updateEndAt( return ApiResponse.ok(PolicyApplicationResponse.from(application)); } - // DELETE /api/v1/policy-applications/{id} — 소프트 삭제(관심 해제). - // PolicyApplicationService.delete() → PolicyApplication.delete()가 deletedAt만 세팅한다(물리 삭제 아님, 재등록 - // 시 reactivate()로 되살아남). - // 반환할 데이터가 없어 ApiResponse.ok(null) — data는 null, meta 없음. + /** + * {@code DELETE /api/v1/policy-applications/{id}} — 소프트 삭제(관심 해제). 물리 삭제가 아니라 {@code + * deletedAt}만 세팅한다(재등록 시 reactivate로 되살아남). 반환할 데이터가 없어 {@code ApiResponse.ok(null)} — data는 + * null, meta 없음. + */ @DeleteMapping("/{id}") public ApiResponse delete(@CurrentUser Long userId, @PathVariable Long id) { policyApplicationService.delete(id, userId); return ApiResponse.ok(null); } - // register()/changeStatus() 공통 — ApplicationStatus.valueOf()를 직접 부르지 않고 이걸 거친다. - // 평소엔 @Pattern이 이미 걸러준 값만 들어와서 아무 차이가 없지만, 두 @Pattern과 enum 상수가 어긋나는 드리프트가 - // 생기면 IllegalArgumentException을 여기서 CustomException(P007)으로 바꿔, 처리 못 한 예외로 새서 500이 되는 걸 막는다. + /** + * {@link #create}/{@link #changeStatus} 공통 — {@code ApplicationStatus.valueOf()}를 직접 부르지 않고 이걸 + * 거친다. 평소엔 {@code @Pattern}(={@link ApplicationStatus#VALUES_PATTERN})이 이미 걸러준 값만 들어와서 아무 차이가 + * 없지만, 그 문자열 상수와 enum 상수가 어긋나는 드리프트가 생기면 {@code IllegalArgumentException}을 여기서 {@code + * CustomException(P007)}로 바꿔, 처리 못 한 예외로 새서 500이 되는 걸 막는다. + */ private ApplicationStatus parseStatus(String status) { try { return ApplicationStatus.valueOf(status); diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java index 6a9f837..b963995 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java @@ -15,8 +15,10 @@ public record PolicyApplicationChecklistResponse( boolean checked, LocalDateTime createdAt) { - // PolicyApplicationChecklistController.add()/update()와 - // PolicyApplicationChecklistService.getByApplication()(Page.map)에서 호출된다. + /** + * PolicyApplicationChecklistController.add()/update()와 + * PolicyApplicationChecklistService.getByApplication()(Page.map)에서 호출된다. + */ public static PolicyApplicationChecklistResponse from(PolicyApplicationChecklist checklist) { return new PolicyApplicationChecklistResponse( checklist.getId(), diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java index 779c980..ebb2df5 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateChecklistRequest.java @@ -7,8 +7,8 @@ /** * PolicyApplicationChecklistController.add()가 바인딩하는 요청 DTO. applicationId는 * PolicyApplicationChecklistService.findActiveApplication()이 PolicyApplicationRepository로 조회해 - * 존재/소유권을 확인하는 데 쓰이고, message는 PolicyApplicationChecklist.create()에서 content 필드에 담긴다 (register 쪽 - * 500자 제한은 PolicyApplicationRegisterRequest의 메모 2000자 제한보다 짧다 — 체크리스트 항목 하나의 길이 제약이라 더 짧게 잡혀 있다). + * 존재/소유권을 확인하는 데 쓰이고, message는 PolicyApplicationChecklist.create()에서 content 필드에 담긴다 (create 쪽 500자 + * 제한은 PolicyApplicationCreateRequest의 메모 2000자 제한보다 짧다 — 체크리스트 항목 하나의 길이 제약이라 더 짧게 잡혀 있다). */ public record PolicyApplicationCreateChecklistRequest( @NotNull(message = "신청관리 ID는 필수입니다.") Long applicationId, diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateRequest.java new file mode 100644 index 0000000..a003ed8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationCreateRequest.java @@ -0,0 +1,32 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.ApplicationStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +/** + * PolicyApplicationController.create()가 {@code @Valid @RequestBody}로 바인딩하는 요청 DTO. 검증을 통과한 필드들이 그대로 + * PolicyApplicationService.create()의 인자로 넘어간다(policyId, status(문자열→enum 변환은 컨트롤러의 parseStatus()에서), + * memo, endAt 순서 그대로). + * + * @param policyId PolicyApplicationService.create()에서 policyRepository.findById(policyId)로 조회된다 — + * 없으면 PolicyErrorCode.POLICY_NOT_FOUND. + * @param status enum이 아니라 문자열 + {@code @Pattern}인 이유: enum을 직접 바인딩하면 Jackson 역직렬화 단계에서 실패해 C001로 못 + * 내려간다(api-design.md). {@link ApplicationStatus#VALUES_PATTERN}을 참조해 + * PolicyApplicationController.changeStatus()의 {@code @Pattern}과 같은 화이트리스트를 공유한다. 이 화이트리스트와 + * enum이 어긋나도 컨트롤러의 parseStatus()가 IllegalArgumentException을 + * PolicyErrorCode.INVALID_APPLICATION_STATUS(P007)로 바꿔주므로 500까지 새지는 않는다. + * @param memo PolicyApplicationService.blankToNull()이 공백/빈 문자열을 null로 통일한다 — 여기 {@code @Size}는 길이만 + * 검증하고 트림은 하지 않는다. + * @param endAt 생략(null) 시 PolicyApplicationService.resolveEndAt()이 정책 자체의 신청 마감일로 기본값을 채운다. + */ +public record PolicyApplicationCreateRequest( + @NotNull(message = "정책 ID는 필수입니다.") Long policyId, + @NotBlank(message = "상태는 필수입니다.") + @Pattern(regexp = ApplicationStatus.VALUES_PATTERN, message = "유효하지 않은 상태값입니다.") + String status, + @Size(max = 2000, message = "메모는 2000자를 초과할 수 없습니다.") String memo, + LocalDateTime endAt) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java deleted file mode 100644 index e05c08f..0000000 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationRegisterRequest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.bop.youthpick.policy.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.time.LocalDateTime; - -/** - * PolicyApplicationController.register()가 {@code @Valid @RequestBody}로 바인딩하는 요청 DTO. 검증을 통과한 필드들이 - * 그대로 PolicyApplicationService.register()의 인자로 넘어간다(policyId, status(문자열→enum 변환은 컨트롤러의 - * parseStatus()에서), memo, endAt 순서 그대로). - */ -public record PolicyApplicationRegisterRequest( - // PolicyApplicationService.register()에서 policyRepository.findById(policyId)로 조회된다 — - // 없으면 PolicyErrorCode.POLICY_NOT_FOUND. - @NotNull(message = "정책 ID는 필수입니다.") Long policyId, - // enum이 아니라 문자열 + @Pattern인 이유: enum을 직접 바인딩하면 Jackson 역직렬화 단계에서 실패해 C001로 못 - // 내려간다(api-design.md). - // 이 화이트리스트 문자열은 ApplicationStatus enum 상수 이름과 정확히 일치해야 하고(ApplicationStatus.java 참고), - // PolicyApplicationController.changeStatus()의 @Pattern과도 중복 관리된다. 이 화이트리스트와 enum이 - // 어긋나도 컨트롤러의 parseStatus()가 IllegalArgumentException을 - // PolicyErrorCode.INVALID_APPLICATION_STATUS(P007)로 바꿔주므로 500까지 새지는 않는다. - @NotBlank(message = "상태는 필수입니다.") - @Pattern( - regexp = "INTERESTED|PREPARING|APPLIED|COMPLETED", - message = "유효하지 않은 상태값입니다.") - String status, - // PolicyApplicationService.blankToNull()이 공백/빈 문자열을 null로 통일한다 — 여기 @Size는 길이만 검증하고 트림은 하지 - // 않는다. - @Size(max = 2000, message = "메모는 2000자를 초과할 수 없습니다.") String memo, - // 생략(null) 시 PolicyApplicationService.resolveEndAt()이 정책 자체의 신청 마감일로 기본값을 채운다. - LocalDateTime endAt) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java index e1f2627..65a42e1 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java @@ -22,10 +22,12 @@ public record PolicyApplicationResponse( LocalDateTime endAt, LocalDateTime createdAt) { - // PolicyApplicationController의 register/changeStatus/updateMemo/updateEndAt과 - // PolicyApplicationService.getApplications()(Page.map)에서 호출된다. - // application.getPolicy()는 PolicyApplicationRepository.findByUser_IdAndDeletedAtIsNull()의 - // @EntityGraph(attributePaths = "policy") 덕분에 지연 로딩(N+1) 없이 이미 로드돼 있다. + /** + * PolicyApplicationController의 create/changeStatus/updateMemo/updateEndAt과 + * PolicyApplicationService.getApplications()(Page.map)에서 호출된다. {@code application.getPolicy()}는 + * {@code PolicyApplicationRepository.findByUser_IdAndDeletedAtIsNull()}의 + * {@code @EntityGraph(attributePaths = "policy")} 덕분에 지연 로딩(N+1) 없이 이미 로드돼 있다. + */ public static PolicyApplicationResponse from(PolicyApplication application) { var policy = application.getPolicy(); return new PolicyApplicationResponse( diff --git a/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java index 9a24ee4..3349fd0 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java @@ -3,11 +3,10 @@ /** * 정책 신청관리 상태. INTERESTED(관심)가 구 즐겨찾기 역할. PREPARING(준비중)은 관심과 신청 사이 서류 준비 단계. * - *

이 enum 상수 이름 4개는 코드 곳곳에 문자열로 하드코딩되어 있어 같이 맞춰야 한다: {@link PolicyApplication}의 - * {@code @Enumerated(EnumType.STRING)} 컬럼(DB엔 이 이름 그대로 저장됨), {@code - * PolicyApplicationRegisterRequest}의 {@code @Pattern(regexp = - * "INTERESTED|PREPARING|APPLIED|COMPLETED")}, {@code PolicyApplicationController.changeStatus()}의 - * 동일한 {@code @Pattern}. 상수를 추가/이름 변경할 때 이 세 곳을 빠뜨리면 유효한 값인데도 400으로 막힐 수 있다 — 다만 반대 + *

{@link PolicyApplication}의 {@code @Enumerated(EnumType.STRING)} 컬럼(DB엔 이 이름 그대로 저장됨)과, DB에 저장된 + * 문자열이 이 enum 상수 이름과 일치해야 한다. Bean Validation {@code @Pattern}이 필요한 곳({@code + * PolicyApplicationCreateRequest}, {@code PolicyApplicationController.changeStatus()})은 매번 같은 + * 화이트리스트 문자열을 다시 적지 않고 {@link #VALUES_PATTERN}을 참조한다 — 상수를 추가/이름 변경할 때 이 파일 하나만 고치면 된다. 반대 * 방향({@code @Pattern}이 못 걸러 {@code valueOf()}가 던지는 {@code IllegalArgumentException})은 {@code * PolicyApplicationController.parseStatus()}가 {@code CustomException(INVALID_APPLICATION_STATUS)}로 * 바꿔줘서 500으로 새지는 않는다. @@ -16,5 +15,11 @@ public enum ApplicationStatus { INTERESTED, PREPARING, APPLIED, - COMPLETED + COMPLETED; + + /** + * {@code @Pattern(regexp = ...)}은 어노테이션 속성이라 컴파일타임 상수만 받을 수 있어 {@code values()}로 동적 생성할 수 없다 — + * 이 리터럴이 이 enum의 상수 이름 4개와 어긋나지 않게 유지하는 건 여전히 사람의 책임이다. + */ + public static final String VALUES_PATTERN = "INTERESTED|PREPARING|APPLIED|COMPLETED"; } diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java index 686c071..55b10fe 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java @@ -67,9 +67,11 @@ public class PolicyApplication extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; - // 정적 팩토리 — PolicyApplicationService.register()의 "완전히 새로운 신청" 경로에서만 호출된다 - // (기존에 soft-delete된 행이 있으면 이걸 쓰지 않고 아래 reactivate()로 같은 행을 재사용한다). - public static PolicyApplication register( + /** + * 정적 팩토리 — {@link com.bop.youthpick.policy.service.PolicyApplicationService#create}의 "완전히 새로운 + * 신청" 경로에서만 호출된다(기존에 soft-delete된 행이 있으면 이걸 쓰지 않고 {@link #reactivate}로 같은 행을 재사용한다). + */ + public static PolicyApplication create( User user, Policy policy, ApplicationStatus status, String memo, LocalDateTime endAt) { PolicyApplication application = new PolicyApplication(); application.user = user; @@ -80,30 +82,29 @@ public static PolicyApplication register( return application; } - // PolicyApplicationService.changeStatus()에서 호출. setter 대신 의미 있는 메서드로 상태 변경(entity-jpa.md 규칙). - // save() 호출 없이 필드만 바꿔도 @Transactional 커밋 시 JPA dirty checking으로 자동 UPDATE된다. + /** + * setter 대신 의미 있는 메서드로 상태를 변경한다(entity-jpa.md 규칙). {@code save()} 호출 없이 필드만 바꿔도 + * {@code @Transactional} 커밋 시 JPA dirty checking으로 자동 UPDATE된다. + */ public void changeStatus(ApplicationStatus status) { this.status = status; } - // PolicyApplicationService.updateMemo()에서 호출. null 정규화(blankToNull)는 서비스 계층 책임이라 여기선 그대로 대입만 - // 한다. + /** null 정규화(공백/빈 문자열 → null)는 서비스 계층 책임이라 여기선 그대로 대입만 한다. */ public void updateMemo(String memo) { this.memo = memo; } - // PolicyApplicationService.updateEndAt()에서 호출. 정책 마감일 초과 검증(validateWithinPolicyDeadline)도 서비스 - // 책임 — - // 엔티티는 검증된 값을 그대로 받아 대입만 한다. + /** 정책 마감일 초과 검증은 서비스 책임이고, 엔티티는 검증된 값을 그대로 받아 대입만 한다. */ public void updateEndAt(LocalDateTime endAt) { this.endAt = endAt; } /** - * soft-delete된 행을 UNIQUE(user, policy) 충돌 없이 재등록한다. PolicyApplicationService.register()의 "기존 - * 신청이 있는데 삭제된 상태" 분기에서만 호출되며, status/memo/endAt을 과거 값과 무관하게 이번 요청 값으로 완전히 덮어쓴다. 리뷰 노트: 이 + * soft-delete된 행을 UNIQUE(user, policy) 충돌 없이 재등록한다. PolicyApplicationService.create()의 "기존 신청이 + * 있는데 삭제된 상태" 분기에서만 호출되며, status/memo/endAt을 과거 값과 무관하게 이번 요청 값으로 완전히 덮어쓴다. 리뷰 노트: 이 * 엔티티엔 @Version(낙관적 락)이 없어서, 같은 (user, policy)에 대해 동시에 재등록 요청이 들어오면 나중에 커밋되는 쪽이 조용히 덮어쓸 수 있다 — - * register()의 "새 INSERT" 경로가 DataIntegrityViolationException으로 명시적으로 막는 것과 달리 이 경로는 방어가 없다. + * create()의 "새 INSERT" 경로가 DataIntegrityViolationException으로 명시적으로 막는 것과 달리 이 경로는 방어가 없다. */ public void reactivate(ApplicationStatus status, String memo, LocalDateTime endAt) { this.status = status; @@ -112,23 +113,24 @@ public void reactivate(ApplicationStatus status, String memo, LocalDateTime endA this.deletedAt = null; } - // PolicyApplicationService.delete()에서 호출. 물리 DELETE가 아니라 deletedAt 타임스탬프만 세팅하는 소프트 삭제 — - // UNIQUE(user, policy) 제약은 유지한 채로 "삭제됨"만 표시해서, 나중에 register()의 reactivate() 경로가 이 행을 재사용할 수 있게 - // 한다. + /** + * 물리 DELETE가 아니라 {@code deletedAt} 타임스탬프만 세팅하는 소프트 삭제다. UNIQUE(user, policy) 제약은 유지한 채로 "삭제됨"만 + * 표시해서, 나중에 {@link #reactivate}가 이 행을 재사용할 수 있게 한다. + */ public void delete() { this.deletedAt = LocalDateTime.now(); } - // register()가 "새로 만들지 vs 재활성화할지"를 가르는 조건, findActive()류 조회 필터의 기준이 되는 판별 메서드. + /** {@link #create}가 "새로 만들지 vs 재활성화할지"를 가르는 조건이자, {@code findActive()}류 조회 필터의 기준이 된다. */ public boolean isDeleted() { return deletedAt != null; } - // 소유권 검증 — PolicyApplicationService(changeStatus/updateMemo/updateEndAt/delete)와 - // PolicyApplicationChecklistService(findActiveApplication/findActive, - // checklist.getApplication() 경유)가 - // 공통으로 사용한다. 원래 두 서비스에 토씨 하나 안 다른 private verifyOwner()가 각각 중복돼 있었는데, - // "이 신청의 소유자가 누구인가"는 PolicyApplication 자신이 답할 수 있는 질문이라 엔티티 메서드로 옮겼다. + /** + * 소유권 검증 — {@code PolicyApplicationService}(changeStatus/updateMemo/updateEndAt/delete)와 {@code + * PolicyApplicationChecklistService}가 공통으로 사용한다. 원래 두 서비스에 토씨 하나 안 다른 private verifyOwner()가 각각 + * 중복돼 있었는데, "이 신청의 소유자가 누구인가"는 이 엔티티 자신이 답할 수 있는 질문이라 엔티티 메서드로 옮겼다. + */ public void verifyOwner(Long userId) { if (!user.getId().equals(userId)) { throw new AuthException(AuthErrorCode.FORBIDDEN); diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java index 874bf98..e64f5af 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java @@ -41,21 +41,27 @@ public class PolicyApplicationChecklist extends BaseEntity { @JoinColumn(name = "application_id", nullable = false) private PolicyApplication application; - // 컬럼/필드명은 content이지만, 이걸 감싸는 DTO 쪽(PolicyApplicationCreateChecklistRequest 등)은 - // 전부 message라는 이름을 쓴다 — 같은 값인데 계층마다 이름이 달라서 코드를 오갈 때 헷갈리기 쉽다. + /** + * 컬럼/필드명은 content이지만, 이걸 감싸는 DTO 쪽(PolicyApplicationCreateChecklistRequest 등)은 전부 message라는 이름을 + * 쓴다 — 같은 값인데 계층마다 이름이 달라서 코드를 오갈 때 헷갈리기 쉽다. + */ @Column(columnDefinition = "TEXT", nullable = false) private String content; - // Lombok @Getter가 boolean 필드 checked에 대해 isChecked()를 생성한다 — - // PolicyApplicationChecklistResponse.from()이 이 getter로 값을 읽는다. + /** + * Lombok {@code @Getter}가 boolean 필드 checked에 대해 {@code isChecked()}를 생성한다 — + * PolicyApplicationChecklistResponse.from()이 이 getter로 값을 읽는다. + */ @Column(name = "is_checked", nullable = false) private boolean checked; @Column(name = "deleted_at") private LocalDateTime deletedAt; - // PolicyApplicationChecklistService.add()에서만 호출되는 정적 팩토리 — 새 항목은 항상 미체크(checked=false) 상태로 - // 시작한다. + /** + * PolicyApplicationChecklistService.add()에서만 호출되는 정적 팩토리 — 새 항목은 항상 미체크(checked=false) 상태로 + * 시작한다. + */ public static PolicyApplicationChecklist create(PolicyApplication application, String content) { PolicyApplicationChecklist checklist = new PolicyApplicationChecklist(); checklist.application = application; @@ -64,24 +70,26 @@ public static PolicyApplicationChecklist create(PolicyApplication application, S return checklist; } - // PolicyApplicationChecklistService.update()에서 호출. + /** PolicyApplicationChecklistService.update()에서 호출. */ public void updateContent(String content) { this.content = content; } - // PolicyApplicationChecklistService.check()에서 호출. + /** PolicyApplicationChecklistService.check()에서 호출. */ public void check() { this.checked = true; } - // PolicyApplicationChecklistService.uncheck()에서 호출. + /** PolicyApplicationChecklistService.uncheck()에서 호출. */ public void uncheck() { this.checked = false; } - // PolicyApplicationChecklistService.delete()에서 호출하는 개별 소프트 삭제. - // 여러 항목을 한 번에 지우는 경로(부모 신청관리 재등록 시)는 이 메서드를 쓰지 않고 - // PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()가 벌크 UPDATE로 처리한다. + /** + * PolicyApplicationChecklistService.delete()에서 호출하는 개별 소프트 삭제. 여러 항목을 한 번에 지우는 경로(부모 신청관리 재등록 + * 시)는 이 메서드를 쓰지 않고 PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()가 벌크 + * UPDATE로 처리한다. + */ public void delete() { this.deletedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java index 23efa3e..30c92f3 100644 --- a/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -8,29 +8,43 @@ @Getter @RequiredArgsConstructor public enum PolicyErrorCode implements ErrorCode { - // PolicyApplicationService.register()에서 policyRepository.findById(policyId)가 비었을 때 던진다. + /** PolicyApplicationService.create()에서 policyRepository.findById(policyId)가 비었을 때 던진다. */ POLICY_NOT_FOUND("P001", "일치하는 정책이 존재하지 않습니다.", HttpStatus.NOT_FOUND), - // PolicyApplicationService.register()에서 두 번 쓰인다: ①활성 상태인 기존 신청이 있을 때(중복 등록 시도), - // ②동시 요청으로 UNIQUE(user, policy) 제약을 위반한 DataIntegrityViolationException을 잡았을 때 — 같은 도메인 에러로 통일. + + /** + * PolicyApplicationService.create()에서 두 번 쓰인다: ①활성 상태인 기존 신청이 있을 때(중복 등록 시도), ②동시 요청으로 + * UNIQUE(user, policy) 제약을 위반한 DataIntegrityViolationException을 잡았을 때 — 같은 도메인 에러로 통일. + */ POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT), - // policy-application 기능이 아니라 user 도메인의 UserProfileService에서 쓰인다(관심 지역 코드 검증) — - // "정책" 접두어(P)를 쓰지만 실제로는 지역 코드 조회 실패에 재사용되는 코드다. + REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND), - // PolicyApplicationService.findActive(id)가 못 찾을 때(changeStatus/updateMemo/updateEndAt/delete 공통 - // 경로), - // PolicyApplicationChecklistService에서도 같은 목적으로 재사용된다. + + /** + * PolicyApplicationService.findActive(id)가 못 찾을 때(changeStatus/updateMemo/updateEndAt/delete 공통 + * 경로), PolicyApplicationChecklistService에서도 같은 목적으로 재사용된다. + */ POLICY_APPLICATION_NOT_FOUND("P004", "정책 관리 항목을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - // PolicyApplicationChecklistService 전용 — 이 파일이 속한 policy-application 핵심 - // 흐름(register/changeStatus 등)에선 쓰이지 않는다. + + /** + * PolicyApplicationChecklistService 전용 — 이 파일이 속한 policy-application 핵심 흐름(create/changeStatus + * 등)에선 쓰이지 않는다. + */ CHECKLIST_NOT_FOUND("P005", "체크리스트 항목을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - // PolicyApplicationService.validateWithinPolicyDeadline()에서 던진다 — register()의 resolveEndAt()과 - // updateEndAt()이 공통으로 이 검증을 거친다(개인 마감일이 정책 자체 마감일보다 늦으면 안 됨). + + /** + * PolicyApplicationService.validateWithinPolicyDeadline()에서 던진다 — create()의 resolveEndAt()과 + * updateEndAt()이 공통으로 이 검증을 거친다(개인 마감일이 정책 자체 마감일보다 늦으면 안 됨). + */ END_AT_AFTER_POLICY_DEADLINE("P006", "개인 마감일은 정책 신청 마감일을 넘을 수 없습니다.", HttpStatus.BAD_REQUEST), - // PolicyApplicationController.parseStatus()에서 던진다 — DTO/@RequestParam의 @Pattern이 이미 걸러주는 - // 값이라 평소엔 발생하지 않지만, ApplicationStatus enum과 두 곳의 @Pattern 문자열이 어긋나는 경우를 대비한 - // 방어선이다(ApplicationStatus.java 클래스 주석 참고). 이게 없으면 valueOf()의 IllegalArgumentException이 - // GlobalExceptionHandler의 Exception.class 핸들러로 떨어져 500(S001)으로 샌다. + + /** + * PolicyApplicationController.parseStatus()에서 던진다 — DTO/{@code @RequestParam}의 + * {@code @Pattern}이 이미 걸러주는 값이라 평소엔 발생하지 않지만, ApplicationStatus enum과 두 곳의 {@code @Pattern} + * 문자열이 어긋나는 경우를 대비한 방어선이다. 이게 없으면 valueOf()의 IllegalArgumentException이 GlobalExceptionHandler의 + * Exception.class 핸들러로 떨어져 500(S001)으로 샌다. + */ INVALID_APPLICATION_STATUS("P007", "유효하지 않은 상태값입니다.", HttpStatus.BAD_REQUEST), + // PolicyComparisonService.compare()에서 policyIds에 중복이 있을 때 던진다. 개수(2~3개) 제약은 컨트롤러의 // Bean Validation이 C001로 먼저 걸러주므로, 여기서는 "서로 다른 정책"이라는 의미적 제약만 담당한다. INVALID_COMPARISON_REQUEST("P008", "정책 비교는 서로 다른 정책을 선택해야 합니다.", HttpStatus.BAD_REQUEST); diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java index 0a60128..4fb713e 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java @@ -12,22 +12,27 @@ public interface PolicyApplicationChecklistRepository extends JpaRepository { - // PolicyApplicationChecklistService.findActive(id, userId)가 이걸 감싼다 — - // update/check/uncheck/delete 네 메서드가 모두 이 조회를 거친다. + /** + * PolicyApplicationChecklistService.findActive(id, userId)가 이걸 감싼다 — + * update/check/uncheck/delete 네 메서드가 모두 이 조회를 거친다. + */ Optional findByIdAndDeletedAtIsNull(Long id); - /** 체크리스트 노출 순서를 id 오름차순(등록 순서)으로 고정한다. */ - // PolicyApplicationChecklistService.getByApplication()에서 사용 — 목록 API의 실제 조회. + /** + * 체크리스트 노출 순서를 id 오름차순(등록 순서)으로 고정한다. PolicyApplicationChecklistService.getByApplication()에서 사용 + * — 목록 API의 실제 조회. + */ Page findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( Long applicationId, Pageable pageable); - // 이 리포지토리를 쓰는 다른 모든 메서드와 달리, 이건 PolicyApplicationChecklistService가 아니라 - // 옆 도메인 파일인 PolicyApplicationService.register()의 reactivate 분기에서 직접 호출된다 — - // 사용자가 관심 삭제했던 정책을 재등록하면, 그 신청에 달려있던 예전 체크리스트를 전부 무효화하기 위함이다. - // flushAutomatically = true라서, 직전에 PolicyApplication.reactivate()로 바뀐 변경분이 먼저 flush된 - // 뒤에 이 벌크 UPDATE가 실행된다(순서 보장). clearAutomatically는 안 켜져 있어서, 같은 트랜잭션 안에서 - // 이미 영속 상태로 로드해둔 PolicyApplicationChecklist가 있다면 이 벌크 UPDATE 이후에도 메모리 값은 - // 갱신되지 않는다 — 지금 호출 지점(register())은 체크리스트 엔티티를 따로 로드하지 않아서 문제되지 않는다. + /** + * 이 리포지토리를 쓰는 다른 모든 메서드와 달리, 이건 PolicyApplicationChecklistService가 아니라 옆 도메인 파일인 + * PolicyApplicationService.create()의 reactivate 분기에서 직접 호출된다 — 사용자가 관심 삭제했던 정책을 재등록하면, 그 신청에 + * 달려있던 예전 체크리스트를 전부 무효화하기 위함이다. {@code flushAutomatically = true}라서, 직전에 + * PolicyApplication.reactivate()로 바뀐 변경분이 먼저 flush된 뒤에 이 벌크 UPDATE가 실행된다(순서 보장). {@code + * clearAutomatically}는 안 켜져 있어서, 같은 트랜잭션 안에서 이미 영속 상태로 로드해둔 PolicyApplicationChecklist가 있다면 이 + * 벌크 UPDATE 이후에도 메모리 값은 갱신되지 않는다 — 지금 호출 지점(create())은 체크리스트 엔티티를 따로 로드하지 않아서 문제되지 않는다. + */ @Modifying(flushAutomatically = true) @Query( "UPDATE PolicyApplicationChecklist c SET c.deletedAt = CURRENT_TIMESTAMP " diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java index a9b668b..b563b5a 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java @@ -14,18 +14,25 @@ public interface PolicyApplicationRepository extends JpaRepository, JpaSpecificationExecutor { - // PolicyApplicationService.findActive(id)가 이걸 감싼다 — changeStatus/updateMemo/updateEndAt/delete가 - // 모두 이 메서드를 거쳐 "존재하지 않거나 이미 삭제된 id"를 POLICY_APPLICATION_NOT_FOUND로 통일해서 처리한다. + /** + * PolicyApplicationService.findActive(id)가 이걸 감싼다 — changeStatus/updateMemo/updateEndAt/delete가 + * 모두 이 메서드를 거쳐 "존재하지 않거나 이미 삭제된 id"를 POLICY_APPLICATION_NOT_FOUND로 통일해서 처리한다. + */ Optional findByIdAndDeletedAtIsNull(Long id); - // PolicyApplicationService.getApplications()(목록 조회)에서 사용. @EntityGraph로 policy 연관관계를 함께 - // fetch해서, - // PolicyApplicationResponse.from()이 policy.getTitle() 등을 부를 때 N+1 쿼리가 나지 않게 한다. + /** + * PolicyApplicationService.getApplications()(목록 조회)에서 사용. {@code @EntityGraph}로 policy 연관관계를 함께 + * fetch해서, PolicyApplicationResponse.from()이 policy.getTitle() 등을 부를 때 N+1 쿼리가 나지 않게 한다. + */ @EntityGraph(attributePaths = "policy") Page findByUser_IdAndDeletedAtIsNull(Long userId, Pageable pageable); - // @SQLRestriction("deleted_at IS NULL")은 모든 엔티티 조회에 붙을 수 있으므로, 재등록 판단은 native query로 - // 명시적으로 우회한다. soft-delete된 기존 행까지 포함해야 UNIQUE(user_id, policy_id) 충돌 없이 reactivate할 수 있다. + /** + * PolicyApplicationService.create()의 첫 줄에서 사용 — soft-delete 여부와 무관하게 (user, policy) 조합의 기존 행이 + * 있는지부터 확인한다(테이블의 UNIQUE(user_id, policy_id) 제약과 짝을 이루는 조회). {@code @SQLRestriction("deleted_at + * IS NULL")}은 모든 엔티티 조회에 붙을 수 있으므로, 재등록 판단은 native query로 명시적으로 우회한다. soft-delete된 기존 행까지 포함해야 + * UNIQUE(user_id, policy_id) 충돌 없이 reactivate할 수 있다. + */ @Query( value = "SELECT * FROM policy_applications WHERE user_id = :userId AND policy_id = :policyId LIMIT 1", diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java index ed92c8b..dbfffa5 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java @@ -6,7 +6,6 @@ import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; -import com.bop.youthpick.policy.repository.PolicyApplicationRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,18 +14,21 @@ /** * 정책 신청관리(PolicyApplication)에 딸린 준비 체크리스트의 비즈니스 로직. 체크리스트는 항상 부모 PolicyApplication을 통해서만 접근 가능하다 — - * applicationId 하나 없이 체크리스트만 단독 조회하는 API는 없다. PolicyApplicationRepository를 직접 참조하는 것도 그래서다(체크리스트 자체 - * 레포만으론 소유권을 못 가린다). + * applicationId 하나 없이 체크리스트만 단독 조회하는 API는 없다. 부모 신청관리 조회는 PolicyApplicationRepository를 이 서비스가 직접 다시 + * 부르지 않고 {@link PolicyApplicationService#findActive}를 재사용한다(체크리스트 자체 레포만으론 소유권을 못 가려 여전히 부모 조회가 + * 필요하지만, 그 조회 자체는 한 곳에서만 구현한다). */ @Service @RequiredArgsConstructor public class PolicyApplicationChecklistService { private final PolicyApplicationChecklistRepository applicationChecklistRepository; - private final PolicyApplicationRepository policyApplicationRepository; + private final PolicyApplicationService policyApplicationService; - // PolicyApplicationChecklistController.add()에서 호출. 먼저 findActiveApplication()으로 부모 신청관리가 - // 존재하고 요청자 소유인지 확인한 뒤에만 PolicyApplicationChecklist.create()로 새 항목을 만든다. + /** + * 먼저 {@link #findActiveApplication}으로 부모 신청관리가 존재하고 요청자 소유인지 확인한 뒤에만 {@link + * PolicyApplicationChecklist#create}로 새 항목을 만든다. + */ @Transactional public PolicyApplicationChecklist add(Long applicationId, Long userId, String message) { PolicyApplication application = findActiveApplication(applicationId, userId); @@ -36,8 +38,10 @@ public PolicyApplicationChecklist add(Long applicationId, Long userId, String me return applicationChecklistRepository.save(checklist); } - // PolicyApplicationChecklistController.update()에서 호출. save() 없이 필드만 바꿔도 @Transactional - // 커밋 시 dirty checking으로 자동 반영된다(PolicyApplication.changeStatus()와 같은 패턴). + /** + * {@code save()} 없이 필드만 바꿔도 {@code @Transactional} 커밋 시 dirty checking으로 자동 반영된다 ({@link + * PolicyApplication#changeStatus}와 같은 패턴). + */ @Transactional public PolicyApplicationChecklist update(Long id, Long userId, String message) { PolicyApplicationChecklist checklist = findActive(id, userId); @@ -45,29 +49,27 @@ public PolicyApplicationChecklist update(Long id, Long userId, String message) { return checklist; } - // PolicyApplicationChecklistController.check()에서 호출. @Transactional public void check(Long id, Long userId) { findActive(id, userId).check(); } - // PolicyApplicationChecklistController.uncheck()에서 호출. @Transactional public void uncheck(Long id, Long userId) { findActive(id, userId).uncheck(); } - // PolicyApplicationChecklistController.delete()에서 호출 — 항목 하나만 소프트 삭제. - // 부모 신청관리 전체가 삭제/재등록될 때 체크리스트를 일괄 정리하는 건 여기가 아니라 - // PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()이고, 그건 - // PolicyApplicationService.register()의 reactivate 분기에서 (이 서비스를 거치지 않고) 직접 호출된다. + /** + * 항목 하나만 소프트 삭제한다. 부모 신청관리 전체가 삭제/재등록될 때 체크리스트를 일괄 정리하는 건 여기가 아니라 {@link + * PolicyApplicationChecklistRepository#softDeleteAllByApplicationId}이고, 그건 {@link + * PolicyApplicationService#create}의 reactivate 분기에서 (이 서비스를 거치지 않고) 직접 호출된다. + */ @Transactional public void delete(Long id, Long userId) { findActive(id, userId).delete(); } - // PolicyApplicationChecklistController.getByApplication()에서 호출. 부모 신청관리 소유권 확인(1쿼리) 후 - // 체크리스트 목록을 id 오름차순으로 조회(1쿼리)해 PolicyApplicationChecklistResponse로 변환한다. + /** 부모 신청관리 소유권 확인(1쿼리) 후 체크리스트 목록을 id 오름차순으로 조회(1쿼리)해 응답 DTO로 변환한다. */ @Transactional(readOnly = true) public Page getByApplication( Long applicationId, Long userId, Pageable pageable) { @@ -78,25 +80,21 @@ public Page getByApplication( .map(PolicyApplicationChecklistResponse::from); } - // add()/getByApplication()이 공통으로 쓰는 "부모 신청관리가 존재하고, 삭제되지 않았고, 요청자 소유인지" 확인. - // PolicyApplicationService.findActive()와 거의 같은 조회(findByIdAndDeletedAtIsNull)를 이 서비스가 - // 별도로 다시 한다 — 두 서비스가 같은 Repository를 각자 호출하는 구조라 로직이 갈라져 있다. + /** + * {@link #add}/{@link #getByApplication}이 공통으로 쓰는 "부모 신청관리가 존재하고, 삭제되지 않았고, 요청자 소유인지" 확인. 존재/삭제 + * 여부 조회는 {@link PolicyApplicationService#findActive}에 위임하고, 여기선 체크리스트 쪽에서만 필요한 소유권 검증만 이어서 한다. + */ private PolicyApplication findActiveApplication(Long applicationId, Long userId) { - PolicyApplication application = - policyApplicationRepository - .findByIdAndDeletedAtIsNull(applicationId) - .orElseThrow( - () -> - new CustomException( - PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + PolicyApplication application = policyApplicationService.findActive(applicationId); application.verifyOwner(userId); return application; } - // update/check/uncheck/delete가 공통으로 쓰는 조회. 체크리스트 자신의 deletedAt뿐 아니라 - // checklist.getApplication().isDeleted()까지 한 번 더 확인하는 이유: 체크리스트는 안 지워졌는데 부모 - // PolicyApplication만 나중에 소프트 삭제된 경우를 걸러내기 위해서다(그런 상태의 체크리스트는 "고아"로 취급해 - // CHECKLIST_NOT_FOUND로 통일). + /** + * update/check/uncheck/delete가 공통으로 쓰는 조회. 체크리스트 자신의 {@code deletedAt}뿐 아니라 {@code + * checklist.getApplication().isDeleted()}까지 한 번 더 확인하는 이유: 체크리스트는 안 지워졌는데 부모 PolicyApplication만 + * 나중에 소프트 삭제된 경우를 걸러내기 위해서다(그런 상태의 체크리스트는 "고아"로 취급해 CHECKLIST_NOT_FOUND로 통일). + */ private PolicyApplicationChecklist findActive(Long id, Long userId) { PolicyApplicationChecklist checklist = applicationChecklistRepository diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java index 2d0f858..d5f5c26 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java @@ -22,23 +22,23 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -@Service // 서비스 계층 등록 -@RequiredArgsConstructor // final 필드에 대한 생성자를 별도로 생성하지 않고 Lombok이 자동으로 생성자를 생성해준다. +@Service +@RequiredArgsConstructor public class PolicyApplicationService { - // 사용자 정보,정책 신청 관리, 정책, 정책 신청 체크리스트의 객체를 참조한다. private final PolicyApplicationRepository policyApplicationRepository; private final UserRepository userRepository; private final PolicyRepository policyRepository; private final PolicyApplicationChecklistRepository policyApplicationChecklistRepository; - // 여러 DB 변경 작업을 하나로 묶어서 작업하는 에노테이션을 트랜잭션이라고 한다. 사용자가 정책 등록같은 행동을 취했을 때 작동하고 하나라도 오류 날 시 롤백해준다. - // register 메서드는 컨트롤러로부터 사용자 ID, 정책 ID, 상태(준비중같은 상태 말하는거임), 메모, 마감일을 매개변수로 전달받는다. - // existing은 policyApplicationRepository에게 사용자 id와 정책 id로 조회를 요청한 결과를 할당한다. - // 기존 정책 신청 내역 조회 결과가 없으면 null로 처리한다. - // 메모가 비어있거나 공백이면 null로 통일하고, 값이 있으면 원본 그대로 사용한다(내용 가공 없음). + /** + * 정책 신청관리를 새로 등록한다. 같은 (user, policy) 조합의 기존 행이 있으면 두 갈래로 나뉜다: soft-delete되지 않은 상태면 {@link + * PolicyErrorCode#POLICY_ALREADY_EXISTS}, soft-delete된 상태면 {@link + * PolicyApplication#reactivate}로 같은 행을 재활성화하고(상태/메모/마감일을 이번 요청 값으로 덮어씀) 기존에 연결돼 있던 체크리스트를 모두 + * 소프트삭제한다. + */ @Transactional - public PolicyApplication register( + public PolicyApplication create( Long userId, Long policyId, ApplicationStatus status, @@ -50,11 +50,6 @@ public PolicyApplication register( .orElse(null); String normalizedMemo = blankToNull(memo); - // 이 조건문은 기존 신청 내역이 존재하는 경우 이 조건문이 실행된다. - // 기존 신청 내역이 삭제되지 않았다면 예외를 발생시켜 이미 존재하는 정책이라고 반환한다. - // 만약에 삭제 내역이 있다면 reactivate를 통해 다시 활성화한다.(상태, 메모, 마감일등을 갱신한다. 기존 신청에 연결된 체크리스트(id)를 - // 소프트삭제한다.) - // 다시 활성화된 신청 정보를 반환한다. if (existing != null) { if (!existing.isDeleted()) { throw new CustomException(PolicyErrorCode.POLICY_ALREADY_EXISTS); @@ -63,34 +58,20 @@ public PolicyApplication register( policyApplicationChecklistRepository.softDeleteAllByApplicationId(existing.getId()); return existing; } - // user는 사용자 리포의 사용자 id를 조회한다. - // 사용자가 존재하지 않을 시 예외를 발생시켜 사용자를 찾을 수 없다고 반환한다. - // 조회된 사용자는 user에 할당한다. User user = userRepository .findById(userId) .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); - // policy는 정책 리포의 정책 id를 조회한다. - // 정책 조회가 되지 않는다면 예외를 발생시켜 정책을 찾을 수 없다고 반환한다. - // 정책 조회가 된다면 policy에 할당한다. Policy policy = policyRepository .findById(policyId) .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); - // PolicyApplication.register 정적 팩토리 메서드에 user, policy, status, memo, endAt을 인자로 넘겨 새 - // PolicyApplication 객체를 만든다. - // 생성된 객체를 application 변수에 할당한다. PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( user, policy, status, normalizedMemo, resolveEndAt(endAt, policy)); - // 리포 save 메서드에 application 정보를 저장한다. - // 저장된 정책 신청 객체를 반환한다. try { return policyApplicationRepository.save(application); - - // 동시에 같은 정책 신청 요청이 들어와 유니크 제약조건 위반이 발생한 경우 - // 중복 신청 예외로 변환한다. } catch (DataIntegrityViolationException e) { // including-deleted 조회 확인 이후 동시 요청이 먼저 저장한 경우 // (uk_policy_applications_user_policy UNIQUE 위반). 같은 도메인 에러로 통일한다. @@ -98,12 +79,7 @@ public PolicyApplication register( } } - // 여러 DB 작업들을 하나로 묶어서 작업하는 걸 트랜잭션이라고 한다 오류가 하나라도 발생하면 롤백한다. - // changeStatus 메서드는 컨트롤러로부터 PK id, 사용자 id, 상태를 매개변수로 전달받는다. - // application은 findActive(id)로 삭제되지 않은 신청 내역을 조회한 결과를 할당한다. - // application.verifyOwner(userId)로 해당 정책 신청이 요청한 사용자의 것이 맞는지 확인한다. - // application.changeStatus(status)로 상태를 변경한다(트랜잭션 커밋 시 자동으로 DB에 반영되고, 별도 save 호출은 필요 없다). 변경된 - // application을 반환한다. + /** 신청관리 항목의 상태만 단독으로 변경한다. 소유권 검증 후 변경하며, 트랜잭션 커밋 시 dirty checking으로 자동 반영된다. */ @Transactional public PolicyApplication changeStatus(Long id, Long userId, ApplicationStatus status) { PolicyApplication application = findActive(id); @@ -112,11 +88,7 @@ public PolicyApplication changeStatus(Long id, Long userId, ApplicationStatus st return application; } - // 여러 DB 작업들을 하나로 묶어서 작업하는 걸 트랜잭션이라고한다. 하나라도 오류 날 시 변경 값들을 롤백한다. - // updateMemo 메서드는 컨트롤러로부터 PK id, 사용자 id, 메모를 매개변수로 전달받는다. - // application은 findActive(id)로 삭제되지 않은 신청 내역을 조회하고, application.verifyOwner(userId)로 요청자가 소유자인지 - // 확인한다. - + /** 신청관리 항목의 개인 메모만 단독으로 수정한다. 공백/빈 문자열은 {@link #blankToNull}로 null 정규화한다. */ @Transactional public PolicyApplication updateMemo(Long id, Long userId, String memo) { PolicyApplication application = findActive(id); @@ -125,12 +97,7 @@ public PolicyApplication updateMemo(Long id, Long userId, String memo) { return application; } - // 여러 DB 변경 작업들을 하나로 묶어서 처리하는 걸 트랜잭션이라고 한다. 하나라도 오류 날 시 변경값을 롤백한다. - // updateEndAt 메서드는 컨트롤러로부터 pk, 사용자 id, 마감일(endAt)을 매개변수로 전달받는다. - // application은 findActive(id)로 삭제되지 않은 신청 내역을 조회한 결과를 할당한다. - // application.verifyOwner(userId)로 해당 신청이 요청한 사용자의 것이 맞는지 확인한다. - // 마감일이 null이 아니면 정책 자체의 신청 마감일을 넘지 않는지 검증한다. - // application.updateEndAt(endAt)으로 마감일을 갱신하고, 변경된 application을 반환한다. + /** 신청관리 항목의 개인 마감일만 단독으로 수정한다. null이 아니면 정책 자체의 신청 마감일을 넘지 않는지 검증한다. */ @Transactional public PolicyApplication updateEndAt(Long id, Long userId, LocalDateTime endAt) { PolicyApplication application = findActive(id); @@ -142,10 +109,7 @@ public PolicyApplication updateEndAt(Long id, Long userId, LocalDateTime endAt) return application; } - // 여러 DB 변경 작업들을 하나로 묶어서 작업하는 에노테이션을 트랜잭션이라고한다. 리드온리는 오직 읽기만 가능하다. - // getApplications 메서드는 컨트롤러로부터 사용자 id와 Pageable을 매개변수로 전달받는다. - // policyApplicationRepository에게 해당 사용자의 삭제되지 않은 정책 신청 목록을 조회한다. - // 조회 결과를 PolicyApplicationResponse DTO로 변환하여 Page 형태로 반환한다. + /** 로그인 사용자의 삭제되지 않은 신청관리 목록을 페이지로 조회한다. */ @Transactional(readOnly = true) public Page getApplications(Long userId, Pageable pageable) { return policyApplicationRepository @@ -153,12 +117,7 @@ public Page getApplications(Long userId, Pageable pag .map(PolicyApplicationResponse::from); } - // 여러 DB 작업들을 하나로 묶어서 작업하는 트랜잭션 - // 사용자가 삭제 할 때 PK ID, 사용자 ID등을 매개변수를 통해 전달받는다. - // application은 pk id가 삭제되지 않았는지 조회한다. - // 해당 삭제를 사용자가 한게 맞는지 사용자 ID를 확인한다. - // 다른 사람 신청이면 권한 예외를 발생시킨다. - // 정책 신청을 삭제 처리한다. + /** 신청관리 항목을 소프트 삭제(관심 해제)한다. 소유권 검증 후 처리한다. */ @Transactional public void delete(Long id, Long userId) { PolicyApplication application = findActive(id); @@ -166,22 +125,24 @@ public void delete(Long id, Long userId) { application.delete(); } - // 해당 ID가 지워지지 않았는지 확인한다. - // 리포에게 PK ID를 확인하여 존재하지 않으면 정책 신청이 존재하지 않는다고 에러를 퍼트린다. - private PolicyApplication findActive(Long id) { + /** + * id로 조회하되 soft-delete된 행은 제외한다. 없으면 {@link PolicyErrorCode#POLICY_APPLICATION_NOT_FOUND}. 접근 + * 제한자가 package-private인 이유: 같은 패키지의 {@link PolicyApplicationChecklistService}도 부모 신청관리를 조회할 때 이 + * 메서드를 그대로 재사용한다(두 서비스가 각자 같은 Repository 조회를 중복 구현하던 것을 여기로 합쳤다). + */ + PolicyApplication findActive(Long id) { return policyApplicationRepository .findByIdAndDeletedAtIsNull(id) .orElseThrow( () -> new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); } - // 메모가 비어있거나 공백이면 null로 통일하고, 값이 있으면 원본 그대로(가공 없이) 반환하는 메서드 + /** 메모가 비어있거나 공백이면 null로 통일하고, 값이 있으면 원본 그대로(가공 없이) 반환한다. */ private static String blankToNull(String memo) { return StringUtils.hasText(memo) ? memo : null; } /** 개인이 마감일을 지정하지 않으면(null) 정책 자체의 신청 마감일을 기본값으로 잡는다. 직접 지정한 경우엔 정책 마감일을 넘지 않는지 검증한다. */ - // 정책 마감일을 확인하는 코드 private static LocalDateTime resolveEndAt(LocalDateTime requestedEndAt, Policy policy) { if (requestedEndAt == null) { return policy.getApplicationEndDate() != null diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java index a1fccfe..64e0d89 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistControllerTest.java @@ -46,7 +46,7 @@ class PolicyApplicationChecklistControllerTest { private PolicyApplicationChecklist checklist() { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.APPLIED, diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java index aab8645..ccbf684 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java @@ -51,13 +51,13 @@ class PolicyApplicationControllerTest { @Test void 유효한_요청이면_201과_등록된_신청관리를_반환한다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, "메모", null); - when(policyApplicationService.register( + when(policyApplicationService.create( eq(1L), eq(2L), eq(ApplicationStatus.INTERESTED), any(), any())) .thenReturn(application); @@ -83,13 +83,13 @@ class PolicyApplicationControllerTest { @Test void 등록시_PREPARING도_유효한_상태값이다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.PREPARING, null, null); - when(policyApplicationService.register( + when(policyApplicationService.create( eq(1L), eq(2L), eq(ApplicationStatus.PREPARING), any(), any())) .thenReturn(application); @@ -193,7 +193,7 @@ class PolicyApplicationControllerTest { @Test void changeStatus_유효한_요청이면_200과_변경된_상태를_반환한다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.APPLIED, @@ -216,7 +216,7 @@ class PolicyApplicationControllerTest { @Test void changeStatus_PREPARING도_유효한_상태값이다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.PREPARING, @@ -257,7 +257,7 @@ class PolicyApplicationControllerTest { @Test void updateMemo_유효한_요청이면_200과_수정된_메모를_반환한다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, @@ -300,7 +300,7 @@ class PolicyApplicationControllerTest { @Test void updateMemo_빈_문자열이면_메모를_비운다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, @@ -320,7 +320,7 @@ class PolicyApplicationControllerTest { @Test void updateEndAt_유효한_요청이면_200과_수정된_마감일을_반환한다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, @@ -340,7 +340,7 @@ class PolicyApplicationControllerTest { @Test void updateEndAt_빈_문자열이면_마감일을_비운다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, @@ -361,7 +361,7 @@ class PolicyApplicationControllerTest { @Test void updateEndAt_파라미터를_생략해도_마감일을_비운다() throws Exception { PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java index 792bf53..b00f75d 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationRepositoryTest.java @@ -38,7 +38,7 @@ class PolicyApplicationRepositoryTest { entityManager.persistAndFlush(policy); PolicyApplication application = - PolicyApplication.register(user, policy, ApplicationStatus.INTERESTED, null, null); + PolicyApplication.create(user, policy, ApplicationStatus.INTERESTED, null, null); entityManager.persistAndFlush(application); application.delete(); entityManager.flush(); @@ -61,16 +61,16 @@ class PolicyApplicationRepositoryTest { ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); entityManager.persistAndFlush(policy); - PolicyApplication registered = - policyApplicationService.register( + PolicyApplication created = + policyApplicationService.create( user.getId(), policy.getId(), ApplicationStatus.INTERESTED, null, null); - Long applicationId = registered.getId(); + Long applicationId = created.getId(); policyApplicationService.delete(applicationId, user.getId()); entityManager.flush(); entityManager.clear(); PolicyApplication reactivated = - policyApplicationService.register( + policyApplicationService.create( user.getId(), policy.getId(), ApplicationStatus.INTERESTED, null, null); assertThat(reactivated.getId()).isEqualTo(applicationId); diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java index 7827787..d9c2cb3 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java @@ -17,7 +17,6 @@ import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; -import com.bop.youthpick.policy.repository.PolicyApplicationRepository; import com.bop.youthpick.user.entity.User; import java.util.List; import java.util.Optional; @@ -35,7 +34,7 @@ class PolicyApplicationChecklistServiceTest { @Mock private PolicyApplicationChecklistRepository applicationChecklistRepository; - @Mock private PolicyApplicationRepository policyApplicationRepository; + @Mock private PolicyApplicationService policyApplicationService; private PolicyApplicationChecklistService checklistService; @@ -47,22 +46,21 @@ class PolicyApplicationChecklistServiceTest { void setUp() { checklistService = new PolicyApplicationChecklistService( - applicationChecklistRepository, policyApplicationRepository); + applicationChecklistRepository, policyApplicationService); } /** 소유자가 USER_ID인 활성 신청. owner.getId()는 소유권 검증에 안 걸리는 테스트에선 안 쓰일 수 있어 lenient로 둔다. */ private PolicyApplication application() { User owner = mock(User.class); lenient().when(owner.getId()).thenReturn(USER_ID); - return PolicyApplication.register( + return PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.APPLIED, null, null); } @Test void add_체크리스트를_정상적으로_생성한다() { PolicyApplication application = application(); - when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) - .thenReturn(Optional.of(application)); + when(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(application); when(applicationChecklistRepository.save(any(PolicyApplicationChecklist.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -75,8 +73,8 @@ private PolicyApplication application() { @Test void add_대상_신청관리가_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { - when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) - .thenReturn(Optional.empty()); + when(policyApplicationService.findActive(APPLICATION_ID)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); assertThatThrownBy(() -> checklistService.add(APPLICATION_ID, USER_ID, "제출 서류 준비")) .isInstanceOf(CustomException.class) @@ -87,8 +85,7 @@ private PolicyApplication application() { @Test void add_소유자가_아니면_FORBIDDEN_예외를_던진다() { PolicyApplication application = application(); - when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) - .thenReturn(Optional.of(application)); + when(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(application); assertThatThrownBy(() -> checklistService.add(APPLICATION_ID, OTHER_USER_ID, "제출 서류 준비")) .isInstanceOf(AuthException.class) @@ -310,8 +307,7 @@ private PolicyApplication application() { PolicyApplicationChecklist.create(application, "제출 서류 준비"); Pageable pageable = PageRequest.of(0, 20); Page page = new PageImpl<>(List.of(checklist), pageable, 1); - when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) - .thenReturn(Optional.of(application)); + when(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(application); when(applicationChecklistRepository.findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( APPLICATION_ID, pageable)) .thenReturn(page); @@ -325,8 +321,8 @@ private PolicyApplication application() { @Test void getByApplication_대상_신청관리가_없으면_POLICY_APPLICATION_NOT_FOUND_예외를_던진다() { - when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) - .thenReturn(Optional.empty()); + when(policyApplicationService.findActive(APPLICATION_ID)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); assertThatThrownBy( () -> @@ -340,8 +336,7 @@ private PolicyApplication application() { @Test void getByApplication_소유자가_아니면_FORBIDDEN_예외를_던진다() { PolicyApplication application = application(); - when(policyApplicationRepository.findByIdAndDeletedAtIsNull(APPLICATION_ID)) - .thenReturn(Optional.of(application)); + when(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(application); assertThatThrownBy( () -> diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java index b40aee9..61d41da 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java @@ -71,7 +71,7 @@ void setUp() { .thenAnswer(invocation -> invocation.getArgument(0)); PolicyApplication result = - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", null); assertThat(result.getStatus()).isEqualTo(ApplicationStatus.INTERESTED); @@ -91,7 +91,7 @@ void setUp() { .thenAnswer(invocation -> invocation.getArgument(0)); PolicyApplication result = - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", null); assertThat(result.getEndAt()).isEqualTo(LocalDateTime.of(2026, 8, 31, 0, 0)); @@ -110,7 +110,7 @@ void setUp() { LocalDateTime explicitEndAt = LocalDateTime.of(2026, 9, 15, 18, 0); PolicyApplication result = - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", explicitEndAt); assertThat(result.getEndAt()).isEqualTo(explicitEndAt); @@ -130,7 +130,7 @@ void setUp() { LocalDateTime sameDayEndAt = LocalDateTime.of(2026, 8, 31, 23, 59); PolicyApplication result = - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, "메모", sameDayEndAt); assertThat(result.getEndAt()).isEqualTo(sameDayEndAt); @@ -149,7 +149,7 @@ void setUp() { LocalDateTime tooLateEndAt = LocalDateTime.of(2026, 9, 1, 0, 0); assertThatThrownBy( () -> - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, @@ -171,7 +171,7 @@ void setUp() { .thenAnswer(invocation -> invocation.getArgument(0)); PolicyApplication result = - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, " ", null); assertThat(result.getMemo()).isNull(); @@ -180,7 +180,7 @@ void setUp() { @Test void 이미_등록된_행이_있으면_POLICY_ALREADY_EXISTS_예외를_던진다() { PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, @@ -192,7 +192,7 @@ void setUp() { assertThatThrownBy( () -> - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.APPLIED, null, null)) .isInstanceOf(CustomException.class) .extracting(ex -> ((CustomException) ex).getErrorCode()) @@ -202,7 +202,7 @@ void setUp() { @Test void soft_delete된_행이_있으면_재활성화한다() { PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, @@ -214,7 +214,7 @@ void setUp() { .thenReturn(Optional.of(existing)); PolicyApplication result = - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); assertThat(result).isSameAs(existing); @@ -228,7 +228,7 @@ void setUp() { Policy policy = mock(Policy.class); when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 10, 1)); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), policy, ApplicationStatus.INTERESTED, null, null); existing.delete(); when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( @@ -236,7 +236,7 @@ void setUp() { .thenReturn(Optional.of(existing)); PolicyApplication result = - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); assertThat(result.getEndAt()).isEqualTo(LocalDateTime.of(2026, 10, 1, 0, 0)); @@ -245,7 +245,7 @@ void setUp() { @Test void soft_delete된_행을_재활성화하면_이전_체크리스트를_모두_소프트딜리트한다() { PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), mock(Policy.class), ApplicationStatus.INTERESTED, @@ -256,8 +256,7 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.of(existing)); - policyApplicationService.register( - USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); + policyApplicationService.create(USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); verify(policyApplicationChecklistRepository).softDeleteAllByApplicationId(existing.getId()); } @@ -275,7 +274,7 @@ void setUp() { assertThatThrownBy( () -> - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, @@ -295,7 +294,7 @@ void setUp() { assertThatThrownBy( () -> - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, @@ -316,7 +315,7 @@ void setUp() { assertThatThrownBy( () -> - policyApplicationService.register( + policyApplicationService.create( USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, @@ -332,7 +331,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -362,7 +361,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -382,7 +381,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, "기존 메모", null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -397,7 +396,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, "기존 메모", null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -423,7 +422,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -440,7 +439,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -456,7 +455,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, @@ -477,7 +476,7 @@ void setUp() { Policy policy = mock(Policy.class); when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 8, 31)); PolicyApplication existing = - PolicyApplication.register(owner, policy, ApplicationStatus.INTERESTED, null, null); + PolicyApplication.create(owner, policy, ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -495,7 +494,7 @@ void setUp() { when(owner.getId()).thenReturn(USER_ID); Policy policy = mock(Policy.class); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, policy, ApplicationStatus.INTERESTED, @@ -525,7 +524,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -542,7 +541,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -568,7 +567,7 @@ void setUp() { User owner = mock(User.class); when(owner.getId()).thenReturn(USER_ID); PolicyApplication existing = - PolicyApplication.register( + PolicyApplication.create( owner, mock(Policy.class), ApplicationStatus.INTERESTED, null, null); when(policyApplicationRepository.findByIdAndDeletedAtIsNull(10L)) .thenReturn(Optional.of(existing)); @@ -588,7 +587,7 @@ void setUp() { when(policy.getCategory()).thenReturn("일자리"); when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 12, 31)); PolicyApplication application = - PolicyApplication.register( + PolicyApplication.create( mock(User.class), policy, ApplicationStatus.APPLIED, "메모", null); Pageable pageable = PageRequest.of(0, 20); Page page = new PageImpl<>(List.of(application), pageable, 1); From ccfcfa3368890777559683c9ddd531aa829610a4 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 22 Jul 2026 10:21:41 +0900 Subject: [PATCH 54/93] =?UTF-8?q?fix:=20=EC=8B=A0=EC=B2=AD=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EC=A0=95=EB=B3=B4=20=EC=97=86=EB=8A=94=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=EC=9D=B4=20=EC=82=AC=EC=97=85=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=ED=9B=84=EC=97=90=EB=8F=84=20=EC=83=81?= =?UTF-8?q?=EC=8B=9C=EB=A1=9C=20=EB=85=B8=EC=B6=9C=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 신청기간 정보 없는 정책이 사업기간 종료 후에도 상시로 노출되는 문제 수정 PolicyRepository.findCards가 applicationEndDate is null이면 무조건 "상시"로 간주해 노출시켰는데, aplyPrdSeCd가 진짜 상시(0057002)가 아니면서 aplyYmd가 비어 applicationEndDate만 null인 정책(주로 0057003 — 지역 단발성 모집/특강류)은 businessPeriodEnd가 이미 지난 경우가 많았다(최대 2024-12-31, 평균 201일 경과). businessPeriodEnd는 매퍼가 이미 항상 채워 넣고 있었지만 목록 쿼리에서 전혀 참조되지 않았다 — applicationEndDate가 null일 때 businessPeriodEnd도 같이 확인하도록 조건을 추가했다. 로컬 MySQL 실데이터 기준 목록 노출 건수 1,593 → 1,194 (399건 감소, 조사에서 찾은 문제 건수와 정확히 일치). Closes #123 * fix: 매퍼가 신청기간 폴백과 URL 정규화를 자동 적용하도록 근본 수정 + 기존 데이터 백필 PolicyRepository.findCards 조건 추가만으로는 다음 배치 동기화 때 같은 문제(신청기간 정보 없는 정책이 상시로 계속 노출)가 재발할 수 있었다. PolicyMapper를 아예 고쳐서 재발을 막고, 기존에 이미 적재된 행도 같은 규칙으로 백필한다. - PolicyMapper.parseApplyPeriod: aplyYmd가 비어있거나 형식이 이상할 때 진짜 상시(0057002)가 아니면 사업기간(bizPrdBgngYmd~bizPrdEndYmd)으로 대체 - PolicyMapper.normalizeUrl(신규): aplyUrlAddr/refUrlAddr1/2 — 스킴 없는 도메인("www.xxx.go.kr")엔 https:// 를 붙이고, "-"/"전화문의" 같은 URL 아닌 자유 텍스트는 null로 정리해 깨진 링크를 만들지 않는다 - V13 마이그레이션: 위 두 규칙을 기존 적재 데이터에도 동일하게 백필 (로컬 검증: 신청기간 459건 백필, URL 스킴 보정 64건, 자유 텍스트 정리 2건 — 매핑 규칙과 정확히 일치) Refs #123 --- .../policy/repository/PolicyRepository.java | 12 ++-- .../youthpick/sync/service/PolicyMapper.java | 57 +++++++++++++++---- ..._backfill_policy_apply_period_and_urls.sql | 36 ++++++++++++ .../repository/PolicyRepositoryTest.java | 46 +++++++++++++++ .../sync/service/PolicyMapperTest.java | 53 +++++++++++++++++ 5 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/migration/V14__backfill_policy_apply_period_and_urls.sql diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index 450b2cf..fe01681 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -30,14 +30,18 @@ public interface PolicyRepository Optional findByIdAndVisibilityAndDeletedAtIsNull(Long id, PolicyVisibility visibility); /** - * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함. category는 표준 - * 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE 부분일치(escape '!'). sidoName은 시도명 - * EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 포함된다. age는 요청 구간과 정책 자격 구간의 - * 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. + * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함하되, + * businessPeriodEnd(사업기간 종료일)가 있고 이미 지났다면 제외한다 — aplyPrdSeCd가 진짜 상시(0057002)가 아닌데도 + * aplyYmd(신청기간)가 비어 applicationEndDate만 null인 정책(예: 0057003 지역 단발성 모집)이 이미 끝났음에도 "상시"로 계속 노출되는 + * 문제를 막는다. category는 표준 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE 부분일치(escape + * '!'). sidoName은 시도명 EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 포함된다. age는 + * 요청 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. */ @Query( "select p from Policy p where p.visibility = :visibility and p.deletedAt is null" + " and (p.applicationEndDate is null or p.applicationEndDate >= :today)" + + " and (p.applicationEndDate is not null" + + " or p.businessPeriodEnd is null or p.businessPeriodEnd >= :today)" + " and (:category is null or p.category = :category)" + " and (:keyword is null" + " or p.title like :keyword escape '!'" diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java index f621a02..e35bc39 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java @@ -61,7 +61,11 @@ public class PolicyMapper { /** 신규/변경 정책 1건 전처리. Policy.create 파라미터 순서 = Policy 필드 선언 순서. */ public Policy toEntity(YouthPolicyItem item) { String plcyNo = trimToNull(item.plcyNo()); - ApplyPeriod applyPeriod = parseApplyPeriod(plcyNo, item.aplyPrdSeCd(), item.aplyYmd()); + LocalDate businessBegin = toDate(plcyNo, "bizPrdBgngYmd", item.bizPrdBgngYmd()); + LocalDate businessEnd = toDate(plcyNo, "bizPrdEndYmd", item.bizPrdEndYmd()); + ApplyPeriod applyPeriod = + parseApplyPeriod( + plcyNo, item.aplyPrdSeCd(), item.aplyYmd(), businessBegin, businessEnd); return Policy.create( plcyNo, trimToNull(item.plcyNm()), @@ -87,14 +91,14 @@ public Policy toEntity(YouthPolicyItem item) { trimToNull(item.aplyYmd()), applyPeriod.start(), applyPeriod.end(), - toDate(plcyNo, "bizPrdBgngYmd", item.bizPrdBgngYmd()), - toDate(plcyNo, "bizPrdEndYmd", item.bizPrdEndYmd()), + businessBegin, + businessEnd, trimToNull(item.bizPrdEtcCn()), toInt(plcyNo, "sprtSclCnt", item.sprtSclCnt()), toBoolean(item.sprtArvlSeqYn()), - trimToNull(item.aplyUrlAddr()), - trimToNull(item.refUrlAddr1()), - trimToNull(item.refUrlAddr2()), + normalizeUrl(plcyNo, "aplyUrlAddr", item.aplyUrlAddr()), + normalizeUrl(plcyNo, "refUrlAddr1", item.refUrlAddr1()), + normalizeUrl(plcyNo, "refUrlAddr2", item.refUrlAddr2()), trimToNull(item.plcyAplyMthdCn()), trimToNull(item.sbmsnDcmntCn()), trimToNull(item.srngMthdCn()), @@ -158,21 +162,52 @@ String normalizeCategory(String plcyNo, String lclsfNm) { private record ApplyPeriod(LocalDate start, LocalDate end) {} - /** "yyyyMMdd ~ yyyyMMdd" 파싱. 상시(0057002)·빈값은 스킵(경고 없음), 형식 이상은 경고 + null. */ - private ApplyPeriod parseApplyPeriod(String plcyNo, String aplyPrdSeCd, String aplyYmd) { - String raw = trimToNull(aplyYmd); - if (raw == null || ALWAYS_OPEN_CODE.equals(trimToNull(aplyPrdSeCd))) { + /** + * "yyyyMMdd ~ yyyyMMdd" 파싱. 상시(0057002)는 스킵(경고 없음). 그 외 코드에서 aplyYmd가 비어 있거나 형식이 이상하면 + * 사업기간(bizPrdBgngYmd~bizPrdEndYmd)으로 대체한다 — 신청기간 자체를 안 주는 정책(주로 0057003 지역 단발성 모집)이 실제로는 이미 + * 끝났는데도 신청마감일 없음("상시")으로 계속 노출되는 문제를 막기 위함(#123). 형식 이상은 경고 로그를 남긴다. + */ + private ApplyPeriod parseApplyPeriod( + String plcyNo, + String aplyPrdSeCd, + String aplyYmd, + LocalDate businessBegin, + LocalDate businessEnd) { + if (ALWAYS_OPEN_CODE.equals(trimToNull(aplyPrdSeCd))) { return new ApplyPeriod(null, null); } + String raw = trimToNull(aplyYmd); + if (raw == null) { + return new ApplyPeriod(businessBegin, businessEnd); + } String[] parts = raw.split("~"); if (parts.length != 2) { log.warn("정책 {} 필드 aplyYmd 형식 이상 — 원문 '{}'", plcyNo, raw); - return new ApplyPeriod(null, null); + return new ApplyPeriod(businessBegin, businessEnd); } return new ApplyPeriod( toDate(plcyNo, "aplyYmd(시작일)", parts[0]), toDate(plcyNo, "aplyYmd(마감일)", parts[1])); } + /** + * 신청·참고 URL 정규화. 이미 http(s)://면 그대로, "www.xxx.go.kr"처럼 스킴만 빠진 도메인이면 https://를 붙인다. "-", + * "전화문의"처럼 URL이 아닌 자유 텍스트는 깨진 링크를 만들지 않도록 null로 버린다(#123). + */ + private static String normalizeUrl(String plcyNo, String field, String value) { + String v = trimToNull(value); + if (v == null) { + return null; + } + if (v.startsWith("http://") || v.startsWith("https://")) { + return v; + } + if (v.contains(" ") || v.contains("\t") || !v.contains(".")) { + log.warn("정책 {} 필드 {} URL 형식 아님(자유 텍스트로 판단) — 원문 '{}'", plcyNo, field, v); + return null; + } + return "https://" + v; + } + /** sprvsnInstCdNm(주관기관) 우선, 비어 있으면 operInstCdNm(운영기관) fallback */ private String organizationName(YouthPolicyItem item) { String supervising = trimToNull(item.sprvsnInstCdNm()); diff --git a/src/main/resources/db/migration/V14__backfill_policy_apply_period_and_urls.sql b/src/main/resources/db/migration/V14__backfill_policy_apply_period_and_urls.sql new file mode 100644 index 0000000..42bab15 --- /dev/null +++ b/src/main/resources/db/migration/V14__backfill_policy_apply_period_and_urls.sql @@ -0,0 +1,36 @@ +-- 신청기간·URL 데이터 정합성 백필 (#123) +-- 신규 수집분은 sync PolicyMapper(parseApplyPeriod/normalizeUrl)가 같은 규칙으로 저장 전에 정리한다. +-- 이 마이그레이션은 그 규칙을 이미 적재된 기존 행에도 동일하게 적용한다. + +-- 1) 신청기간(aplyYmd)이 비어 applicationEndDate가 null인 정책 중, 진짜 상시(0057002)가 아니면서 +-- 사업기간(businessPeriodEnd)은 있는 경우 사업기간으로 신청기간을 대체한다. +-- (0057003류 지역 단발성 모집이 신청마감일 없음="상시"로 계속 노출되던 문제) +UPDATE policies +SET application_start_date = COALESCE(application_start_date, business_period_begin), + application_end_date = business_period_end +WHERE application_period_type != '0057002' + AND application_end_date IS NULL + AND business_period_end IS NOT NULL; + +-- 2) 스킴 없는 도메인형 URL("www.xxx.go.kr")에 https:// 를 붙인다 +UPDATE policies SET application_url = CONCAT('https://', application_url) +WHERE application_url IS NOT NULL AND application_url NOT LIKE 'http%' + AND application_url LIKE '%.%' AND application_url NOT LIKE '% %'; + +UPDATE policies SET reference_url1 = CONCAT('https://', reference_url1) +WHERE reference_url1 IS NOT NULL AND reference_url1 NOT LIKE 'http%' + AND reference_url1 LIKE '%.%' AND reference_url1 NOT LIKE '% %'; + +UPDATE policies SET reference_url2 = CONCAT('https://', reference_url2) +WHERE reference_url2 IS NOT NULL AND reference_url2 NOT LIKE 'http%' + AND reference_url2 LIKE '%.%' AND reference_url2 NOT LIKE '% %'; + +-- 3) URL이 아닌 자유 텍스트("-", "전화문의" 등)는 깨진 링크를 만들지 않도록 null로 정리한다 +UPDATE policies SET application_url = NULL +WHERE application_url IS NOT NULL AND application_url <> '' AND application_url NOT LIKE 'http%'; + +UPDATE policies SET reference_url1 = NULL +WHERE reference_url1 IS NOT NULL AND reference_url1 <> '' AND reference_url1 NOT LIKE 'http%'; + +UPDATE policies SET reference_url2 = NULL +WHERE reference_url2 IS NOT NULL AND reference_url2 <> '' AND reference_url2 NOT LIKE 'http%'; diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java index a8277b2..07f47f0 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -7,6 +7,7 @@ import com.bop.youthpick.policy.dto.PolicySyncSnapshot; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; @@ -14,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; // @DataJpaTest는 슬라이스 스캔 대상에서 일반 @Configuration을 제외하므로, BaseEntity의 @@ -83,6 +85,38 @@ class PolicyRepositoryTest { .isEqualTo(1); } + @Test + void 신청마감일이_없어도_사업기간이_이미_지났으면_카드_목록에서_제외한다() { + LocalDate today = LocalDate.of(2026, 7, 21); + // 신청기간 정보가 없는(0057003류) 일회성 모집 — 사업기간은 이미 끝났다: 제외돼야 한다. + policyRepository.save( + policyForCards("P001", null, LocalDate.of(2026, 3, 31), today.minusDays(1))); + // 신청기간 정보가 없고 사업기간도 없는 진짜 상시(0057002류): 포함돼야 한다. + policyRepository.save(policyForCards("P002", null, null, null)); + // 사업기간이 아직 안 끝난 경우: 포함돼야 한다. + policyRepository.save( + policyForCards("P003", null, LocalDate.of(2026, 1, 1), today.plusDays(1))); + // 신청마감일 자체가 지난 경우: 기존 조건으로도 이미 제외된다. + policyRepository.save(policyForCards("P004", today.minusDays(1), null, null)); + + List result = + policyRepository + .findCards( + PolicyVisibility.VISIBLE, + today, + null, + null, + null, + null, + null, + PageRequest.of(0, 20)) + .getContent(); + + assertThat(result) + .extracting(Policy::getPolicyNo) + .containsExactlyInAnyOrder("P002", "P003"); + } + private Policy newPolicy( String policyNo, PolicyVisibility visibility, LocalDateTime deletedAt) { Policy policy = BeanUtils.instantiateClass(Policy.class); @@ -92,4 +126,16 @@ private Policy newPolicy( ReflectionTestUtils.setField(policy, "deletedAt", deletedAt); return policy; } + + private Policy policyForCards( + String policyNo, + LocalDate applicationEndDate, + LocalDate businessPeriodBegin, + LocalDate businessPeriodEnd) { + Policy policy = newPolicy(policyNo, PolicyVisibility.VISIBLE, null); + ReflectionTestUtils.setField(policy, "applicationEndDate", applicationEndDate); + ReflectionTestUtils.setField(policy, "businessPeriodBegin", businessPeriodBegin); + ReflectionTestUtils.setField(policy, "businessPeriodEnd", businessPeriodEnd); + return policy; + } } diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java index 0cc7d2c..049c3ec 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java @@ -170,6 +170,59 @@ void applyPeriodEdgeCases() throws IOException { assertThat(alwaysOpen.getApplicationPeriodRaw()).isEqualTo("20260101 ~ 20261231"); // 원문은 보존 } + @Test + @DisplayName("X: aplyYmd가 비어있고 상시(0057002)가 아니면 사업기간으로 신청기간을 대체한다 (#123)") + void fallsBackToBusinessPeriodWhenApplyPeriodMissing() throws IOException { + Policy fallback = + mapper.toEntity( + item( + "{\"plcyNo\":\"X5\",\"aplyPrdSeCd\":\"0057003\"," + + "\"bizPrdBgngYmd\":\"20260301\",\"bizPrdEndYmd\":\"20260630\"}")); + assertThat(fallback.getApplicationStartDate()).isEqualTo(LocalDate.of(2026, 3, 1)); + assertThat(fallback.getApplicationEndDate()).isEqualTo(LocalDate.of(2026, 6, 30)); + + // 상시(0057002)는 사업기간이 있어도 대체하지 않는다 — 원본이 명시한 "상시" 의미를 그대로 존중. + Policy alwaysOpenWithBusinessPeriod = + mapper.toEntity( + item( + "{\"plcyNo\":\"X6\",\"aplyPrdSeCd\":\"0057002\"," + + "\"bizPrdBgngYmd\":\"20260301\",\"bizPrdEndYmd\":\"20260630\"}")); + assertThat(alwaysOpenWithBusinessPeriod.getApplicationStartDate()).isNull(); + assertThat(alwaysOpenWithBusinessPeriod.getApplicationEndDate()).isNull(); + + // 사업기간마저 없으면 그대로 null (판단 근거가 없는 경우) + Policy noSignalAtAll = + mapper.toEntity(item("{\"plcyNo\":\"X7\",\"aplyPrdSeCd\":\"0057003\"}")); + assertThat(noSignalAtAll.getApplicationStartDate()).isNull(); + assertThat(noSignalAtAll.getApplicationEndDate()).isNull(); + } + + @Test + @DisplayName("X: 신청·참고 URL은 스킴 없는 도메인에 https://를 붙이고, URL이 아닌 자유 텍스트는 null로 버린다 (#123)") + void normalizesUrls() throws IOException { + Policy schemeAdded = + mapper.toEntity( + item( + "{\"plcyNo\":\"X8\",\"aplyUrlAddr\":\"www.bokjiro.go.kr\"," + + "\"refUrlAddr1\":\"www.khug.or.kr/jeonse/index.js\"}")); + assertThat(schemeAdded.getApplicationUrl()).isEqualTo("https://www.bokjiro.go.kr"); + assertThat(schemeAdded.getReferenceUrl1()) + .isEqualTo("https://www.khug.or.kr/jeonse/index.js"); + + Policy notAUrl = + mapper.toEntity( + item( + "{\"plcyNo\":\"X9\",\"aplyUrlAddr\":\"전화문의\"," + + "\"refUrlAddr1\":\"-\"}")); + assertThat(notAUrl.getApplicationUrl()).isNull(); + assertThat(notAUrl.getReferenceUrl1()).isNull(); + + Policy alreadyPrefixed = + mapper.toEntity( + item("{\"plcyNo\":\"X10\",\"aplyUrlAddr\":\"https://www.molit.go.kr\"}")); + assertThat(alreadyPrefixed.getApplicationUrl()).isEqualTo("https://www.molit.go.kr"); + } + @Test @DisplayName("X: 주관기관이 비면 운영기관으로 fallback, 둘 다 비면 null / N은 false") void organizationFallbackAndBooleanN() throws IOException { From 0c7b91d84d6a1113cf4b45b64f1423773258a036 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Wed, 22 Jul 2026 10:37:59 +0900 Subject: [PATCH 55/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=B7=A8=EC=97=85?= =?UTF-8?q?=EC=83=81=ED=83=9C(jobCode)=20=ED=95=84=ED=84=B0=C2=B7=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=B6=94=EA=B0=80=20(#133)=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트 '생활·상태' 필터가 대응 파라미터 부재로 전송조차 되지 않아 죽어 있었다. job_codes는 2,638건 전부 채워져 있어 마이그레이션 없이 바로 쓸 수 있다. - 필터: 선택 코드 OR 제한없음(0013010)을 함께 통과시킨다. 재직자를 골랐다고 K-패스 같은 제한없음 정책(1,962건)이 빠지면 안 되기 때문이다. - 정렬: 제한없음을 뒤로 밀어 선택 상태 전용 정책이 먼저 보이게 한다. 필터만으로는 78%가 남아 '안 걸린 것 같은' 결과가 된다. - 콤마 다중값은 구분자로 감싸 매칭한다 — '0013001'이 '10013001'에 걸리지 않는다. 정렬을 Pageable에서 JPQL order by로 옮겼다. "조건 없는 정책을 뒤로"는 해당 필터가 걸렸을 때만 적용해야 하는데 Spring Data Sort로는 파라미터 의존 순서를 표현할 수 없다. #120의 nationwide 정렬도 같은 order by로 합쳤다. 검증: ./gradlew.bat test / spotlessCheck 통과 - PolicyRepositoryTest: 제한없음 포함·후순위, 무필터 시 미적용, 부분일치 방지, 전국 정책 후순위(지역 필터 유/무) - PolicyServiceTest: jobCode 트림 전달, 공백이면 미필터, 정렬 없는 Pageable 전달 Claude-Session: https://claude.ai/code/session_01LJrujYeM5M3ALofEXB5mCZ Co-authored-by: Claude Opus 4.8 (1M context) --- .../policy/controller/PolicyController.java | 9 +- .../bop/youthpick/policy/entity/Policy.java | 6 + .../policy/repository/PolicyRepository.java | 25 +++- .../policy/service/PolicyService.java | 30 ++--- .../repository/PolicyRepositoryTest.java | 122 ++++++++++++++++++ .../policy/service/PolicyServiceTest.java | 55 +++++--- 6 files changed, 205 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java index 205ef34..c566f11 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java @@ -24,8 +24,9 @@ public class PolicyController { private final PolicyService policyService; /** - * 정책 목록(카드) 조회 (비회원 허용). 최신순 서버 고정 정렬. category(표준 5분류) exact match, keyword는 부분일치 검색, region은 - * 시도명('전국'이면 전 시도 커버 정책만), ageMin/ageMax는 자격 구간과의 겹침 필터 — 모두 선택. meta에 + * 정책 목록(카드) 조회 (비회원 허용). 기본 최신순이되, region·jobCode 필터가 걸리면 조건 없는 정책(전국/취업상태 제한없음)을 뒤로 민다. + * category(표준 5분류) exact match, keyword는 부분일치 검색, region은 시도명('전국'은 지역 무관이라 필터 미적용), + * ageMin/ageMax는 자격 구간과의 겹침, jobCode는 온통청년 취업상태 코드(예: 0013001 재직자) — 모두 선택. meta에 * page/totalCount/totalPages. */ @GetMapping @@ -35,9 +36,11 @@ public ApiResponse> getCards( @RequestParam(required = false) String region, @RequestParam(required = false) Integer ageMin, @RequestParam(required = false) Integer ageMax, + @RequestParam(required = false) String jobCode, @PageableDefault(size = 20) Pageable pageable) { Page page = - policyService.getCards(category, keyword, region, ageMin, ageMax, pageable); + policyService.getCards( + category, keyword, region, ageMin, ageMax, jobCode, pageable); return ApiResponse.ok(page.getContent(), page); } diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java index 76bd6d8..2f62f2e 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Policy.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -83,6 +83,12 @@ public class Policy extends BaseEntity { @Column(name = "max_age") private Integer maxAge; + /** + * jobCd '제한없음'. 실측 2,638건 중 1,962건(74%)이 이 값이며, 취업상태를 따지지 않는 정책이라는 뜻이다. 특정 상태로 필터링할 때도 이 정책들은 + * 대상에 포함해야 한다 — 재직자를 골랐다고 K-패스가 빠지면 안 된다. + */ + public static final String JOB_CODE_UNRESTRICTED = "0013010"; + /** jobCd — 콤마 다중 */ @Column(name = "job_codes", length = 255) private String jobCodes; diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index fe01681..970e9e5 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -33,9 +33,13 @@ public interface PolicyRepository * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함하되, * businessPeriodEnd(사업기간 종료일)가 있고 이미 지났다면 제외한다 — aplyPrdSeCd가 진짜 상시(0057002)가 아닌데도 * aplyYmd(신청기간)가 비어 applicationEndDate만 null인 정책(예: 0057003 지역 단발성 모집)이 이미 끝났음에도 "상시"로 계속 노출되는 - * 문제를 막는다. category는 표준 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE 부분일치(escape - * '!'). sidoName은 시도명 EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 포함된다. age는 - * 요청 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. + * 문제를 막는다(#123). category는 표준 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE + * 부분일치(escape '!'). sidoName은 시도명 EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 + * 포함된다. age는 요청 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. jobCode는 + * 온통청년 취업상태 코드 하나로, 해당 코드를 가진 정책과 '제한없음'({@link Policy#JOB_CODE_UNRESTRICTED}) 정책을 함께 통과시킨다. + * + *

정렬은 Pageable이 아니라 이 쿼리가 고정한다. "조건 없는 정책을 뒤로"는 필터가 걸렸을 때만 적용해야 하는데, Spring Data {@code + * Sort}로는 파라미터에 따라 달라지는 순서를 표현할 수 없기 때문이다. 호출자는 정렬 없는 Pageable을 넘긴다. */ @Query( "select p from Policy p where p.visibility = :visibility and p.deletedAt is null" @@ -53,7 +57,19 @@ public interface PolicyRepository + " select pr from PolicyRegion pr" + " where pr.policy = p and pr.region.sidoName = :sidoName))" + " and (:ageMin is null or p.maxAge is null or p.maxAge = 0 or p.maxAge >= :ageMin)" - + " and (:ageMax is null or p.minAge is null or p.minAge = 0 or p.minAge <= :ageMax)") + + " and (:ageMax is null or p.minAge is null or p.minAge = 0 or p.minAge <= :ageMax)" + + " and (:jobCode is null or p.jobCodes is null" + + " or concat(',', p.jobCodes, ',') like concat('%,', :jobCode, ',%')" + + " or concat(',', p.jobCodes, ',') like '%," + + Policy.JOB_CODE_UNRESTRICTED + + ",%')" + + " order by" + + " case when :sidoName is not null and p.nationwide = true then 1 else 0 end," + + " case when :jobCode is not null and (p.jobCodes is null" + + " or concat(',', p.jobCodes, ',') like '%," + + Policy.JOB_CODE_UNRESTRICTED + + ",%') then 1 else 0 end," + + " p.id desc") Page findCards( @Param("visibility") PolicyVisibility visibility, @Param("today") LocalDate today, @@ -62,5 +78,6 @@ Page findCards( @Param("sidoName") String sidoName, @Param("ageMin") Integer ageMin, @Param("ageMax") Integer ageMax, + @Param("jobCode") String jobCode, Pageable pageable); } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 080e6c8..10c6a23 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -19,7 +19,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,9 +37,12 @@ public class PolicyService { private static final String NATIONWIDE_REGION = "전국"; /** - * 정책 목록(카드) 조회 (비회원 허용). 삭제·숨김·신청 마감 지난 정책은 제외하고 최신순(id 내림차순, 서버 고정 정렬)으로 내린다. category(표준 5분류) - * exact match, keyword는 5개 필드 LIKE 부분일치, region은 시도명 필터('전국'은 지역 무관이라 필터 미적용), age는 [ageMin, - * ageMax] 구간과 정책 자격 구간의 겹침(overlap)만 필터한다. 지역 라벨은 페이지 단위 배치 조회(fetch join)로 조립해 N+1을 피한다. + * 정책 목록(카드) 조회 (비회원 허용). 삭제·숨김·신청 마감 지난 정책은 제외한다. category(표준 5분류) exact match, keyword는 5개 필드 + * LIKE 부분일치, region은 시도명 필터('전국'은 지역 무관이라 필터 미적용), age는 [ageMin, ageMax] 구간과 정책 자격 구간의 + * 겹침(overlap), jobCode는 온통청년 취업상태 코드 필터다. 지역 라벨은 페이지 단위 배치 조회(fetch join)로 조립해 N+1을 피한다. + * + *

정렬은 리포지토리 쿼리가 고정한다({@code PolicyRepository.findCards} 참고) — 지역·취업상태 필터가 걸렸을 때만 '조건 없는 정책'을 + * 뒤로 미뤄야 해서 파라미터에 따라 순서가 달라지기 때문이다. 여기서는 정렬 없는 Pageable을 넘긴다. */ @Transactional(readOnly = true) public Page getCards( @@ -49,6 +51,7 @@ public Page getCards( @Nullable String region, @Nullable Integer ageMin, @Nullable Integer ageMax, + @Nullable String jobCode, Pageable pageable) { String categoryFilter = trimToNull(category); String keywordPattern = toLikePattern(keyword); @@ -57,8 +60,7 @@ public Page getCards( // 전국 대상 정책은 policy_regions에 전 시도가 들어 있어 개별 시도 조회에도 이미 포함된다. String sidoName = NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter; - Pageable sorted = - PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortFor(sidoName)); + Pageable unsorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); Page page = policyRepository.findCards( @@ -69,7 +71,8 @@ public Page getCards( sidoName, ageMin, ageMax, - sorted); + trimToNull(jobCode), + unsorted); List policyIds = page.getContent().stream().map(Policy::getId).toList(); Map> sidoNamesByPolicyId = @@ -93,19 +96,6 @@ public Page getCards( .toList())); } - /** - * 목록 정렬. 지역 필터가 없으면 최신순만, 특정 시도를 고르면 전국 정책(is_nationwide)을 뒤로 밀어 그 지역 전용 정책이 먼저 보이게 한다. 전국 정책은 - * 어느 시도를 골라도 걸리므로 그대로 두면 지역 선택이 무의미해진다. - * - *

지역 무필터에는 적용하지 않는다 — 대상이 넓은 정책을 뒤로 미룰 이유가 없다. - */ - private static Sort sortFor(@Nullable String sidoName) { - Sort latestFirst = Sort.by(Sort.Direction.DESC, "id"); - return sidoName == null - ? latestFirst - : Sort.by(Sort.Order.asc("nationwide")).and(latestFirst); - } - @Nullable private static String trimToNull(@Nullable String value) { if (value == null) { diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java index 07f47f0..181913d 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -6,7 +6,9 @@ import com.bop.youthpick.global.config.JpaAuditingConfig; import com.bop.youthpick.policy.dto.PolicySyncSnapshot; import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -15,6 +17,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; @@ -25,6 +28,8 @@ class PolicyRepositoryTest { @Autowired private PolicyRepository policyRepository; + @Autowired private RegionRepository regionRepository; + @Autowired private PolicyRegionRepository policyRegionRepository; @Test void 저장된_모든_정책의_비교용_스냅샷을_반환한다() { @@ -109,6 +114,7 @@ class PolicyRepositoryTest { null, null, null, + null, PageRequest.of(0, 20)) .getContent(); @@ -117,6 +123,122 @@ class PolicyRepositoryTest { .containsExactlyInAnyOrder("P002", "P003"); } + @Test + void jobCode_필터는_해당_코드와_제한없음_정책을_함께_통과시키고_제한없음을_뒤로_민다() { + // 재직자 전용 / 미취업자 전용 / 제한없음 / 다중값(재직+미취업) + savePolicyWithJobCodes("J-EMPLOYED", "0013001"); + savePolicyWithJobCodes("J-UNEMPLOYED", "0013003"); + savePolicyWithJobCodes("J-ANY", Policy.JOB_CODE_UNRESTRICTED); + savePolicyWithJobCodes("J-MULTI", "0013001,0013003"); + + Page page = findCards("0013001"); + + // 미취업자 전용만 빠지고, 제한없음은 남되 맨 뒤로 간다. + assertThat(page.getContent()) + .extracting(Policy::getPolicyNo) + .containsExactly("J-MULTI", "J-EMPLOYED", "J-ANY"); + assertThat(page.getTotalElements()).isEqualTo(3); + } + + @Test + void jobCode가_없으면_취업상태로_거르지도_정렬하지도_않는다() { + savePolicyWithJobCodes("J-ANY", Policy.JOB_CODE_UNRESTRICTED); + savePolicyWithJobCodes("J-EMPLOYED", "0013001"); + + Page page = findCards(null); + + // 최신순(id desc)만 적용 — 제한없음이 뒤로 밀리지 않는다. + assertThat(page.getContent()) + .extracting(Policy::getPolicyNo) + .containsExactly("J-EMPLOYED", "J-ANY"); + } + + @Test + void jobCode_매칭은_구분자로_감싸_부분일치_사고를_막는다() { + // '0013001'로 검색할 때 '10013001'이나 '00130010'이 걸리면 안 된다. + savePolicyWithJobCodes("J-PREFIXED", "10013001"); + savePolicyWithJobCodes("J-SUFFIXED", "00130010"); + savePolicyWithJobCodes("J-EXACT", "0013001"); + + Page page = findCards("0013001"); + + assertThat(page.getContent()).extracting(Policy::getPolicyNo).containsExactly("J-EXACT"); + } + + @Test + void 시도를_고르면_전국_정책을_뒤로_밀어_그_지역_전용_정책이_먼저_나온다() { + Region sejong = regionRepository.save(Region.create("29000", "세종특별자치시", "세종특별자치시")); + Region seoul = regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + // 전국 정책도 세종에 링크가 있어 시도 필터에 함께 걸린다 — 그래서 정렬로 밀어내야 한다. + Policy nationwide = saveLinked("P-ALL", true, sejong, seoul); + Policy sejongOnly = saveLinked("P-SEJONG", false, sejong); + + Page page = + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + null, + null, + "세종특별자치시", + null, + null, + null, + PageRequest.of(0, 20)); + + assertThat(page.getContent()) + .extracting(Policy::getPolicyNo) + .containsExactly(sejongOnly.getPolicyNo(), nationwide.getPolicyNo()); + } + + @Test + void 지역을_고르지_않으면_전국_정책을_뒤로_밀지_않는다() { + Region sejong = regionRepository.save(Region.create("29000", "세종특별자치시", "세종특별자치시")); + Policy nationwide = saveLinked("P-ALL", true, sejong); + Policy normal = saveLinked("P-NORMAL", false, sejong); + + Page page = findCards(null); + + // 최신순(id desc)만 — 나중에 저장된 P-NORMAL이 앞선다. + assertThat(page.getContent()) + .extracting(Policy::getPolicyNo) + .containsExactly(normal.getPolicyNo(), nationwide.getPolicyNo()); + } + + private Policy saveLinked(String policyNo, boolean nationwide, Region... regions) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + ReflectionTestUtils.setField(policy, "nationwide", nationwide); + Policy saved = policyRepository.save(policy); + for (Region region : regions) { + policyRegionRepository.save(PolicyRegion.create(saved, region)); + } + return saved; + } + + private Page findCards(String jobCode) { + return policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + null, + null, + null, + null, + null, + jobCode, + PageRequest.of(0, 20)); + } + + private void savePolicyWithJobCodes(String policyNo, String jobCodes) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + ReflectionTestUtils.setField(policy, "jobCodes", jobCodes); + policyRepository.save(policy); + } + private Policy newPolicy( String policyNo, PolicyVisibility visibility, LocalDateTime deletedAt) { Policy policy = BeanUtils.instantiateClass(Policy.class); diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index 28a6d21..d6d0eb2 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -139,6 +139,7 @@ void setUp() { isNull(), isNull(), isNull(), + isNull(), pageableCaptor.capture())) .thenReturn( new PageImpl<>( @@ -157,10 +158,10 @@ void setUp() { newPolicyRegion(allSido, "27110", "대구광역시", "중구"))); Page page = - policyService.getCards(null, null, null, null, null, PageRequest.of(0, 20)); + policyService.getCards(null, null, null, null, null, null, PageRequest.of(0, 20)); - assertThat(pageableCaptor.getValue().getSort()) - .isEqualTo(Sort.by(Sort.Direction.DESC, "id")); + // 정렬은 리포지토리 쿼리(order by)가 고정한다 — 서비스는 정렬 없는 Pageable을 넘긴다. + assertThat(pageableCaptor.getValue().getSort()).isEqualTo(Sort.unsorted()); PolicyCardResponse card = page.getContent().get(0); assertThat(card.id()).isEqualTo(1L); assertThat(card.title()).isEqualTo("한 시도 정책"); @@ -185,10 +186,12 @@ void setUp() { isNull(), isNull(), isNull(), + isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - policyService.getCards(null, "50% 지원_special!", null, null, null, PageRequest.of(0, 20)); + policyService.getCards( + null, "50% 지원_special!", null, null, null, null, PageRequest.of(0, 20)); assertThat(keywordCaptor.getValue()).isEqualTo("%50!% 지원!_special!!%"); } @@ -204,33 +207,53 @@ void setUp() { sidoNameCaptor.capture(), isNull(), isNull(), + isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - policyService.getCards(null, null, "전국", null, null, PageRequest.of(0, 20)); + policyService.getCards(null, null, "전국", null, null, null, PageRequest.of(0, 20)); assertThat(sidoNameCaptor.getValue()).isNull(); } @Test - void 시도를_고르면_전국_정책을_뒤로_밀어_그_지역_전용_정책이_먼저_나오게_정렬한다() { - ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + void jobCode는_공백을_제거해_그대로_리포지토리에_전달한다() { + ArgumentCaptor jobCodeCaptor = ArgumentCaptor.forClass(String.class); when(policyRepository.findCards( eq(PolicyVisibility.VISIBLE), any(LocalDate.class), isNull(), isNull(), - eq("세종특별자치시"), isNull(), isNull(), - pageableCaptor.capture())) + isNull(), + jobCodeCaptor.capture(), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + policyService.getCards(null, null, null, null, null, " 0013001 ", PageRequest.of(0, 20)); + + assertThat(jobCodeCaptor.getValue()).isEqualTo("0013001"); + } + + @Test + void jobCode가_빈_문자열이면_필터를_걸지_않는다() { + ArgumentCaptor jobCodeCaptor = ArgumentCaptor.forClass(String.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + jobCodeCaptor.capture(), + any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - policyService.getCards(null, null, "세종특별자치시", null, null, PageRequest.of(0, 20)); + policyService.getCards(null, null, null, null, null, " ", PageRequest.of(0, 20)); - // nationwide=false(지역 전용)가 asc로 먼저, 같은 그룹 안에서는 최신순. - assertThat(pageableCaptor.getValue().getSort()) - .isEqualTo(Sort.by(Sort.Order.asc("nationwide"), Sort.Order.desc("id"))); + assertThat(jobCodeCaptor.getValue()).isNull(); } @Test @@ -244,10 +267,11 @@ void setUp() { sidoNameCaptor.capture(), isNull(), isNull(), + isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - policyService.getCards(null, null, "서울특별시", null, null, PageRequest.of(0, 20)); + policyService.getCards(null, null, "서울특별시", null, null, null, PageRequest.of(0, 20)); assertThat(sidoNameCaptor.getValue()).isEqualTo("서울특별시"); } @@ -264,10 +288,11 @@ void setUp() { isNull(), ageMinCaptor.capture(), ageMaxCaptor.capture(), + isNull(), any(Pageable.class))) .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); - policyService.getCards(null, null, null, 19, 34, PageRequest.of(0, 20)); + policyService.getCards(null, null, null, 19, 34, null, PageRequest.of(0, 20)); assertThat(ageMinCaptor.getValue()).isEqualTo(19); assertThat(ageMaxCaptor.getValue()).isEqualTo(34); From 5cb8673a8190276e9ae5a4fa7ed49238ea941abf Mon Sep 17 00:00:00 2001 From: Chaeyeon Han <91395969+imi21123@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:05:27 +0900 Subject: [PATCH 56/93] =?UTF-8?q?feat:=20=EB=A7=9E=EC=B6=A4=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 온보딩 프로필 코드값 입력 검증 추가 UserProfileRequest가 @Size(max = 16)만 검사해 어휘에 없는 값도 그대로 저장됐다. 맞춤정책 매칭은 알 수 없는 코드를 만나면 예외 없이 해당 축을 0점으로 넘기므로, 잘못된 값이 들어가도 드러나지 않았다. - 어휘 enum 5종 추가 (EmploymentStatus, EducationLevel, MaritalStatus, Major, SpecialCondition) - UserProfileRequest에 @Pattern 적용 — 어휘 밖 값은 400 C001로 거절. 저장 타입은 String 유지라 마이그레이션 불필요 - @Pattern이 컴파일 상수만 받는 제약 때문에 상수 목록을 문자열로 중복 작성 — 둘이 어긋나면 ProfileCodePatternTest가 실패 - 존재하지 않는 코드를 쓰던 테스트 픽스처를 실제 어휘 값으로 교체 (UNIVERSITY → UNIV_GRADUATE, COMPUTER_SCIENCE → ENGINEERING, LOW_INCOME → BASIC_LIVELIHOOD) Co-Authored-By: Claude Opus 4.8 * feat: 맞춤정책 조회 API 추가 (GET /api/v1/recommended-policies) 온보딩 프로필과 정책 자격조건을 매칭해 맞춤정책을 내려주는 회원 전용 API. 프로필과 정책의 코드 체계가 달라 변환 계층을 함께 도입했다. user_profiles는 온보딩 자체 코드(UNEMPLOYED)를, policies는 온통청년 코드(0013003)를 저장해 변환 없이 비교하면 취업·학력 축이 영원히 일치하지 않는다. V11 주석이 이 컬럼에 jobCd가 들어간다고 잘못 적어 둔 것이 원인이라 주석도 V14로 정정했다. 자격은 정책이 조건을 명시했고 사용자가 벗어날 때만 제외한다(8축). 점수는 "자격 통과"가 아니라 "정책이 나를 명시적으로 겨냥"할 때만 부여한다(10축, 합 100). 실데이터 기준 1,521건 → 188건. Co-Authored-By: Claude Opus 4.8 * fix: dev의 findCards jobCode 파라미터 추가에 맞춰 호출부·테스트 보정 - RecommendedPolicyService는 취업상태를 자체 스코어링(YouthPolicyCodeMapper)으로 처리하므로 DB 레벨 jobCode 필터는 걸지 않고 null로 넘긴다(ageMin/ageMax와 동일 패턴) - PolicyRepositoryTest/RecommendedPolicyServiceTest의 findCards 목/호출 인자 개수 보정 --------- Co-authored-by: imi21123 Co-authored-by: Claude Opus 4.8 --- docs/schema.sql | 4 +- .../global/config/SecurityConfig.java | 3 +- .../RecommendedPolicyController.java | 31 ++ .../policy/dto/RecommendedPolicyResponse.java | 37 ++ .../service/RecommendedPolicyService.java | 406 ++++++++++++++++++ .../policy/service/YouthPolicyCodeMapper.java | 146 +++++++ .../user/dto/UserProfileRequest.java | 20 +- .../youthpick/user/entity/EducationLevel.java | 22 + .../user/entity/EmploymentStatus.java | 24 ++ .../com/bop/youthpick/user/entity/Major.java | 22 + .../youthpick/user/entity/MaritalStatus.java | 12 + .../user/entity/SpecialCondition.java | 22 + .../youthpick/user/exception/UserError.java | 2 + .../V15__fix_user_profiles_code_comments.sql | 14 + .../repository/PolicyRepositoryTest.java | 24 ++ .../service/RecommendedPolicyServiceTest.java | 340 +++++++++++++++ .../service/YouthPolicyCodeMapperTest.java | 113 +++++ .../controller/UserProfileControllerTest.java | 49 ++- .../user/entity/ProfileCodePatternTest.java | 62 +++ 19 files changed, 1332 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/RecommendedPolicyResponse.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapper.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/EducationLevel.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/EmploymentStatus.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/Major.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/MaritalStatus.java create mode 100644 src/main/java/com/bop/youthpick/user/entity/SpecialCondition.java create mode 100644 src/main/resources/db/migration/V15__fix_user_profiles_code_comments.sql create mode 100644 src/test/java/com/bop/youthpick/policy/service/RecommendedPolicyServiceTest.java create mode 100644 src/test/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapperTest.java create mode 100644 src/test/java/com/bop/youthpick/user/entity/ProfileCodePatternTest.java diff --git a/docs/schema.sql b/docs/schema.sql index d59fb31..181c4ba 100644 --- a/docs/schema.sql +++ b/docs/schema.sql @@ -61,8 +61,8 @@ CREATE TABLE user_profiles ( user_id BIGINT NOT NULL, birth_year INT NOT NULL COMMENT '출생연도 (REC 나이 20점)', region_code VARCHAR(10) NOT NULL COMMENT '거주 시군구코드 (REC 지역 25점)', - employment_status VARCHAR(16) NOT NULL COMMENT 'jobCd 1개 (REC 취업 15점)', - education_level VARCHAR(16) NOT NULL COMMENT 'schoolCd 1개 (REC 학력 10점)', + employment_status VARCHAR(16) NOT NULL COMMENT '온보딩 취업상태 코드(UNEMPLOYED 등). jobCd 변환은 YouthPolicyCodeMapper (REC 취업 15점)', + education_level VARCHAR(16) NOT NULL COMMENT '온보딩 학력 코드(UNIV_GRADUATE 등). schoolCd 변환은 YouthPolicyCodeMapper (REC 학력 10점)', merry_status VARCHAR(16) NULL COMMENT '결혼여부 코드 (선택)', major VARCHAR(255) NULL COMMENT '전공 콤마목록, plcyMajorCd와 형식 통일 (선택)', special_condition VARCHAR(500) NULL COMMENT '특화조건 콤마목록 (선택)', diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index c1c2feb..8cdb51d 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -96,7 +96,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/v1/auth/logout", "/api/v1/users", "/api/v1/me/**", - "/api/v1/policy-recent-views") + "/api/v1/policy-recent-views", + "/api/v1/recommended-policies") .authenticated() // 회원 전용 — 정책 신청관리(관심정책 흡수) + 체크리스트. .requestMatchers( diff --git a/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java new file mode 100644 index 0000000..1553fc3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java @@ -0,0 +1,31 @@ +package com.bop.youthpick.policy.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.policy.dto.RecommendedPolicyResponse; +import com.bop.youthpick.policy.service.RecommendedPolicyService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/recommended-policies") +@RequiredArgsConstructor +public class RecommendedPolicyController { + + private final RecommendedPolicyService recommendedPolicyService; + + /** 맞춤정책 조회 (회원 전용). 로그인 사용자의 온보딩 프로필과 정책 자격조건을 매칭해 점수순으로 내린다. */ + @GetMapping + public ApiResponse> getRecommendations( + @CurrentUser Long userId, + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String region) { + return ApiResponse.ok( + recommendedPolicyService.getRecommendations(userId, category, keyword, region)); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/dto/RecommendedPolicyResponse.java b/src/main/java/com/bop/youthpick/policy/dto/RecommendedPolicyResponse.java new file mode 100644 index 0000000..0e53d54 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/RecommendedPolicyResponse.java @@ -0,0 +1,37 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Policy; +import java.time.LocalDate; +import java.util.List; + +/** + * 맞춤정책 조회 응답. {@link PolicyCardResponse}에 매칭 점수(score, REC 6축 합산·0~100)와 매칭된 축 목록(matchedAxes)을 + * 더한다. matchedAxes는 프론트가 추천 사유 문구를 조립하는 데 쓰는 원자료다. + */ +public record RecommendedPolicyResponse( + Long id, + String title, + String category, + String description, + Integer minAge, + Integer maxAge, + LocalDate applicationEndDate, + List provinces, + int score, + List matchedAxes) { + + public static RecommendedPolicyResponse of( + Policy policy, List provinces, int score, List matchedAxes) { + return new RecommendedPolicyResponse( + policy.getId(), + policy.getTitle(), + policy.getCategory(), + policy.getDescription(), + policy.getMinAge(), + policy.getMaxAge(), + policy.getApplicationEndDate(), + provinces, + score, + matchedAxes); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java b/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java new file mode 100644 index 0000000..24e4a84 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java @@ -0,0 +1,406 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.policy.dto.RecommendedPolicyResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserProfileRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 로그인 사용자의 온보딩 프로필과 정책 자격조건을 매칭해 맞춤정책을 조회한다. + * + *

자격(하드 필터) — 정책이 조건을 명시했는데 사용자가 그 밖이면 목록에서 뺀다: 나이·지역·취업·학력·결혼여부·전공·특화조건·소득. 다른 시도 + * 전용이거나 재직자 전용인 정책을 미취업 사용자에게 "맞춤"으로 보여주는 건 추천이 아니라 오답이기 때문이다. 조건을 명시하지 않았거나(제한없음) 사용자가 값을 입력하지 않아 + * 판정할 수 없으면 빼지 않는다. + * + *

적합도(점수) — 10축 합계 100점: 분야20·지역18·특화조건12·취업12·키워드10·학력8·전공7·결혼여부5·나이5·소득3. 배점은 "그 축이 + * 일치했을 때 사용자를 얼마나 좁게 특정하는가"로 정했다. 모든 축이 같은 규칙을 따른다 — "자격 통과"에는 주지 않고 정책이 조건을 명시했고 사용자가 거기 해당할 때만 + * 준다. 제한없음(전국·학력무관 등)은 겨냥이 아니므로 0점이다. + * + *

자격에만 쓰이고 점수에는 없는 축은 없다 — 자격 조건 8축이 모두 점수에도 참여하며, 분야·키워드는 반대로 자격에는 쓰지 않는다(관심사가 달라도 신청은 가능하므로 제외 + * 조건이 되면 안 된다). + * + *

취업·학력은 프로필과 정책의 코드 체계가 달라 {@link YouthPolicyCodeMapper}로 변환한 뒤 비교한다. + */ +@Service +@RequiredArgsConstructor +public class RecommendedPolicyService { + + private static final String NATIONWIDE_REGION = "전국"; + + // 배점 원칙: 그 축이 일치했을 때 사용자를 얼마나 좁게 특정하는가. 좁게 특정할수록 높다. + // 합계 100점이지만 10개 축이 모두 "명시 + 일치"하는 정책은 현실에 거의 없어, 절대값보다 상대 순위가 의미를 갖는다. + private static final int CATEGORY_WEIGHT = 20; // 사용자가 직접 고른 관심분야 + private static final int REGION_WEIGHT = 18; // 내 시도 전용 정책 + private static final int SPECIALIZATION_WEIGHT = 12; // 여성·장애인·농업인 등 가장 좁은 대상 + private static final int EMPLOYMENT_WEIGHT = 12; // 청년정책의 핵심 구분축 + private static final int KEYWORD_WEIGHT = 10; // 사용자 선택이지만 문자열 교집합이라 노이즈 있음 + private static final int EDUCATION_WEIGHT = 8; + private static final int MAJOR_WEIGHT = 7; + private static final int MARITAL_WEIGHT = 5; // 인구를 절반씩 가르는 넓은 조건 + private static final int AGE_WEIGHT = 5; // 청년정책 대부분이 청년 범위를 명시 — 특정력이 거의 없다 + private static final int INCOME_WEIGHT = 3; // "상한 이하" 범위 조건이라 가장 넓다 + + private final PolicyRepository policyRepository; + private final PolicyRegionRepository policyRegionRepository; + private final UserProfileRepository userProfileRepository; + + @Transactional(readOnly = true) + public List getRecommendations( + Long userId, + @Nullable String category, + @Nullable String keyword, + @Nullable String region) { + UserProfile profile = + userProfileRepository + .findByUserId(userId) + .orElseThrow(() -> new UserException(UserError.PROFILE_NOT_FOUND)); + + String categoryFilter = trimToNull(category); + String keywordPattern = toLikePattern(keyword); + String regionFilter = trimToNull(region); + // '전국'은 지역 드롭다운의 "지역 무관" 선택지라 시도명 필터를 걸지 않는다(PolicyService와 동일 규약). + String sidoName = NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter; + + Page candidates = + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + categoryFilter, + keywordPattern, + sidoName, + null, + null, + null, + Pageable.unpaged()); + + List policyIds = candidates.getContent().stream().map(Policy::getId).toList(); + Map> sidoNamesByPolicyId = + policyIds.isEmpty() + ? Map.of() + : policyRegionRepository.findWithRegionByPolicyIdIn(policyIds).stream() + .collect( + Collectors.groupingBy( + pr -> pr.getPolicy().getId(), + Collectors.mapping( + pr -> pr.getRegion().getSidoName(), + Collectors.toList()))); + + int age = LocalDate.now().getYear() - profile.getBirthYear(); + String userSidoName = profile.getRegion().getSidoName(); + String userJobCode = YouthPolicyCodeMapper.toJobCode(profile.getEmploymentStatus()); + String userSchoolCode = YouthPolicyCodeMapper.toSchoolCode(profile.getEducationLevel()); + UserMatchProfile user = + new UserMatchProfile( + age, + userSidoName, + userJobCode, + userSchoolCode, + YouthPolicyCodeMapper.toMaritalCode(profile.getMerryStatus()), + mapAll(profile.getMajor(), YouthPolicyCodeMapper::toMajorCode), + mapAll( + profile.getSpecialCondition(), + YouthPolicyCodeMapper::toSpecializationCode), + profile.getIncome(), + splitToSet(profile.getCategories()), + splitToSet(profile.getKeywords())); + + List recommendations = new ArrayList<>(); + for (Policy policy : candidates.getContent()) { + List provinces = + sidoNamesByPolicyId.getOrDefault(policy.getId(), List.of()).stream() + .distinct() + .sorted() + .toList(); + if (!isEligible(policy, provinces, user)) { + continue; + } + recommendations.add(score(policy, provinces, user)); + } + + // 점수 내림차순, 동점이면 id 내림차순(최신순). + recommendations.sort( + Comparator.comparingInt(RecommendedPolicyResponse::score) + .thenComparingLong(RecommendedPolicyResponse::id) + .reversed()); + return recommendations; + } + + /** + * 자격 판정에 필요한 사용자 값 묶음. 코드 변환에 실패했거나 사용자가 입력하지 않은 항목은 null/빈 값이며, 그 경우 판정을 포기하고 통과시킨다. + * + * @param majorCodes 전공은 다중 선택이라 목록. 특화조건도 같다. + */ + private record UserMatchProfile( + int age, + String sidoName, + @Nullable String jobCode, + @Nullable String schoolCode, + @Nullable String maritalCode, + Set majorCodes, + Set specializationCodes, + @Nullable Integer income, + Set categories, + Set keywords) {} + + /** 정책이 명시한 자격조건 중 하나라도 사용자가 벗어나면 추천 대상이 아니다. */ + private static boolean isEligible( + Policy policy, List provinces, UserMatchProfile user) { + return withinAgeRange(policy, user.age()) + && coversRegion(provinces, user.sidoName()) + && satisfiesCode( + policy.getJobCodes(), + user.jobCode(), + YouthPolicyCodeMapper.JOB_CODE_UNRESTRICTED) + && satisfiesCode( + policy.getSchoolCodes(), + user.schoolCode(), + YouthPolicyCodeMapper.SCHOOL_CODE_UNRESTRICTED) + && satisfiesCode( + policy.getMaritalStatusCode(), + user.maritalCode(), + YouthPolicyCodeMapper.MARITAL_CODE_UNRESTRICTED) + && satisfiesAnyCode( + policy.getMajorCodes(), + user.majorCodes(), + YouthPolicyCodeMapper.MAJOR_CODE_UNRESTRICTED) + && satisfiesAnyCode( + policy.getSpecializationCodes(), + user.specializationCodes(), + YouthPolicyCodeMapper.SPECIALIZATION_CODE_UNRESTRICTED) + && withinIncomeLimit(policy, user.income()); + } + + /** + * 연소득 상한을 넘으면 신청 자체가 불가하므로 제외한다. 금액 상한은 {@code earnCndSeCd}가 "연소득 기준"일 때만 유효하다 — 무관·기타 조건에서는 + * {@code incomeMaxAmount}가 채워져 있어도 판정 근거가 아니다. 소득 미입력(null)은 판정하지 않는다("소득 무관"과 "미설정"을 저장 단계에서 + * 구분하지 못하기 때문). + */ + private static boolean withinIncomeLimit(Policy policy, @Nullable Integer userIncome) { + if (userIncome == null + || !YouthPolicyCodeMapper.INCOME_CONDITION_ANNUAL_AMOUNT.equals( + policy.getIncomeConditionCode())) { + return true; + } + Integer maxAmount = policy.getIncomeMaxAmount(); + return maxAmount == null || maxAmount <= 0 || userIncome <= maxAmount; + } + + /** min/maxAge가 0 또는 NULL이면 제한없음이라 항상 통과한다. */ + private static boolean withinAgeRange(Policy policy, int age) { + Integer minAge = policy.getMinAge(); + Integer maxAge = policy.getMaxAge(); + if (minAge != null && minAge > 0 && age < minAge) { + return false; + } + return maxAge == null || maxAge <= 0 || age <= maxAge; + } + + /** 지역 매핑이 없으면 지역 제한이 없는 것으로 본다(프론트 {@code provincesToLabel}이 '전국'으로 표시하는 규칙과 동일). */ + private static boolean coversRegion(List provinces, String userSidoName) { + return provinces.isEmpty() || provinces.contains(userSidoName); + } + + /** + * 정책이 코드를 명시했고 제한없음도 아닌데 사용자 코드가 빠져 있으면 자격 미달. 사용자 코드를 변환하지 못했으면(null) 판정을 포기하고 통과시킨다 — 코드표 불일치 + * 때문에 후보를 통째로 날리는 것보다 낫다. + */ + private static boolean satisfiesCode( + @Nullable String policyCodes, @Nullable String userCode, String unrestrictedCode) { + Set codes = splitToSet(policyCodes); + if (codes.isEmpty() || codes.contains(unrestrictedCode) || userCode == null) { + return true; + } + return codes.contains(userCode); + } + + /** + * 자격을 통과한 정책의 적합도를 매긴다. 모든 축이 같은 규칙을 따른다 — "자격 통과"에는 주지 않고, 정책이 조건을 명시했고 사용자가 거기 해당할 때만 + * 준다. 제한없음(전국·학력무관 등)은 사용자를 겨냥한 게 아니므로 0점이다. + */ + private static RecommendedPolicyResponse score( + Policy policy, List provinces, UserMatchProfile user) { + int total = 0; + List matchedAxes = new ArrayList<>(); + + if (containsToken(user.categories(), policy.getCategory()) + || containsToken(user.categories(), policy.getMiddleCategory())) { + total += CATEGORY_WEIGHT; + matchedAxes.add("분야"); + } + if (!policy.isNationwide() && !provinces.isEmpty()) { + total += REGION_WEIGHT; + matchedAxes.add("지역"); + } + if (matchesAnyCode( + policy.getSpecializationCodes(), + user.specializationCodes(), + YouthPolicyCodeMapper.SPECIALIZATION_CODE_UNRESTRICTED)) { + total += SPECIALIZATION_WEIGHT; + matchedAxes.add("특화조건"); + } + if (matchesCode( + policy.getJobCodes(), + user.jobCode(), + YouthPolicyCodeMapper.JOB_CODE_UNRESTRICTED)) { + total += EMPLOYMENT_WEIGHT; + matchedAxes.add("취업"); + } + Set policyKeywords = splitToSet(policy.getKeywords()); + if (!user.keywords().isEmpty() + && !policyKeywords.isEmpty() + && !Collections.disjoint(user.keywords(), policyKeywords)) { + total += KEYWORD_WEIGHT; + matchedAxes.add("키워드"); + } + if (matchesCode( + policy.getSchoolCodes(), + user.schoolCode(), + YouthPolicyCodeMapper.SCHOOL_CODE_UNRESTRICTED)) { + total += EDUCATION_WEIGHT; + matchedAxes.add("학력"); + } + if (matchesAnyCode( + policy.getMajorCodes(), + user.majorCodes(), + YouthPolicyCodeMapper.MAJOR_CODE_UNRESTRICTED)) { + total += MAJOR_WEIGHT; + matchedAxes.add("전공"); + } + if (matchesCode( + policy.getMaritalStatusCode(), + user.maritalCode(), + YouthPolicyCodeMapper.MARITAL_CODE_UNRESTRICTED)) { + total += MARITAL_WEIGHT; + matchedAxes.add("결혼여부"); + } + if (statesAgeLimit(policy)) { + total += AGE_WEIGHT; + matchedAxes.add("나이"); + } + if (statesIncomeLimit(policy) && user.income() != null) { + total += INCOME_WEIGHT; + matchedAxes.add("소득"); + } + + return RecommendedPolicyResponse.of(policy, provinces, total, matchedAxes); + } + + /** 다중 선택(전공·특화조건)용 가점 판정. 제한없음이면 겨냥이 아니고, 사용자가 값을 안 넣었으면 판정할 게 없다. */ + private static boolean matchesAnyCode( + @Nullable String policyCodes, Set userCodes, String unrestrictedCode) { + if (userCodes.isEmpty()) { + return false; + } + Set codes = splitToSet(policyCodes); + return !codes.contains(unrestrictedCode) && !Collections.disjoint(codes, userCodes); + } + + /** + * 소득 가점은 정책이 실제 금액 상한을 건 경우에만 준다. 상한을 넘는 사용자는 이미 자격에서 걸러졌으므로, 여기 도달했다면 상한 이내라는 뜻이다 — 즉 "저소득 대상 + * 정책에 내가 해당"이라는 겨냥 신호다. + */ + private static boolean statesIncomeLimit(Policy policy) { + if (!YouthPolicyCodeMapper.INCOME_CONDITION_ANNUAL_AMOUNT.equals( + policy.getIncomeConditionCode())) { + return false; + } + Integer maxAmount = policy.getIncomeMaxAmount(); + return maxAmount != null && maxAmount > 0; + } + + /** 나이 가점은 정책이 나이 조건을 실제로 명시한 경우에만 준다(제한없음은 "내 조건과 일치"가 아니다). */ + private static boolean statesAgeLimit(Policy policy) { + Integer minAge = policy.getMinAge(); + Integer maxAge = policy.getMaxAge(); + return (minAge != null && minAge > 0) || (maxAge != null && maxAge > 0); + } + + /** 제한없음은 가점 대상이 아니다 — 자격 판정({@link #satisfiesCode})에서는 이미 통과시킨 뒤다. */ + private static boolean matchesCode( + @Nullable String policyCodes, @Nullable String userCode, String unrestrictedCode) { + if (userCode == null) { + return false; + } + Set codes = splitToSet(policyCodes); + return !codes.contains(unrestrictedCode) && codes.contains(userCode); + } + + /** + * 다중 선택(전공·특화조건)용 자격 판정. 사용자가 아무것도 고르지 않았으면 통과시킨다 — 온보딩에서 "해당 없음"과 "입력 안 함"을 구분해 저장하지 않아, 미입력을 + * 부적격으로 처리하면 정당한 후보를 잘못 지운다. + */ + private static boolean satisfiesAnyCode( + @Nullable String policyCodes, Set userCodes, String unrestrictedCode) { + Set codes = splitToSet(policyCodes); + if (codes.isEmpty() || codes.contains(unrestrictedCode) || userCodes.isEmpty()) { + return true; + } + return !Collections.disjoint(codes, userCodes); + } + + /** 콤마목록을 온통청년 코드 집합으로 변환한다. 변환 실패한 항목은 버린다(판정 불가). */ + private static Set mapAll( + @Nullable String commaSeparated, UnaryOperator toCode) { + return splitToSet(commaSeparated).stream() + .map(toCode) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + private static boolean containsToken(Set userTokens, @Nullable String value) { + String trimmed = trimToNull(value); + return trimmed != null && userTokens.contains(trimmed); + } + + private static Set splitToSet(@Nullable String commaSeparated) { + if (commaSeparated == null || commaSeparated.isBlank()) { + return Set.of(); + } + return Arrays.stream(commaSeparated.split(",")) + .map(String::trim) + .filter(token -> !token.isEmpty()) + .collect(Collectors.toSet()); + } + + @Nullable + private static String trimToNull(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** LIKE 특수문자(%/_)와 이스케이프 문자(!)를 이스케이프하고 부분일치 패턴으로 감싼다. */ + @Nullable + private static String toLikePattern(@Nullable String keyword) { + String normalized = trimToNull(keyword); + if (normalized == null) { + return null; + } + String escaped = normalized.replace("!", "!!").replace("%", "!%").replace("_", "!_"); + return "%" + escaped + "%"; + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapper.java b/src/main/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapper.java new file mode 100644 index 0000000..badaeee --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapper.java @@ -0,0 +1,146 @@ +package com.bop.youthpick.policy.service; + +import java.util.Map; +import java.util.Set; +import org.springframework.lang.Nullable; + +/** + * 온보딩 프로필이 저장하는 자체 코드({@code UNEMPLOYED} 등)를 정책이 저장하는 온통청년 코드({@code jobCd}/{@code schoolCd})로 + * 변환한다. + * + *

두 코드 체계가 갈라진 배경: 스키마 주석(V11, {@code docs/schema.sql})은 {@code + * user_profiles.employment_status}에 jobCd를 그대로 담을 것을 전제했지만, 실제 온보딩 화면(front-end {@code + * profileOptions.ts})은 의미 기반 자체 코드를 저장한다. 그래서 변환 없이 비교하면 취업·학력 축은 영원히 일치하지 않는다. + * + *

실데이터로 직접 확인한 코드: {@code 0013001}=재직자(청년근로자 교통비·일자리 안심공제), {@code + * 0013003}=미취업자(청년일자리도약장려금·국민취업지원제도), {@code 0013006}=(예비)창업자(청년창업 재정지원·청년스타트업), {@code 0049005}=대학 + * 재학(대학생 아르바이트·천원의 아침밥), {@code 0055001}=기혼(신혼부부 주거자금·출산지원금), {@code 0055002}=미혼(미혼남녀 만남 프로그램), + * {@code 0014002}=여성(여성새로일하기센터), {@code 0014005}=장애인(장애인재능키움센터), {@code 0014006}=농업인(청년농업인 영농정착), + * {@code 0014007}=군인(군복무 청년 상해보험), {@code 0011005}=공학계열(창의융합형 공학인재양성). + * + *

표준 코드표만 근거인 코드(다중선택 정책뿐이라 제목으로 특정하지 못함): 나머지 전공 계열({@code 0011001}~{@code 0011004}, + * {@code 0011006}, {@code 0011008})과 특화조건 {@code 0014003}(기초생활수급자)·{@code 0014004}(한부모가정)·{@code + * 0014008}(지역인재). 이 값들은 공식 코드표로 한 번 대조해야 하며, 틀리면 자격 필터가 정당한 정책을 사용자에게서 빼앗는다. + */ +final class YouthPolicyCodeMapper { + + private YouthPolicyCodeMapper() {} + + /** jobCd 제한없음 — 대상을 특정하지 않는 정책이라 "내 조건과 일치"로 보지 않는다. */ + static final String JOB_CODE_UNRESTRICTED = "0013010"; + + /** schoolCd 학력무관 — 위와 동일. */ + static final String SCHOOL_CODE_UNRESTRICTED = "0049010"; + + /** mrgSttsCd 제한없음. */ + static final String MARITAL_CODE_UNRESTRICTED = "0055003"; + + /** plcyMajorCd 전공무관. */ + static final String MAJOR_CODE_UNRESTRICTED = "0011009"; + + /** sbizCd 제한없음. */ + static final String SPECIALIZATION_CODE_UNRESTRICTED = "0014010"; + + /** + * earnCndSeCd 중 "연소득 기준" — 이 값일 때만 {@code incomeMaxAmount}가 실제 금액 상한이다(0043001=무관, 0043003=기타). + */ + static final String INCOME_CONDITION_ANNUAL_AMOUNT = "0043002"; + + private static final Map JOB_CODE_BY_EMPLOYMENT_STATUS = + Map.of( + "EMPLOYED", "0013001", + "SELF_EMPLOYED", "0013002", + "UNEMPLOYED", "0013003", + "FREELANCER", "0013004", + "STARTUP", "0013006", + "ETC", "0013009"); + + private static final Map SCHOOL_CODE_BY_EDUCATION_LEVEL = + Map.of( + "UNDER_HS", "0049001", + "HS_ENROLLED", "0049002", + "HS_GRADUATE", "0049004", + "UNIV_ENROLLED", "0049005", + "UNIV_EXPECTED", "0049006", + "UNIV_GRADUATE", "0049007", + "GRAD_SCHOOL", "0049008", + "ETC", "0049009"); + + private static final Map MARITAL_CODE_BY_STATUS = + Map.of("MARRIED", "0055001", "SINGLE", "0055002"); + + private static final Map MAJOR_CODE_BY_MAJOR = + Map.of( + "HUMANITIES", "0011001", + "SOCIAL_SCIENCE", "0011002", + "BUSINESS_ECONOMICS", "0011003", + "NATURAL_SCIENCE", "0011004", + "ENGINEERING", "0011005", + "ARTS_PHYSICAL", "0011006", + "ETC", "0011008"); + + private static final Map SPECIALIZATION_CODE_BY_CONDITION = + Map.of( + "WOMEN", "0014002", + "BASIC_LIVELIHOOD", "0014003", + "SINGLE_PARENT", "0014004", + "DISABLED", "0014005", + "FARMER", "0014006", + "VETERAN", "0014007", + "REGIONAL_TALENT", "0014008"); + + /** + * 매핑이 정의된 온보딩 취업상태 코드 전체. 온보딩 선택지가 늘었는데 이 표를 갱신하지 않으면 해당 사용자는 조용히 취업 축 0점이 되므로({@code + * YouthPolicyCodeMapperTest}가 어휘 enum과의 일치를 강제한다) 테스트에서 대조용으로 쓴다. + */ + static Set supportedEmploymentStatuses() { + return JOB_CODE_BY_EMPLOYMENT_STATUS.keySet(); + } + + /** 매핑이 정의된 온보딩 학력 코드 전체. 용도는 {@link #supportedEmploymentStatuses()}와 같다. */ + static Set supportedEducationLevels() { + return SCHOOL_CODE_BY_EDUCATION_LEVEL.keySet(); + } + + static Set supportedMaritalStatuses() { + return MARITAL_CODE_BY_STATUS.keySet(); + } + + static Set supportedMajors() { + return MAJOR_CODE_BY_MAJOR.keySet(); + } + + static Set supportedSpecialConditions() { + return SPECIALIZATION_CODE_BY_CONDITION.keySet(); + } + + @Nullable + static String toMaritalCode(@Nullable String maritalStatus) { + return maritalStatus == null ? null : MARITAL_CODE_BY_STATUS.get(maritalStatus); + } + + @Nullable + static String toMajorCode(@Nullable String major) { + return major == null ? null : MAJOR_CODE_BY_MAJOR.get(major); + } + + @Nullable + static String toSpecializationCode(@Nullable String specialCondition) { + return specialCondition == null + ? null + : SPECIALIZATION_CODE_BY_CONDITION.get(specialCondition); + } + + /** 매핑되지 않는 값(코드표 개정·오타 등)은 null — 호출부가 "판정 불가"로 다루고 정책을 배제하지 않는다. */ + @Nullable + static String toJobCode(@Nullable String employmentStatus) { + return employmentStatus == null + ? null + : JOB_CODE_BY_EMPLOYMENT_STATUS.get(employmentStatus); + } + + @Nullable + static String toSchoolCode(@Nullable String educationLevel) { + return educationLevel == null ? null : SCHOOL_CODE_BY_EDUCATION_LEVEL.get(educationLevel); + } +} diff --git a/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java index 45bfc38..7492cda 100644 --- a/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java +++ b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java @@ -1,9 +1,15 @@ package com.bop.youthpick.user.dto; +import com.bop.youthpick.user.entity.EducationLevel; +import com.bop.youthpick.user.entity.EmploymentStatus; +import com.bop.youthpick.user.entity.Major; +import com.bop.youthpick.user.entity.MaritalStatus; +import com.bop.youthpick.user.entity.SpecialCondition; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.util.List; @@ -18,13 +24,17 @@ public record UserProfileRequest( Integer birthYear, @NotBlank(message = "거주 지역은 필수입니다.") @Size(max = 10, message = "지역 코드 형식이 올바르지 않습니다.") String regionCode, - @NotBlank(message = "취업상태는 필수입니다.") @Size(max = 16, message = "취업상태 코드 형식이 올바르지 않습니다.") + @NotBlank(message = "취업상태는 필수입니다.") + @Pattern(regexp = EmploymentStatus.PATTERN, message = "취업상태 코드 형식이 올바르지 않습니다.") String employmentStatus, - @NotBlank(message = "학력은 필수입니다.") @Size(max = 16, message = "학력 코드 형식이 올바르지 않습니다.") + @NotBlank(message = "학력은 필수입니다.") + @Pattern(regexp = EducationLevel.PATTERN, message = "학력 코드 형식이 올바르지 않습니다.") String educationLevel, - @Size(max = 16, message = "결혼여부 코드 형식이 올바르지 않습니다.") String merryStatus, - List<@NotBlank String> major, - List<@NotBlank String> specialCondition, + @Pattern(regexp = MaritalStatus.PATTERN, message = "결혼여부 코드 형식이 올바르지 않습니다.") + String merryStatus, + List<@Pattern(regexp = Major.PATTERN, message = "전공 코드 형식이 올바르지 않습니다.") String> major, + List<@Pattern(regexp = SpecialCondition.PATTERN, message = "특화조건 코드 형식이 올바르지 않습니다.") String> + specialCondition, @Min(value = 0, message = "연소득이 올바르지 않습니다.") Integer income, @Size(max = 3, message = "관심분야는 최대 3개까지 선택할 수 있습니다.") List<@NotBlank String> categories, @Size(max = 19, message = "관심키워드는 최대 19개까지 선택할 수 있습니다.") List<@NotBlank String> keywords) {} diff --git a/src/main/java/com/bop/youthpick/user/entity/EducationLevel.java b/src/main/java/com/bop/youthpick/user/entity/EducationLevel.java new file mode 100644 index 0000000..416f2ec --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/EducationLevel.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.user.entity; + +/** + * 온보딩 학력 선택지. 이 목록이 서비스가 인정하는 학력 어휘의 정본이며, 온통청년 {@code schoolCd}로의 변환은 {@code + * YouthPolicyCodeMapper}가 담당한다. 문자열로 받는 이유는 {@link EmploymentStatus}와 같다. + */ +public enum EducationLevel { + UNDER_HS, + HS_ENROLLED, + HS_GRADUATE, + UNIV_ENROLLED, + UNIV_EXPECTED, + UNIV_GRADUATE, + GRAD_SCHOOL, + ETC; + + /** + * {@link EmploymentStatus#PATTERN}과 같은 이유로 손으로 적고, {@code ProfileCodePatternTest}가 일치를 강제한다. + */ + public static final String PATTERN = + "UNDER_HS|HS_ENROLLED|HS_GRADUATE|UNIV_ENROLLED|UNIV_EXPECTED|UNIV_GRADUATE|GRAD_SCHOOL|ETC"; +} diff --git a/src/main/java/com/bop/youthpick/user/entity/EmploymentStatus.java b/src/main/java/com/bop/youthpick/user/entity/EmploymentStatus.java new file mode 100644 index 0000000..d43e942 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/EmploymentStatus.java @@ -0,0 +1,24 @@ +package com.bop.youthpick.user.entity; + +/** + * 온보딩 취업상태 선택지. 이 목록이 서비스가 인정하는 취업상태 어휘의 정본이며, 온통청년 {@code jobCd}로의 변환은 {@code + * YouthPolicyCodeMapper}가 담당한다. + * + *

{@link UserProfile#getEmploymentStatus()}는 이 enum의 {@code name()}을 문자열로 저장한다 — 잘못된 enum 값이 + * Jackson 역직렬화 단계에서 터지지 않고 {@code C001} 입력값 오류로 내려가도록, 요청 DTO는 enum이 아니라 문자열 + {@link #PATTERN}으로 + * 받는다 (api-design 규칙). + */ +public enum EmploymentStatus { + UNEMPLOYED, + EMPLOYED, + SELF_EMPLOYED, + FREELANCER, + STARTUP, + ETC; + + /** + * {@code @Pattern}은 컴파일 상수만 받으므로 상수 목록을 손으로 한 번 더 적는다. 이 문자열과 위 상수가 어긋나면 {@code + * ProfileCodePatternTest}가 잡는다. + */ + public static final String PATTERN = "UNEMPLOYED|EMPLOYED|SELF_EMPLOYED|FREELANCER|STARTUP|ETC"; +} diff --git a/src/main/java/com/bop/youthpick/user/entity/Major.java b/src/main/java/com/bop/youthpick/user/entity/Major.java new file mode 100644 index 0000000..ec40035 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/Major.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.user.entity; + +/** + * 온보딩 전공 선택지(선택 입력, 다중). 온통청년 {@code plcyMajorCd} 변환은 {@code YouthPolicyCodeMapper}가 담당한다. + * + *

온통청년에는 있지만 온보딩에 없는 계열(농산업)이 있어 이 목록은 부분집합이다. + */ +public enum Major { + HUMANITIES, + SOCIAL_SCIENCE, + BUSINESS_ECONOMICS, + NATURAL_SCIENCE, + ENGINEERING, + ARTS_PHYSICAL, + ETC; + + /** + * {@link EmploymentStatus#PATTERN}과 같은 이유로 손으로 적고, {@code ProfileCodePatternTest}가 일치를 강제한다. + */ + public static final String PATTERN = + "HUMANITIES|SOCIAL_SCIENCE|BUSINESS_ECONOMICS|NATURAL_SCIENCE|ENGINEERING|ARTS_PHYSICAL|ETC"; +} diff --git a/src/main/java/com/bop/youthpick/user/entity/MaritalStatus.java b/src/main/java/com/bop/youthpick/user/entity/MaritalStatus.java new file mode 100644 index 0000000..f066d36 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/MaritalStatus.java @@ -0,0 +1,12 @@ +package com.bop.youthpick.user.entity; + +/** 온보딩 결혼여부 선택지(선택 입력). 온통청년 {@code mrgSttsCd} 변환은 {@code YouthPolicyCodeMapper}가 담당한다. */ +public enum MaritalStatus { + SINGLE, + MARRIED; + + /** + * {@link EmploymentStatus#PATTERN}과 같은 이유로 손으로 적고, {@code ProfileCodePatternTest}가 일치를 강제한다. + */ + public static final String PATTERN = "SINGLE|MARRIED"; +} diff --git a/src/main/java/com/bop/youthpick/user/entity/SpecialCondition.java b/src/main/java/com/bop/youthpick/user/entity/SpecialCondition.java new file mode 100644 index 0000000..02bf104 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/SpecialCondition.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.user.entity; + +/** + * 온보딩 특화조건 선택지(선택 입력, 다중). 온통청년 {@code sbizCd} 변환은 {@code YouthPolicyCodeMapper}가 담당한다. + * + *

온통청년에는 있지만 사용자가 주장할 수 없는 값(중소기업·기타)이 있어 이 목록은 부분집합이다. + */ +public enum SpecialCondition { + WOMEN, + BASIC_LIVELIHOOD, + SINGLE_PARENT, + DISABLED, + FARMER, + VETERAN, + REGIONAL_TALENT; + + /** + * {@link EmploymentStatus#PATTERN}과 같은 이유로 손으로 적고, {@code ProfileCodePatternTest}가 일치를 강제한다. + */ + public static final String PATTERN = + "WOMEN|BASIC_LIVELIHOOD|SINGLE_PARENT|DISABLED|FARMER|VETERAN|REGIONAL_TALENT"; +} diff --git a/src/main/java/com/bop/youthpick/user/exception/UserError.java b/src/main/java/com/bop/youthpick/user/exception/UserError.java index 7518134..83caf65 100644 --- a/src/main/java/com/bop/youthpick/user/exception/UserError.java +++ b/src/main/java/com/bop/youthpick/user/exception/UserError.java @@ -12,6 +12,8 @@ public enum UserError implements ErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "사용자를 찾을 수 없습니다."), DUPLICATE_USERNAME(HttpStatus.CONFLICT, "U002", "이미 사용 중인 아이디입니다."), PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 온보딩 프로필이 등록되어 있습니다."), + // 프로필 수정(PATCH)과 맞춤정책 조회(RecommendedPolicyService) 양쪽에서 쓴다 — 둘 다 "온보딩 프로필이 아직 없음"이라 + // 같은 코드로 통일한다. PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "U004", "온보딩 프로필을 찾을 수 없습니다."); private final HttpStatus status; diff --git a/src/main/resources/db/migration/V15__fix_user_profiles_code_comments.sql b/src/main/resources/db/migration/V15__fix_user_profiles_code_comments.sql new file mode 100644 index 0000000..eca228a --- /dev/null +++ b/src/main/resources/db/migration/V15__fix_user_profiles_code_comments.sql @@ -0,0 +1,14 @@ +-- ============================================================ +-- V15 — user_profiles 취업/학력 컬럼 주석 정정 +-- V1/V11은 이 두 컬럼에 온통청년 jobCd/schoolCd가 그대로 들어간다고 적었지만, +-- 실제 온보딩(front-end profileOptions.ts)은 'UNEMPLOYED'/'UNIV_GRADUATE' 같은 +-- 서비스 자체 코드를 저장한다. 잘못된 주석 때문에 맞춤정책 매칭이 두 코드 체계를 +-- 그대로 비교해 취업·학력 축이 한 번도 일치하지 않는 버그가 있었다. +-- 변환은 YouthPolicyCodeMapper가 담당한다(적용 완료된 V11은 checksum 때문에 수정하지 않고 여기서 덮어쓴다). +-- ============================================================ + +ALTER TABLE user_profiles + MODIFY COLUMN employment_status VARCHAR(16) NOT NULL + COMMENT '온보딩 취업상태 코드(UNEMPLOYED 등). jobCd 변환은 YouthPolicyCodeMapper (REC 취업 15점)', + MODIFY COLUMN education_level VARCHAR(16) NOT NULL + COMMENT '온보딩 학력 코드(UNIV_GRADUATE 등). schoolCd 변환은 YouthPolicyCodeMapper (REC 학력 10점)'; diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java index 181913d..c19f819 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -19,6 +19,7 @@ import org.springframework.context.annotation.Import; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; // @DataJpaTest는 슬라이스 스캔 대상에서 일반 @Configuration을 제외하므로, BaseEntity의 @@ -90,6 +91,29 @@ class PolicyRepositoryTest { .isEqualTo(1); } + // RecommendedPolicyService는 점수 계산을 위해 후보를 페이지 없이(Pageable.unpaged()) 전량 조회한다. + // 목록 조회(getCards)는 항상 PageRequest를 넘기므로 이 경로는 여기서만 실제로 실행된다. + @Test + void 카드_조회는_unpaged와_나이_조건_없이도_노출_중인_정책만_반환한다() { + policyRepository.save(newPolicy("P001", PolicyVisibility.VISIBLE, null)); + policyRepository.save(newPolicy("P002", PolicyVisibility.VISIBLE, LocalDateTime.now())); + policyRepository.save(newPolicy("P003", PolicyVisibility.HIDDEN, null)); + + Page page = + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + null, + null, + null, + null, + null, + null, + Pageable.unpaged()); + + assertThat(page.getContent()).extracting(Policy::getPolicyNo).containsExactly("P001"); + } + @Test void 신청마감일이_없어도_사업기간이_이미_지났으면_카드_목록에서_제외한다() { LocalDate today = LocalDate.of(2026, 7, 21); diff --git a/src/test/java/com/bop/youthpick/policy/service/RecommendedPolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/RecommendedPolicyServiceTest.java new file mode 100644 index 0000000..ac8a1de --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/RecommendedPolicyServiceTest.java @@ -0,0 +1,340 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.policy.dto.RecommendedPolicyResponse; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.entity.Region; +import com.bop.youthpick.policy.repository.PolicyRegionRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.entity.UserProfile; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserProfileRepository; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.BeanUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class RecommendedPolicyServiceTest { + + private static final String SEOUL = "서울특별시"; + private static final String BUSAN = "부산광역시"; + + /** 프로필: 만 30세 · 서울 · 미취업(0013003) · 대학졸업(0049007) · 관심분야 일자리 · 키워드 청년 */ + private static final String USER_JOB_CODE = "0013003"; + + private static final String USER_SCHOOL_CODE = "0049007"; + + @Mock private PolicyRepository policyRepository; + @Mock private PolicyRegionRepository policyRegionRepository; + @Mock private UserProfileRepository userProfileRepository; + + private RecommendedPolicyService recommendedPolicyService; + + @BeforeEach + void setUp() { + recommendedPolicyService = + new RecommendedPolicyService( + policyRepository, policyRegionRepository, userProfileRepository); + } + + @Test + void 프로필이_없으면_PROFILE_NOT_FOUND_예외를_던진다() { + when(userProfileRepository.findByUserId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> recommendedPolicyService.getRecommendations(1L, null, null, null)) + .isInstanceOf(UserException.class) + .hasFieldOrPropertyWithValue("errorCode", UserError.PROFILE_NOT_FOUND); + } + + @Test + void 모든_축이_일치하면_100점을_받는다() { + Policy policy = newPolicy(1L, "청년 일자리 지원"); + setFields(policy, 20, 39, USER_JOB_CODE, USER_SCHOOL_CODE, "일자리", "청년,지원"); + // 프로필: 미혼(0055002) · 인문계열(0011001) · 여성(0014002) · 연소득 3000 + ReflectionTestUtils.setField(policy, "maritalStatusCode", "0055002"); + ReflectionTestUtils.setField(policy, "majorCodes", "0011001"); + ReflectionTestUtils.setField(policy, "specializationCodes", "0014002"); + ReflectionTestUtils.setField(policy, "incomeConditionCode", "0043002"); + ReflectionTestUtils.setField(policy, "incomeMaxAmount", 5000); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + List result = recommend(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).score()).isEqualTo(100); + assertThat(result.get(0).matchedAxes()) + .containsExactlyInAnyOrder( + "분야", "지역", "특화조건", "취업", "키워드", "학력", "전공", "결혼여부", "나이", "소득"); + } + + @Test + void 자격_나이를_벗어난_정책은_제외한다() { + Policy policy = newPolicy(1L, "만 20~24세 정책"); + setFields(policy, 20, 24, USER_JOB_CODE, USER_SCHOOL_CODE, "일자리", "청년"); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + assertThat(recommend()).isEmpty(); + } + + @Test + void 다른_시도_전용_정책은_제외한다() { + Policy policy = newPolicy(1L, "부산 전용 정책"); + setFields(policy, null, null, USER_JOB_CODE, USER_SCHOOL_CODE, "일자리", "청년"); + givenCandidates(List.of(policy), Map.of(1L, List.of(BUSAN))); + + assertThat(recommend()).isEmpty(); + } + + @Test + void 사용자_취업상태를_대상으로_하지_않는_정책은_제외한다() { + Policy policy = newPolicy(1L, "재직자 전용 정책"); + // 0013001 = 재직자. 미취업(0013003) 사용자는 자격 미달. + setFields(policy, null, null, "0013001", USER_SCHOOL_CODE, "일자리", "청년"); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + assertThat(recommend()).isEmpty(); + } + + @Test + void 제한없음_정책은_제외하지_않지만_취업_학력_가점도_주지_않는다() { + Policy policy = newPolicy(1L, "누구나 신청 가능 정책"); + // 0013010 = 취업무관, 0049010 = 학력무관, 나이·관심사도 불일치 → 지역(18)만 남는다. + setFields(policy, null, null, "0013010", "0049010", "주거", "전세"); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + List result = recommend(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).score()).isEqualTo(18); + assertThat(result.get(0).matchedAxes()).containsExactly("지역"); + } + + @Test + void 지역_매핑이_없는_정책은_전국으로_보고_제외하지_않는다() { + Policy policy = newPolicy(1L, "지역 매핑 없는 정책"); + setFields(policy, null, null, "0013010", "0049010", "주거", "전세"); + givenCandidates(List.of(policy), Map.of()); + + List result = recommend(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).score()).isZero(); + assertThat(result.get(0).matchedAxes()).isEmpty(); + } + + @Test + void 전국_정책은_지역_가점을_받지_않는다() { + // 전국은 "지역 제한을 안 둔" 것이라 나를 겨냥한 게 아니다 — 취업·학력의 제한없음과 같은 취급. + Policy policy = newPolicy(1L, "전국 대상 정책"); + setFields(policy, null, null, "0013010", "0049010", "주거", "전세"); + ReflectionTestUtils.setField(policy, "nationwide", true); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + List result = recommend(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).score()).isZero(); + assertThat(result.get(0).matchedAxes()).isEmpty(); + } + + @Test + void 내_시도_특화_정책만_지역_가점을_받는다() { + Policy nationwide = newPolicy(1L, "전국 대상 정책"); + setFields(nationwide, null, null, "0013010", "0049010", "주거", "전세"); + ReflectionTestUtils.setField(nationwide, "nationwide", true); + Policy local = newPolicy(2L, "서울 전용 정책"); + setFields(local, null, null, "0013010", "0049010", "주거", "전세"); + givenCandidates(List.of(nationwide, local), Map.of(1L, List.of(SEOUL), 2L, List.of(SEOUL))); + + List result = recommend(); + + assertThat(result).extracting(RecommendedPolicyResponse::id).containsExactly(2L, 1L); + assertThat(result.get(0).score()).isEqualTo(18); + assertThat(result.get(1).score()).isZero(); + } + + @Test + void 기혼자_전용_정책은_미혼_사용자에게서_제외한다() { + Policy policy = newPolicy(1L, "신혼부부 전세자금 지원"); + setFields(policy, null, null, "0013010", "0049010", "주거", "전세"); + // 0055001 = 기혼. 프로필은 SINGLE(0055002). + ReflectionTestUtils.setField(policy, "maritalStatusCode", "0055001"); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + assertThat(recommend()).isEmpty(); + } + + @Test + void 다른_전공_전용_정책은_제외한다() { + Policy policy = newPolicy(1L, "공학인재 양성"); + setFields(policy, null, null, "0013010", "0049010", "일자리", "청년"); + // 0011005 = 공학계열. 프로필은 HUMANITIES(0011001). + ReflectionTestUtils.setField(policy, "majorCodes", "0011005"); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + assertThat(recommend()).isEmpty(); + } + + @Test + void 해당하지_않는_특화조건_전용_정책은_제외한다() { + Policy policy = newPolicy(1L, "청년농업인 영농정착 지원"); + setFields(policy, null, null, "0013010", "0049010", "일자리", "청년"); + // 0014006 = 농업인. 프로필은 WOMEN(0014002). + ReflectionTestUtils.setField(policy, "specializationCodes", "0014006"); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + assertThat(recommend()).isEmpty(); + } + + @Test + void 연소득_상한을_넘으면_제외한다() { + Policy policy = newPolicy(1L, "저소득 청년 지원"); + setFields(policy, null, null, "0013010", "0049010", "주거", "전세"); + // earnCndSeCd=0043002(연소득 기준), 상한 2000만원. 프로필 소득은 3000만원. + ReflectionTestUtils.setField(policy, "incomeConditionCode", "0043002"); + ReflectionTestUtils.setField(policy, "incomeMaxAmount", 2000); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + assertThat(recommend()).isEmpty(); + } + + @Test + void 소득조건이_금액기준이_아니면_상한값이_있어도_제외하지_않는다() { + Policy policy = newPolicy(1L, "소득 무관 정책"); + setFields(policy, null, null, "0013010", "0049010", "주거", "전세"); + // 0043001 = 소득 무관 — 상한값이 채워져 있어도 판정 근거가 아니다. + ReflectionTestUtils.setField(policy, "incomeConditionCode", "0043001"); + ReflectionTestUtils.setField(policy, "incomeMaxAmount", 2000); + givenCandidates(List.of(policy), Map.of(1L, List.of(SEOUL))); + + assertThat(recommend()).hasSize(1); + } + + @Test + void 점수가_높은_정책이_먼저_온다() { + Policy low = newPolicy(1L, "지역만 맞는 정책"); + setFields(low, null, null, "0013010", "0049010", "주거", "전세"); + Policy high = newPolicy(2L, "관심분야까지 맞는 정책"); + setFields(high, null, null, "0013010", "0049010", "일자리", "전세"); + givenCandidates(List.of(low, high), Map.of(1L, List.of(SEOUL), 2L, List.of(SEOUL))); + + List result = recommend(); + + assertThat(result).extracting(RecommendedPolicyResponse::id).containsExactly(2L, 1L); + assertThat(result.get(0).score()).isEqualTo(38); + assertThat(result.get(1).score()).isEqualTo(18); + } + + private List recommend() { + when(userProfileRepository.findByUserId(1L)).thenReturn(Optional.of(newProfile())); + return recommendedPolicyService.getRecommendations(1L, null, null, null); + } + + /** findCards 스텁 + 정책별 시도명 매핑 스텁을 한 번에 세운다. */ + private void givenCandidates( + List policies, Map> sidoNamesByPolicyId) { + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + any(Pageable.class))) + .thenReturn(pageOf(policies)); + + List policyIds = policies.stream().map(Policy::getId).toList(); + List policyRegions = + policies.stream() + .flatMap( + policy -> + sidoNamesByPolicyId + .getOrDefault(policy.getId(), List.of()) + .stream() + .map(sido -> newPolicyRegion(policy, sido))) + .toList(); + lenient() + .when(policyRegionRepository.findWithRegionByPolicyIdIn(policyIds)) + .thenReturn(policyRegions); + } + + private static Page pageOf(List policies) { + return new PageImpl<>(policies); + } + + private static Policy newPolicy(Long id, String title) { + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "id", id); + ReflectionTestUtils.setField(policy, "policyNo", "R2026" + id); + ReflectionTestUtils.setField(policy, "title", title); + return policy; + } + + private static void setFields( + Policy policy, + Integer minAge, + Integer maxAge, + String jobCodes, + String schoolCodes, + String category, + String keywords) { + ReflectionTestUtils.setField(policy, "minAge", minAge); + ReflectionTestUtils.setField(policy, "maxAge", maxAge); + ReflectionTestUtils.setField(policy, "jobCodes", jobCodes); + ReflectionTestUtils.setField(policy, "schoolCodes", schoolCodes); + ReflectionTestUtils.setField(policy, "category", category); + ReflectionTestUtils.setField(policy, "keywords", keywords); + } + + private static PolicyRegion newPolicyRegion(Policy policy, String sidoName) { + return PolicyRegion.create(policy, newRegion(sidoName)); + } + + private static Region newRegion(String sidoName) { + Region region = BeanUtils.instantiateClass(Region.class); + ReflectionTestUtils.setField(region, "code", sidoName + "-code"); + ReflectionTestUtils.setField(region, "sidoName", sidoName); + ReflectionTestUtils.setField(region, "name", sidoName); + return region; + } + + private static UserProfile newProfile() { + User user = User.createSocialUser("google", "gid-1", "user@example.com", "닉네임"); + return UserProfile.create( + user, + newRegion(SEOUL), + LocalDate.now().getYear() - 30, + "UNEMPLOYED", + "UNIV_GRADUATE", + "SINGLE", + "HUMANITIES", + "WOMEN", + 3000, + "일자리", + "청년"); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapperTest.java b/src/test/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapperTest.java new file mode 100644 index 0000000..b0cb011 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/YouthPolicyCodeMapperTest.java @@ -0,0 +1,113 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.user.entity.EducationLevel; +import com.bop.youthpick.user.entity.EmploymentStatus; +import com.bop.youthpick.user.entity.Major; +import com.bop.youthpick.user.entity.MaritalStatus; +import com.bop.youthpick.user.entity.SpecialCondition; +import java.util.Arrays; +import java.util.List; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +/** + * 온보딩 어휘({@link EmploymentStatus}/{@link EducationLevel}) ↔ 온통청년 코드 매핑이 어긋나지 않도록 잡는 가드. + * + *

매핑이 빠지면 예외가 나는 게 아니라 조용히 해당 축 0점이 된다(실제로 그 상태로, 취업·학력 축이 한 번도 매칭되지 않은 채 전체 정책이 추천으로 + * 나갔다). 그래서 런타임이 아니라 빌드에서 잡는다 — enum에 상수를 추가하고 매퍼를 갱신하지 않으면 여기서 깨진다. + */ +class YouthPolicyCodeMapperTest { + + @Test + void 모든_취업상태_상수가_매핑돼_있다() { + assertThat(YouthPolicyCodeMapper.supportedEmploymentStatuses()) + .containsExactlyInAnyOrderElementsOf(names(EmploymentStatus.values())); + } + + @Test + void 모든_학력_상수가_매핑돼_있다() { + assertThat(YouthPolicyCodeMapper.supportedEducationLevels()) + .containsExactlyInAnyOrderElementsOf(names(EducationLevel.values())); + } + + @Test + void 모든_취업상태가_서로_다른_jobCd로_변환된다() { + List jobCodes = + Arrays.stream(EmploymentStatus.values()) + .map(status -> YouthPolicyCodeMapper.toJobCode(status.name())) + .toList(); + + assertThat(jobCodes).doesNotContainNull().doesNotHaveDuplicates(); + } + + @Test + void 모든_학력이_서로_다른_schoolCd로_변환된다() { + List schoolCodes = + Arrays.stream(EducationLevel.values()) + .map(level -> YouthPolicyCodeMapper.toSchoolCode(level.name())) + .toList(); + + assertThat(schoolCodes).doesNotContainNull().doesNotHaveDuplicates(); + } + + /** 제한없음은 정책이 "대상을 안 가린다"는 표시일 뿐 사용자가 고를 수 있는 상태가 아니다 — 변환 결과로 나오면 자격 판정이 무너진다. */ + @Test + void 제한없음_코드는_어떤_선택지로도_변환되지_않는다() { + assertThat( + Arrays.stream(EmploymentStatus.values()) + .map(status -> YouthPolicyCodeMapper.toJobCode(status.name()))) + .doesNotContain(YouthPolicyCodeMapper.JOB_CODE_UNRESTRICTED); + assertThat( + Arrays.stream(EducationLevel.values()) + .map(level -> YouthPolicyCodeMapper.toSchoolCode(level.name()))) + .doesNotContain(YouthPolicyCodeMapper.SCHOOL_CODE_UNRESTRICTED); + } + + @Test + void 모든_결혼여부_상수가_서로_다른_코드로_매핑돼_있다() { + assertThat(YouthPolicyCodeMapper.supportedMaritalStatuses()) + .containsExactlyInAnyOrderElementsOf(names(MaritalStatus.values())); + assertThat(codesOf(MaritalStatus.values(), YouthPolicyCodeMapper::toMaritalCode)) + .doesNotContainNull() + .doesNotHaveDuplicates() + .doesNotContain(YouthPolicyCodeMapper.MARITAL_CODE_UNRESTRICTED); + } + + @Test + void 모든_전공_상수가_서로_다른_코드로_매핑돼_있다() { + assertThat(YouthPolicyCodeMapper.supportedMajors()) + .containsExactlyInAnyOrderElementsOf(names(Major.values())); + assertThat(codesOf(Major.values(), YouthPolicyCodeMapper::toMajorCode)) + .doesNotContainNull() + .doesNotHaveDuplicates() + .doesNotContain(YouthPolicyCodeMapper.MAJOR_CODE_UNRESTRICTED); + } + + @Test + void 모든_특화조건_상수가_서로_다른_코드로_매핑돼_있다() { + assertThat(YouthPolicyCodeMapper.supportedSpecialConditions()) + .containsExactlyInAnyOrderElementsOf(names(SpecialCondition.values())); + assertThat(codesOf(SpecialCondition.values(), YouthPolicyCodeMapper::toSpecializationCode)) + .doesNotContainNull() + .doesNotHaveDuplicates() + .doesNotContain(YouthPolicyCodeMapper.SPECIALIZATION_CODE_UNRESTRICTED); + } + + @Test + void 모르는_값이나_null은_변환하지_않고_null을_돌려준다() { + assertThat(YouthPolicyCodeMapper.toJobCode("NOT_A_REAL_STATUS")).isNull(); + assertThat(YouthPolicyCodeMapper.toSchoolCode("NOT_A_REAL_LEVEL")).isNull(); + assertThat(YouthPolicyCodeMapper.toJobCode(null)).isNull(); + assertThat(YouthPolicyCodeMapper.toSchoolCode(null)).isNull(); + } + + private static List names(Enum[] values) { + return Arrays.stream(values).map(Enum::name).toList(); + } + + private static List codesOf(Enum[] values, UnaryOperator toCode) { + return Arrays.stream(values).map(Enum::name).map(toCode).toList(); + } +} diff --git a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java index 55d46a6..326008c 100644 --- a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java @@ -43,10 +43,10 @@ class UserProfileControllerTest { "birthYear": 2000, "regionCode": "11110", "employmentStatus": "EMPLOYED", - "educationLevel": "UNIVERSITY", + "educationLevel": "UNIV_GRADUATE", "merryStatus": "SINGLE", - "major": ["COMPUTER_SCIENCE"], - "specialCondition": ["LOW_INCOME"], + "major": ["ENGINEERING"], + "specialCondition": ["BASIC_LIVELIHOOD"], "income": 3000, "categories": ["취업", "주거"], "keywords": ["청년", "공모전"] @@ -70,10 +70,10 @@ void clearContext() { when(profile.getRegion()).thenReturn(region); when(profile.getBirthYear()).thenReturn(2000); when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); - when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); + when(profile.getEducationLevel()).thenReturn("UNIV_GRADUATE"); when(profile.getMerryStatus()).thenReturn("SINGLE"); - when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); - when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); + when(profile.getMajor()).thenReturn("ENGINEERING"); + when(profile.getSpecialCondition()).thenReturn("BASIC_LIVELIHOOD"); when(profile.getIncome()).thenReturn(3000); when(profile.getCategories()).thenReturn("취업,주거"); when(profile.getKeywords()).thenReturn("청년,공모전"); @@ -89,8 +89,8 @@ void clearContext() { .andExpect(jsonPath("$.data.userId").value(1)) .andExpect(jsonPath("$.data.regionCode").value("11110")) .andExpect(jsonPath("$.data.merryStatus").value("SINGLE")) - .andExpect(jsonPath("$.data.major[0]").value("COMPUTER_SCIENCE")) - .andExpect(jsonPath("$.data.specialCondition[0]").value("LOW_INCOME")) + .andExpect(jsonPath("$.data.major[0]").value("ENGINEERING")) + .andExpect(jsonPath("$.data.specialCondition[0]").value("BASIC_LIVELIHOOD")) .andExpect(jsonPath("$.data.income").value(3000)) .andExpect(jsonPath("$.data.categories[0]").value("취업")) .andExpect(jsonPath("$.data.categories[1]").value("주거")); @@ -103,7 +103,7 @@ void clearContext() { { "regionCode": "11110", "employmentStatus": "EMPLOYED", - "educationLevel": "UNIVERSITY" + "educationLevel": "UNIV_GRADUATE" } """; @@ -122,7 +122,7 @@ void clearContext() { { "birthYear": 2000, "regionCode": "11110", - "educationLevel": "UNIVERSITY" + "educationLevel": "UNIV_GRADUATE" } """; @@ -134,6 +134,29 @@ void clearContext() { .andExpect(jsonPath("$.code").value("C001")); } + @Test + void 어휘에_없는_취업상태면_400과_C001을_반환한다() throws Exception { + // 검증이 없던 시절에는 이런 값도 그대로 저장돼, 맞춤정책 취업 축이 조용히 0점이 됐다. + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY.replace("\"EMPLOYED\"", "\"MILITARY\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")) + .andExpect(jsonPath("$.errors[0].field").value("employmentStatus")); + } + + @Test + void 어휘에_없는_학력이면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY.replace("\"UNIV_GRADUATE\"", "\"UNIVERSITY\""))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")) + .andExpect(jsonPath("$.errors[0].field").value("educationLevel")); + } + @Test void 온보딩된_사용자면_200과_프로필을_반환한다() throws Exception { UserProfile profile = mock(UserProfile.class); @@ -146,10 +169,10 @@ void clearContext() { when(profile.getRegion()).thenReturn(region); when(profile.getBirthYear()).thenReturn(2000); when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); - when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); + when(profile.getEducationLevel()).thenReturn("UNIV_GRADUATE"); when(profile.getMerryStatus()).thenReturn("SINGLE"); - when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); - when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); + when(profile.getMajor()).thenReturn("ENGINEERING"); + when(profile.getSpecialCondition()).thenReturn("BASIC_LIVELIHOOD"); when(profile.getIncome()).thenReturn(3000); when(profile.getCategories()).thenReturn("취업,주거"); when(profile.getKeywords()).thenReturn("청년,공모전"); diff --git a/src/test/java/com/bop/youthpick/user/entity/ProfileCodePatternTest.java b/src/test/java/com/bop/youthpick/user/entity/ProfileCodePatternTest.java new file mode 100644 index 0000000..62e21fb --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/entity/ProfileCodePatternTest.java @@ -0,0 +1,62 @@ +package com.bop.youthpick.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * {@code @Pattern}이 컴파일 상수만 받는 탓에 enum 상수 목록을 {@code PATTERN} 문자열로 한 번 더 적어야 한다. 둘이 어긋나면 검증이 조용히 + * 헐거워지거나(누락된 상수를 거부) 반대로 없는 코드를 통과시키므로, 여기서 일치를 강제한다. + */ +class ProfileCodePatternTest { + + @Test + void 취업상태_PATTERN은_enum_상수와_정확히_같다() { + assertThat(alternatives(EmploymentStatus.PATTERN)) + .containsExactlyInAnyOrderElementsOf(names(EmploymentStatus.values())); + } + + @Test + void 학력_PATTERN은_enum_상수와_정확히_같다() { + assertThat(alternatives(EducationLevel.PATTERN)) + .containsExactlyInAnyOrderElementsOf(names(EducationLevel.values())); + } + + @Test + void 결혼여부_전공_특화조건_PATTERN도_enum_상수와_정확히_같다() { + assertThat(alternatives(MaritalStatus.PATTERN)) + .containsExactlyInAnyOrderElementsOf(names(MaritalStatus.values())); + assertThat(alternatives(Major.PATTERN)) + .containsExactlyInAnyOrderElementsOf(names(Major.values())); + assertThat(alternatives(SpecialCondition.PATTERN)) + .containsExactlyInAnyOrderElementsOf(names(SpecialCondition.values())); + } + + @Test + void 모든_상수가_자기_PATTERN에_매칭된다() { + for (EmploymentStatus status : EmploymentStatus.values()) { + assertThat(status.name().matches(EmploymentStatus.PATTERN)).isTrue(); + } + for (EducationLevel level : EducationLevel.values()) { + assertThat(level.name().matches(EducationLevel.PATTERN)).isTrue(); + } + } + + @Test + void 어휘에_없는_값은_PATTERN에_매칭되지_않는다() { + // UNIVERSITY는 온보딩 선택지에 없는데도 검증이 없던 시절 테스트 픽스처로 쓰이던 값이다. + assertThat("UNIVERSITY".matches(EducationLevel.PATTERN)).isFalse(); + assertThat("MILITARY".matches(EmploymentStatus.PATTERN)).isFalse(); + assertThat("".matches(EmploymentStatus.PATTERN)).isFalse(); + } + + private static List alternatives(String pattern) { + return Arrays.stream(pattern.split("\\|")).toList(); + } + + private static List names(Enum[] values) { + return Arrays.stream(values).map(Enum::name).toList(); + } +} From a9f992e1151d3e4c570f3ab3b6581fd4e957e9cc Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 22 Jul 2026 15:38:54 +0900 Subject: [PATCH 57/93] =?UTF-8?q?chore:=20=EB=A1=9C=EC=BB=AC=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20CORS=20=ED=97=88=EC=9A=A9=20origin=EC=97=90=205174?= =?UTF-8?q?=20=ED=8F=AC=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite 기본 포트(5173)가 사용 중일 때 자동으로 5174로 올라오는 경우를 위해 로컬 프론트 개발 포트를 하나 더 허용한다. Co-authored-by: Claude Sonnet 5 --- .../java/com/bop/youthpick/global/config/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 8cdb51d..981b897 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -26,7 +26,7 @@ * *

인가 규칙: API 명세서(docs)의 권한 컬럼 기준

* - * 관리자 전용은 {@code hasRole("ADMIN")}, 회원 전용은 {@code authenticated()}, 비회원/공통은 {@code permitAll()}로 + *

관리자 전용은 {@code hasRole("ADMIN")}, 회원 전용은 {@code authenticated()}, 비회원/공통은 {@code permitAll()}로 * 매핑한다. 아직 컨트롤러가 없는 경로도 명세에 있으면 미리 규칙을 걸어 둔다(나중에 컨트롤러가 추가돼도 기본값이 열려 있지 않도록). 명세에 없는 나머지 경로는 여전히 개발 * 편의상 {@code anyRequest().permitAll()}로 열어 둔다. */ @@ -37,7 +37,7 @@ public class SecurityConfig { /** 프론트엔드 배포 주소. refresh token 쿠키 Origin 검증({@code AuthController})과 CORS 설정이 이 목록을 공유한다. */ public static final List ALLOWED_ORIGINS = - List.of("http://localhost:3000", "http://localhost:5173"); + List.of("http://localhost:3000", "http://localhost:5173", "http://localhost:5174"); private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; private final RestAccessDeniedHandler restAccessDeniedHandler; From bfcd2c764864d7ef15ab1f99724dd57eac8ccee7 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 22 Jul 2026 16:09:57 +0900 Subject: [PATCH 58/93] =?UTF-8?q?docs:=20=EB=A6=AC=EB=93=9C=EB=AF=B8=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EC=9E=91=EC=84=B1=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 리드미 새로 작성 깨진 플레이스홀더 대신 기술 스택, 도메인 패키지 구조, 로컬 실행 방법, 문서 링크를 담아 새로 작성 Co-Authored-By: Claude Sonnet 5 * fix: 리드미 인코딩을 UTF-16에서 UTF-8로 수정 이전 커밋에서 원본 파일의 UTF-16 인코딩이 그대로 유지되어 GitHub에서 깨져 보이는 문제를 수정 Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- README.md | Bin 38 -> 5010 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/README.md b/README.md index 4029aba3959fe34f2265c2250ebd0d23fa1183fd..54c229a575465a4c11381852ee81bf71869d59e0 100644 GIT binary patch literal 5010 zcmbtYYjYac8UD_%I2lc6z!j`7xz*ofNpbARu`ASgI&P-BzzQ}3mM)8IO_UjIgGZ5+ zv64WHTu{jb+j2%}(JjJb`HO#wKXT6ghd%E)3#=Tw?MyY2V9(_}@BMksjDqDRm5tCojNt#e(IEH zH5l*iiU&3ReUnXGyCfIxRrp#bHjvQYD~r2dzdTZs#+g`a<1p4gDD%fVVQ;m^w;JMk zCxV&h9sbV_Ut96kx_G*`E~-t&e{A-TdZN|S@FyO(#{HdQYlHQBUB3EAD&21O_Ya{r zog50?c<09CrO(3|*DVy!Yg*cJGo?f;^TT{DudW#^09P1{+R&)PSOTGEOw z<+50jN}I9bVmk8wii(8ZxpCQ8G)8i)Tsl_FSY|d=j9IoiN~0ywvuC2VW8~8&ZHgBh zE1zZ;Y}@@Wd)3O{ojpx!qvvAp#m>ARO*mFMdqIJ@W=sjSJaJ4T^kIxKwA zvF*hrHWiem8VM;v~zHTPimCq)}8NS5rr|>ax8a= zh2wJ*bJH`^@py#&o?X5^7dDbfvsly|bKWtF85+Ghf%FbvwC!}()IK#9O-K90STJmc zlo5Letbe%UH7e?$sS7ac>g*L9b!933#~Jn^1o)ax8Lq)DUz@q@RyT_+!}_83lBW*?trRz8|DbG8E;)Jgp3Z4`}K zK4~N~!Nk+50MF0{?b+W=p6GJ zJ#Vvt=yrH-U9?-I2`O~1jiM7z%4$E=*0U-$&e-X9zc|anm!~dFvI)d8pE4cHGEr}P zwRPHaTo$bZQANGWf+V{{`?lEHQKG~P*#f-#JN(HJB*qK2n}szjoR)!zenGiCZ#y}| z9cL3)us`K3m%TSW2*oh|b7s*+W>G48wF*@6Z9`1OkKItnTi^EX zbYLLGrN3Vhy+f~2hvu*ZU1=Tdi{G#Cr!{046A!zh(mr*HG6K8#qhrQv_~^;@)$iVB zfwkDa)nIzk&byAC%_6Carn6`zO{8AIE?O=^tEF69GxIJoRWolT&4SDm#=qaC9>u?{ zsqUofx@+EYLq#X4bgl#yfk*ZP;Z+#;i?Cz0~X9GLuE%cJ%PR~#72US==@Df((tqu$tlFr%*Bda#T zIlzH?hXA4uoPtygklLu8>Fc$QHqi(X0KkMsBH87173R? zLY;t^+`TO-2vWU*!uG1)AX=JuzRmYKXhxz{W;gVSi_@RZ#bddY{&_g24WA-nlV)Sd ztWip#OiSoTnp|>XC_;5Fx;+GV$RE&N;1IF(tygcr7V0+`f!1yCqlY!>7k75N`Y)kS zlwF?s1XxJeXAl<7*>*OWK~>A9v$X+l_|_4DYM1$13qtw7%E)TQe=VakE2Q9CPubHh@^r!|W{6<`cKs4(dCRhO>Xt%!%v z?7Pl5(^E<-^e$3Hr>;wO_q*RoRwm6SQwU00FEA}zD_`3lM<)(4g5TB&#Wry2YogHN zkrS&VTe=cmeCmQGQB#wBLPPt~Bo!EHQwLGqKrsLa5WEI&cgV$;rlt|>j`zdY5(g^i z)eSbCU$h)MpF@(fMZ>X-1d@k#qnGm^DT4joF24g0R(lu*J!ZOjt6)2>&b+@PNu)qO zpO_9^{u-a2oSmN2$*g%JnWQo;60g2%!az--g{T{;RlM`L8&!CH(KX>l*`y)^S z>rvDsu;Tr5AN)a&_+duA%ewKII2F#>7@^B=4+Lyba>>j)nMkZuU{L~*u>mcMy*oJg z13#r{t18TMxNH2j&8iTDLZkbM;(sznpKlXGLyxYkpiv>5uX&e}f;Tp3t*UF8!B1|B z_O~ZvJF+0Aff%9#+++zb@Y&J;u3(pVu^p1(TOvQ~$)4|D^x$VA7rMS3s@M68xSu$loAOK1q|w|8GdG_YnFZ z^L&$40DnnE!E3XGvY?KNr4oOA_%t*M++gKo#>C|~u#%wnaf^k?S}j4hAQU|rLgA=y z^Nm6Nh}OCvFSwhSL#0W#EcFevnBO_Tk@9YI@|N{>6r){qRluzcjRqXNT|owD{caQF zOmaY<@I0xAt*U5muvcYpU(7wMDbfJV`Oj6}eul@Rrg*kV7$)j3hL65_{K=uz>;|>< zqBc7{F?D5n0IMg5K-mXIK_9wRA(s4`T|ik-gi)}f#e$hUAtW$JVkGppFIw)$r3AZF zES5|r?t(z>vRC)MW4M;_EyMx{G8$AeKyNy{yC#=in+?u`rt+gMxV_9qZxa`ro?MnQ zowTX?M06zjJk#e*!(E=YZY@*zmT{XjlG>LRDHF|?atYJ Date: Wed, 22 Jul 2026 16:37:35 +0900 Subject: [PATCH 59/93] =?UTF-8?q?docs:=20=EB=A6=AC=EB=93=9C=EB=AF=B8?= =?UTF-8?q?=EC=97=90=20=EA=B5=AC=ED=98=84=20=ED=8A=B9=EC=A7=95=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트랜잭션 분리, 배치 중복 방지 락, 외부 데이터 정규화, refresh token 정책, 정책 비교 API 설계, 소프트삭제 재활성화, STOMP 하트비트, 에러코드 체계, admin 패키지 분리 등 코드에서 확인한 실제 구현 포인트를 정리 Closes #157 Co-authored-by: Claude Sonnet 5 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 54c229a..fbaf7db 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,18 @@ | 코드 포맷 | Spotless (google-java-format, AOSP 스타일) | | 테스트 | JUnit 5, Spring Security Test | +## 구현 특징 + +- **정책 상세 조회와 "최근 본 정책" 기록을 별도 트랜잭션으로 분리한다.** 조회 기록은 `REQUIRES_NEW`로 실행해, 기록 저장이 실패해도 그 실패가 상세 조회 응답 자체를 rollback-only로 끌고 내려가지 않는다. +- **배치 동기화는 Redis 락으로 중복 실행을 막는다.** 온통청년 공공 API 전량 수집 스케줄러는 SETNX+TTL+소유 토큰 방식의 락을 쓰고, 해제는 Lua 스크립트로 원자적으로 처리해 소유자가 아닌 프로세스가 잘못 해제하는 경우를 막는다. +- **외부 API 원본 데이터의 결측·형식 문제를 매퍼 계층에서 근본적으로 정규화한다.** 신청기간이 없는 정책의 폴백 처리, 참고 URL 형식 보정을 매퍼에 적용하고, 기존에 이미 적재된 데이터까지 백필하는 마이그레이션을 함께 실행했다. +- **refresh token은 rotate하지 않고 최초 TTL을 그대로 재사용한다.** HttpOnly 쿠키로만 전달하고(XSS 방어) 유효성의 근원은 Redis에 둔다. 재발급 시 access token만 새로 내려주고 쿠키는 다시 세팅하지 않는다. +- **정책 비교는 상태를 저장하지 않는 단일 `GET`이다.** 비교 대상 정책 ID를 쿼리 파라미터로 받아 그때그때 계산해 응답하므로, 요청 URL 자체가 그대로 공유 가능한 링크가 된다. +- **정책 신청(트래커)은 물리 삭제 대신 소프트 삭제 + 재활성화 구조다.** 관심 해제 후 다시 등록하면 새 행을 만들지 않고 기존 soft-deleted 행을 되살리며, 연결된 준비 체크리스트도 함께 정리한다. +- **정책 채팅(STOMP)은 프론트와 하트비트 주기를 명시적으로 맞춘다.** Spring `SimpleBroker`는 heartbeat 값을 선언하지 않으면 기본 비활성으로 협상되어 유휴 커넥션이 중간 인프라에서 조용히 끊길 수 있어, 전용 `TaskScheduler`로 10초 하트비트를 명시했다. +- **에러 응답은 도메인 접두어 체계를 가진 `ErrorCode`로 통일한다.** `C`(공통)·`A`(인증)·`U`(사용자)·`P`(정책) 등 접두어 + 3자리 코드로 프론트가 `code` 기준 메시지를 매핑하고, `GlobalExceptionHandler`가 모든 예외를 같은 형식의 `ErrorResponse`로 변환한다. +- **관리자 전용 코드는 도메인 패키지가 아니라 `admin.{domain}`에 모은다.** 여러 도메인이 공유하는 Entity/Repository/ErrorCode는 그대로 도메인 패키지에 두고 admin 쪽에서 참조하는 단방향 의존만 허용해, 일반 API와 관리자 API의 책임을 분리한다. + ## 프로젝트 구조 패키지는 계층형이 아닌 **도메인 우선** 구조를 따른다. 각 도메인 아래 `controller/service/repository/dto/entity/exception` 등을 둔다. From 286372adf7f9c7e53325ed1e5cbde0e3262c705d Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 22 Jul 2026 16:42:11 +0900 Subject: [PATCH 60/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20STOMP=20=EB=B8=8C=EB=A1=9C=EC=BB=A4=EC=97=90=20?= =?UTF-8?q?=ED=95=98=ED=8A=B8=EB=B9=84=ED=8A=B8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트 STOMP 클라이언트는 heartbeatIncoming/Outgoing을 10초로 설정하지만, SimpleBroker는 heartbeat 값과 TaskScheduler가 없어 기본값(비활성)으로 CONNECT 협상이 이뤄져 실제로는 하트비트가 오가지 않았다. 유휴 커넥션이 중간 인프라의 idle timeout에 조용히 끊기고 재연결이 반복되던 원인. Closes #155 Co-authored-by: Claude Sonnet 5 --- .../global/config/WebSocketConfig.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java index 97d29fc..3d88b86 100644 --- a/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java @@ -9,6 +9,8 @@ import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; @@ -18,6 +20,13 @@ @RequiredArgsConstructor public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + /** + * 프론트 STOMP 클라이언트({@code usePolicyChat.ts})의 heartbeatIncoming/Outgoing과 동일하게 맞춘다. 서버가 + * heartbeat 값을 선언하지 않으면(기본값 {0, 0}) CONNECT 협상에서 하트비트가 꺼진 채로 합의되어 실제로는 오가지 않고, 유휴 커넥션이 중간 인프라의 + * idle timeout에 조용히 끊긴 뒤 재연결이 반복된다. + */ + private static final long HEARTBEAT_INTERVAL_MS = 10_000; + private final PolicyChatInboundInterceptor policyChatInboundInterceptor; private final ObjectMapper objectMapper; @@ -32,10 +41,20 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); registry.setUserDestinationPrefix("/user/"); - registry.enableSimpleBroker("/queue"); + registry.enableSimpleBroker("/queue") + .setHeartbeatValue(new long[] {HEARTBEAT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS}) + .setTaskScheduler(stompHeartbeatScheduler()); registry.setPreservePublishOrder(true); } + private TaskScheduler stompHeartbeatScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("stomp-heartbeat-"); + scheduler.initialize(); + return scheduler; + } + @Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(policyChatInboundInterceptor); From 41b39ffd2f494d207b25591479079b52419463e9 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 16:56:27 +0900 Subject: [PATCH 61/93] =?UTF-8?q?fix:=20=ED=94=84=EB=A0=88=EC=9E=84?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=20=ED=91=9C=EC=A4=80=204xx=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A5=BC=20catch-all=20500=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=ED=95=9C=20=EC=83=81=ED=83=9C=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../global/error/GlobalExceptionHandler.java | 85 ++++++++++++++++++- ...GlobalExceptionHandlerIntegrationTest.java | 61 +++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/bop/youthpick/global/error/GlobalExceptionHandlerIntegrationTest.java diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index ddf503c..9b57368 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -6,18 +6,24 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.ServletRequestBindingException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.servlet.resource.NoResourceFoundException; /** * 전역 예외 처리. 모든 컨트롤러 예외를 여기서 {@link ErrorResponse}로 변환한다. 컨트롤러는 try-catch로 에러 응답을 직접 만들지 않는다(여기로 @@ -136,6 +142,80 @@ public ResponseEntity handleMethodNotSupported() { return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } + // 매핑되지 않은 경로(오타 URL, favicon.ico, 스캐너 봇 등). catch-all로 흘러가면 요청마다 500 + ERROR 로그가 + // application_logs에 적재되므로 여기서 404로 끊는다. 로깅하지 않는다. + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFound() { + ErrorCode errorCode = GlobalErrorCode.RESOURCE_NOT_FOUND; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); + } + + // Content-Type이 엔드포인트가 소비하는 타입과 다른 경우 (예: JSON 엔드포인트에 text/plain 전송) + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleMediaTypeNotSupported() { + ErrorCode errorCode = GlobalErrorCode.UNSUPPORTED_MEDIA_TYPE; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); + } + + // Accept 헤더로 협상 가능한 표현이 없는 경우. 클라이언트가 JSON을 받지 못하는 상황이므로 + // body를 쓰지 않고 상태 코드만 내린다(body를 쓰면 같은 예외가 재발한다). + @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) + public ResponseEntity handleMediaTypeNotAcceptable() { + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); + } + + // 필수 헤더/쿠키 누락 등 요청 바인딩 실패 + @ExceptionHandler(ServletRequestBindingException.class) + public ResponseEntity handleServletRequestBinding() { + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); + } + + // Pageable의 ?sort=존재하지않는속성 등 Spring Data가 속성을 해석하지 못한 경우. + // 사용자 입력 오류이므로 500(S001)이 아니라 C001로 내린다. + @ExceptionHandler(PropertyReferenceException.class) + public ResponseEntity handlePropertyReference( + PropertyReferenceException exception) { + ErrorResponse.FieldErrorDetail detail = + new ErrorResponse.FieldErrorDetail( + "sort", exception.getPropertyName(), "정렬할 수 없는 속성입니다."); + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, List.of(detail))); + } + + // 컨트롤러에 @Validated 없이 파라미터 제약만 있을 때 Spring 6.1+ 내장 검증이 던지는 예외. + // ConstraintViolationException 경로와 동일하게 C001로 수렴시킨다. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidation( + HandlerMethodValidationException exception) { + List details = + exception.getParameterValidationResults().stream() + .flatMap( + result -> + result.getResolvableErrors().stream() + .map( + error -> + new ErrorResponse.FieldErrorDetail( + result.getMethodParameter() + .getParameterName() + == null + ? "" + : result.getMethodParameter() + .getParameterName(), + result.getArgument() == null + ? "" + : String.valueOf( + result + .getArgument()), + error.getDefaultMessage()))) + .toList(); + + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, details)); + } + // 개발자가 예상치 못한 에러 — application_logs 테이블에 남도록 ERROR로 로깅한다. @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { @@ -160,7 +240,10 @@ enum GlobalErrorCode implements ErrorCode { HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 예기치 않은 오류가 발생했습니다."), INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 형태가 올바르지 않습니다."), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", "지원하지 않는 HTTP 메서드입니다."), - FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "C006", "파일 크기 제한을 초과했습니다."); + FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "C006", "파일 크기 제한을 초과했습니다."), + // C003~C006은 file 도메인(FileErrorCode)이 사용 중이라 C007부터 이어간다. + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "C007", "요청한 리소스를 찾을 수 없습니다."), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "C008", "지원하지 않는 요청 형식입니다."); private final HttpStatus status; private final String code; diff --git a/src/test/java/com/bop/youthpick/global/error/GlobalExceptionHandlerIntegrationTest.java b/src/test/java/com/bop/youthpick/global/error/GlobalExceptionHandlerIntegrationTest.java new file mode 100644 index 0000000..dbd0991 --- /dev/null +++ b/src/test/java/com/bop/youthpick/global/error/GlobalExceptionHandlerIntegrationTest.java @@ -0,0 +1,61 @@ +package com.bop.youthpick.global.error; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.service.JwtTokenProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 프레임워크 표준 예외가 catch-all(S001 500)로 새지 않고 정확한 4xx + 에러코드로 내려가는지 실제 dispatch 경로로 확인한다. 개별 핸들러가 없던 + * 시절에는 아래 케이스가 전부 500 + ERROR 로그(application_logs 적재)였다. + */ +@SpringBootTest +@AutoConfigureMockMvc +class GlobalExceptionHandlerIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + + @Test + void 존재하지_않는_경로는_404_C007로_응답한다() throws Exception { + mockMvc.perform(get("/api/v1/does-not-exist")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("C007")); + } + + @Test + void 존재하지_않는_정렬_속성은_400_C001로_응답한다() throws Exception { + String token = jwtTokenProvider.createAccessToken(1L, "USER"); + + mockMvc.perform( + get("/api/v1/policy-applications") + .param("sort", "notAProperty") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")) + .andExpect(jsonPath("$.errors[0].field").value("sort")) + .andExpect(jsonPath("$.errors[0].value").value("notAProperty")); + } + + @Test + void 지원하지_않는_ContentType은_415_C008로_응답한다() throws Exception { + String token = jwtTokenProvider.createAccessToken(1L, "USER"); + + mockMvc.perform( + post("/api/v1/posts") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.TEXT_PLAIN) + .content("plain text body")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.code").value("C008")); + } +} From 46629b6baee232c8ee27891d1f0cb148d1f9cc8d Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Wed, 22 Jul 2026 20:51:24 +0900 Subject: [PATCH 62/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EA=B5=AC=EB=8F=85=20receipt=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PolicyChatInboundInterceptor.java | 30 +++++++++++++++ .../PolicyChatInboundInterceptorTest.java | 38 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java index e0a0145..1372e53 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java @@ -14,12 +14,15 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageBuilder; import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; @@ -40,6 +43,18 @@ public class PolicyChatInboundInterceptor implements ChannelInterceptor { private final PolicyChatAccessService accessService; private final PolicyChatSubscriptionRegistry subscriptionRegistry; + /** + * Spring의 {@code StompSubProtocolHandler}는 DISCONNECT에만 RECEIPT를 자동 응답하고 SUBSCRIBE 등 다른 커맨드는 + * 애플리케이션이 직접 echo해야 한다. 프론트({@code usePolicyChat.ts})는 SUBSCRIBE에 receipt를 걸어 구독 완료를 확인하므로, 이를 + * 수동으로 echo하지 않으면 매번 receipt 대기가 타임아웃되어 실제로는 연결이 끊기지 않았는데도 "재연결 중" 상태로 잘못 표시된다. + * + *

{@code clientOutboundChannel}을 직접(eager) 주입하면 이 인터셉터를 등록하는 {@code WebSocketConfig} → + * {@code DelegatingWebSocketMessageBrokerConfiguration}(채널을 만드는 쪽) → 다시 {@code + * WebSocketConfig}로 순환 참조가 생겨 기동에 실패한다. {@link ObjectProvider}로 실제 사용 시점까지 조회를 미뤄 순환을 끊는다. + */ + @Qualifier("clientOutboundChannel") + private final ObjectProvider clientOutboundChannelProvider; + @Override public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = @@ -72,11 +87,26 @@ public void afterSendCompletion( } if (accessor.getCommand() == StompCommand.SUBSCRIBE) { register(accessor); + sendReceiptIfRequested(accessor); } else if (accessor.getCommand() == StompCommand.UNSUBSCRIBE) { unregister(accessor); } } + private void sendReceiptIfRequested(StompHeaderAccessor originalAccessor) { + String receiptId = originalAccessor.getReceipt(); + if (!StringUtils.hasText(receiptId)) { + return; + } + StompHeaderAccessor receiptAccessor = StompHeaderAccessor.create(StompCommand.RECEIPT); + receiptAccessor.setReceiptId(receiptId); + receiptAccessor.setSessionId(originalAccessor.getSessionId()); + receiptAccessor.setLeaveMutable(true); + Message receiptMessage = + MessageBuilder.createMessage(new byte[0], receiptAccessor.getMessageHeaders()); + clientOutboundChannelProvider.getObject().send(receiptMessage); + } + private void authenticate(StompHeaderAccessor accessor) { String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); if (!StringUtils.hasText(authorization) || !authorization.startsWith(BEARER_PREFIX)) { diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java index ca2964e..e173280 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java @@ -23,6 +23,7 @@ import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpHeaders; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -39,6 +40,7 @@ class PolicyChatInboundInterceptorTest { private PolicyChatSubscriptionRegistry registry; private PolicyChatInboundInterceptor interceptor; private MessageChannel channel; + private MessageChannel clientOutboundChannel; @BeforeEach void setUp() { @@ -51,9 +53,17 @@ void setUp() { accessService = mock(PolicyChatAccessService.class); userRepository = mock(UserRepository.class); registry = new PolicyChatSubscriptionRegistry(); + clientOutboundChannel = mock(MessageChannel.class); + @SuppressWarnings("unchecked") + ObjectProvider clientOutboundChannelProvider = mock(ObjectProvider.class); + when(clientOutboundChannelProvider.getObject()).thenReturn(clientOutboundChannel); interceptor = new PolicyChatInboundInterceptor( - jwtTokenProvider, userRepository, accessService, registry); + jwtTokenProvider, + userRepository, + accessService, + registry, + clientOutboundChannelProvider); channel = mock(MessageChannel.class); } @@ -198,6 +208,32 @@ void setUp() { assertThat(registry.registrationCount()).isOne(); } + @Test + void receipt를_요청한_SUBSCRIBE에는_같은_session과_receiptId로_응답한다() { + JwtStompAuthentication user = new JwtStompAuthentication(7L, "USER"); + Message subscribe = + frame( + StompCommand.SUBSCRIBE, + "/user/queue/policies/10/chat/messages", + user, + "session-1", + "sub-1"); + accessor(subscribe).setReceipt("receipt-1"); + + interceptor.afterSendCompletion(subscribe, channel, true, null); + + verify(clientOutboundChannel) + .send( + org.mockito.ArgumentMatchers.argThat( + message -> { + StompHeaderAccessor receiptAccessor = accessor(message); + return receiptAccessor != null + && receiptAccessor.getCommand() == StompCommand.RECEIPT + && "session-1".equals(receiptAccessor.getSessionId()) + && "receipt-1".equals(receiptAccessor.getReceiptId()); + })); + } + private Message frame( StompCommand command, String destination, From 2b149e7b9803dceaf4f98276f9fd039d6ffc2d01 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 21:06:32 +0900 Subject: [PATCH 63/93] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A0=9C=EC=B6=9C=20API=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=95=84=EC=88=98=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=B8=EC=9D=B8=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../global/config/SecurityConfig.java | 2 ++ .../controller/UserProfileController.java | 14 ++++++-- .../global/config/SecurityConfigTest.java | 7 ++++ .../controller/UserProfileControllerTest.java | 32 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 981b897..2ef5bcc 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -95,6 +95,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/v1/auth/me", "/api/v1/auth/logout", "/api/v1/users", + // 온보딩 프로필 제출 — path userId 신뢰 금지, 컨트롤러에서 principal 대조 + "/api/v1/users/*/profile", "/api/v1/me/**", "/api/v1/policy-recent-views", "/api/v1/recommended-policies") diff --git a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java index 01f4a4a..620eb82 100644 --- a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java +++ b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java @@ -1,5 +1,7 @@ package com.bop.youthpick.user.controller; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; import com.bop.youthpick.auth.service.CurrentUser; import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.user.dto.UserProfileRequest; @@ -23,11 +25,17 @@ public class UserProfileController { private final UserProfileService userProfileService; - // TODO: 인증 도입 후 @PathVariable userId를 인증 principal 기반으로 교체한다. + // 경로의 userId는 프론트 호환을 위해 유지하되, 실제 대상은 인증 principal이다. + // path와 principal이 다르면 타인 프로필 생성(IDOR)이므로 403으로 거부한다. @PostMapping("/api/v1/users/{userId}/profile") public ResponseEntity> submit( - @PathVariable Long userId, @Valid @RequestBody UserProfileRequest request) { - UserProfile profile = userProfileService.submit(userId, request); + @CurrentUser Long currentUserId, + @PathVariable Long userId, + @Valid @RequestBody UserProfileRequest request) { + if (!userId.equals(currentUserId)) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } + UserProfile profile = userProfileService.submit(currentUserId, request); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.ok(UserProfileResponse.from(profile))); } diff --git a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java index 70fad22..57a3bcc 100644 --- a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java +++ b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java @@ -57,6 +57,13 @@ class SecurityConfigTest { assertThat(status).isNotIn(401, 403); } + @Test + void 온보딩_프로필_제출에_인증없이_접근하면_401_A001() throws Exception { + mockMvc.perform(post("/api/v1/users/1/profile")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + @Test void 회원_전용_경로에_인증없이_접근하면_401() throws Exception { mockMvc.perform(get("/api/v1/me/favorites")).andExpect(status().isUnauthorized()); diff --git a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java index 326008c..01ccebf 100644 --- a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java @@ -80,6 +80,8 @@ void clearContext() { when(profile.getStatus()).thenReturn("COMPLETED"); when(userProfileService.submit(eq(1L), any())).thenReturn(profile); + authenticateAs(1L); + mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) .contentType(MediaType.APPLICATION_JSON) @@ -107,6 +109,8 @@ void clearContext() { } """; + authenticateAs(1L); + mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) .contentType(MediaType.APPLICATION_JSON) @@ -126,6 +130,8 @@ void clearContext() { } """; + authenticateAs(1L); + mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) .contentType(MediaType.APPLICATION_JSON) @@ -137,6 +143,8 @@ void clearContext() { @Test void 어휘에_없는_취업상태면_400과_C001을_반환한다() throws Exception { // 검증이 없던 시절에는 이런 값도 그대로 저장돼, 맞춤정책 취업 축이 조용히 0점이 됐다. + authenticateAs(1L); + mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) .contentType(MediaType.APPLICATION_JSON) @@ -148,6 +156,8 @@ void clearContext() { @Test void 어휘에_없는_학력이면_400과_C001을_반환한다() throws Exception { + authenticateAs(1L); + mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) .contentType(MediaType.APPLICATION_JSON) @@ -157,6 +167,28 @@ void clearContext() { .andExpect(jsonPath("$.errors[0].field").value("educationLevel")); } + @Test + void 경로의_userId가_본인이_아니면_403과_A008을_반환한다() throws Exception { + authenticateAs(1L); + + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 2L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("A008")); + } + + @Test + void 미인증_제출_요청이면_401과_A001을_반환한다() throws Exception { + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + @Test void 온보딩된_사용자면_200과_프로필을_반환한다() throws Exception { UserProfile profile = mock(UserProfile.class); From 0b9b8098bc9e0b209c39755d090214177f69c272 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 21:10:50 +0900 Subject: [PATCH 64/93] =?UTF-8?q?fix:=20posts=20=EA=B3=B5=EA=B0=9C=20API?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=20(category=20=ED=8C=A8=ED=84=B4=C2=B7=EC=9E=90=EC=9C=A0?= =?UTF-8?q?=EA=B8=80=20=EC=A0=95=EC=B1=85=20=EA=B1=B0=EB=B6=80=C2=B7?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EA=B8=B8=EC=9D=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../post/controller/PostController.java | 11 +++++- .../youthpick/post/dto/PostCreateRequest.java | 3 +- .../youthpick/post/dto/PostUpdateRequest.java | 3 +- .../youthpick/post/service/PostService.java | 6 ++-- .../PostControllerCategoryValidationTest.java | 35 +++++++++++++++++++ .../post/controller/PostControllerTest.java | 14 ++++++++ .../post/service/PostServiceTest.java | 15 ++++++++ 7 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/bop/youthpick/post/controller/PostControllerCategoryValidationTest.java diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java index 4b9a5a7..29b8f32 100644 --- a/src/main/java/com/bop/youthpick/post/controller/PostController.java +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -9,6 +9,7 @@ import com.bop.youthpick.post.service.PostService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -17,11 +18,15 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/posts") @RequiredArgsConstructor +// @RequestParam 제약(@Pattern 등)을 활성화한다. 없으면 잘못된 값이 서비스까지 내려가 +// PostCategory.valueOf에서 IllegalArgumentException → 500(S001)으로 터진다. +@Validated public class PostController { private final PostService postService; @@ -36,7 +41,11 @@ public ResponseEntity> create( @GetMapping // 리스폰스엔티티로 감싸야함 수정필요 public ApiResponse> findAll( - @RequestParam(required = false) String category, + @RequestParam(required = false) + @Pattern( + regexp = "QUESTION|REVIEW|FREE", + message = "카테고리는 QUESTION, REVIEW, FREE 중 하나여야 합니다.") + String category, @RequestParam(required = false) String query, @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { diff --git a/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java b/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java index 52c6a24..4632e11 100644 --- a/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java +++ b/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java @@ -14,7 +14,8 @@ public record PostCreateRequest( String category, @NotBlank(message = "제목은 필수입니다.") @Size(max = 100, message = "제목은 100자 이하여야 합니다.") String title, - @NotBlank(message = "내용은 필수입니다.") String content, + @NotBlank(message = "내용은 필수입니다.") @Size(max = 10_000, message = "내용은 10,000자 이하여야 합니다.") + String content, @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId, @Size(max = 10, message = "이미지는 최대 10개까지 첨부할 수 있습니다.") List< diff --git a/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java b/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java index cd54cdb..46d443d 100644 --- a/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java +++ b/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java @@ -14,7 +14,8 @@ public record PostUpdateRequest( String category, @NotBlank(message = "제목은 필수입니다.") @Size(max = 100, message = "제목은 100자 이하여야 합니다.") String title, - @NotBlank(message = "내용은 필수입니다.") String content, + @NotBlank(message = "내용은 필수입니다.") @Size(max = 10_000, message = "내용은 10,000자 이하여야 합니다.") + String content, @Positive(message = "정책 ID는 양수여야 합니다.") Long policyId, @Size(max = 10, message = "이미지는 최대 10개까지 첨부할 수 있습니다.") List< diff --git a/src/main/java/com/bop/youthpick/post/service/PostService.java b/src/main/java/com/bop/youthpick/post/service/PostService.java index 30c9983..e48525e 100644 --- a/src/main/java/com/bop/youthpick/post/service/PostService.java +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -111,10 +111,10 @@ private Post findPost(Long postId) { private Policy resolvePolicy(PostCategory category, Long policyId) { if (category == PostCategory.FREE) { + // 도메인 계약: policy가 NULL이면 자유글. 정책이 연결된 자유글을 허용하면 + // 목록/상세 응답에 policyTitle이 붙어 프론트 분류가 깨지므로 명시적으로 거부한다. if (policyId != null) { - return policyRepository - .findById(policyId) - .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); + throw new BoardException(BoardErrorCode.FREE_POST_POLICY_NOT_ALLOWED); } return null; } diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerCategoryValidationTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerCategoryValidationTest.java new file mode 100644 index 0000000..f9b006c --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerCategoryValidationTest.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.post.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 공개 목록 API의 category 검증은 {@code @Validated} 메서드 검증이라 standalone MockMvc로는 동작하지 않는다. 실제 dispatch + * 경로(@SpringBootTest)로 확인한다 — 검증이 없던 시절에는 잘못된 값이 {@code PostCategory.valueOf}까지 내려가 500(S001) + + * ERROR 로그가 됐다. + */ +@SpringBootTest +@AutoConfigureMockMvc +class PostControllerCategoryValidationTest { + + @Autowired private MockMvc mockMvc; + + @Test + void 어휘에_없는_카테고리로_목록을_조회하면_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/posts").param("category", "abc")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 유효한_카테고리로_목록을_조회하면_200을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/posts").param("category", "FREE")).andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java index 2117d5d..34e7d44 100644 --- a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -52,6 +52,20 @@ void setUp() { .build(); } + @Test + void 본문이_1만자를_넘으면_400과_C001을_반환한다() throws Exception { + String longContent = "가".repeat(10_001); + String body = + """ + {"category": "FREE", "title": "제목", "content": "%s"} + """ + .formatted(longContent); + + mockMvc.perform(post("/api/v1/posts").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + @Test void 게시글을_생성하면_201을_반환한다() throws Exception { when(postService.create(eq(1L), any())).thenReturn(detail(3L, "FREE", "제목", "내용")); diff --git a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java index 54acf7f..fe2b4a2 100644 --- a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -74,6 +74,21 @@ void setUp() { verify(policyRepository, never()).findById(any()); } + @Test + void 자유글에_정책을_연결하면_B004_예외가_발생한다() { + // 정책 검증 전에 예외가 나므로 user.getId()가 호출되지 않는다 — id 스텁 없는 mock을 쓴다. + PostCreateRequest request = new PostCreateRequest("FREE", "제목", "내용", 5L, null); + when(userRepository.findByIdAndDeletedAtIsNull(1L)) + .thenReturn(Optional.of(mock(User.class))); + + assertThatThrownBy(() -> postService.create(1L, request)) + .isInstanceOf(BoardException.class) + .extracting(exception -> ((BoardException) exception).getErrorCode()) + .isEqualTo(BoardErrorCode.FREE_POST_POLICY_NOT_ALLOWED); + verify(policyRepository, never()).findById(any()); + verify(postRepository, never()).save(any(Post.class)); + } + @Test void 게시글을_생성할_때_업로드된_이미지를_첨부로_저장한다() { User user = user(1L); From d16a0af3424f958ee05a6ffaa9cdb3634614d460 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 21:44:35 +0900 Subject: [PATCH 65/93] =?UTF-8?q?fix:=20auth=20=EA=B2=AC=EA=B3=A0=EC=84=B1?= =?UTF-8?q?=20=EB=B3=B4=EA=B0=95=20=E2=80=94=20soft-delete=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=B0=A8=EB=8B=A8,=20=EC=A0=9C=EC=9E=AC=20?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=ED=9A=8C=EC=88=98,=20state=20?= =?UTF-8?q?=EC=86=8C=EB=B9=84=20=EC=9B=90=EC=9E=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthService.refresh()/getCurrentUser()가 findByIdAndDeletedAtIsNull을 사용해 soft-delete(탈퇴/제재)된 사용자의 토큰 재발급·/auth/me를 차단 - login() find-or-create 경로에서 soft-delete된 계정 재로그인을 ACCOUNT_DISABLED(A009, 403)로 거부 - AdminUserService.softDelete() 시 RefreshTokenStore.delete(userId)로 refresh token 즉시 회수 (제재 실효성을 access token 만료 이내로) - OAuthStateStore.consume()을 getAndDelete(GETDEL) 기반으로 원자화해 동시 요청의 중복 소비 방지 Closes #167 Co-Authored-By: Claude Fable 5 --- .../admin/user/service/AdminUserService.java | 4 ++ .../auth/exception/AuthErrorCode.java | 3 +- .../youthpick/auth/service/AuthService.java | 17 +++++-- .../auth/service/OAuthStateStore.java | 14 +++--- .../user/service/AdminUserServiceTest.java | 17 ++++++- .../auth/service/AuthServiceTest.java | 46 ++++++++++++++++++- .../auth/service/OAuthStateStoreTest.java | 26 +++++++---- 7 files changed, 101 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java b/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java index c88a896..95e440a 100644 --- a/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java +++ b/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java @@ -3,6 +3,7 @@ import com.bop.youthpick.admin.user.dto.AdminUserProfileResponse; import com.bop.youthpick.admin.user.dto.AdminUserResponse; import com.bop.youthpick.admin.user.repository.AdminUserSpecifications; +import com.bop.youthpick.auth.service.RefreshTokenStore; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.exception.UserError; @@ -21,6 +22,7 @@ public class AdminUserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; + private final RefreshTokenStore refreshTokenStore; @Transactional(readOnly = true) public Page search( @@ -50,6 +52,8 @@ public AdminUserResponse updateRole(Long userId, String role) { public AdminUserResponse softDelete(Long userId) { User user = findUser(userId); user.softDelete(); + // refresh token을 즉시 회수해 제재 실효성을 access token 만료 이내로 확보한다. + refreshTokenStore.delete(userId); return AdminUserResponse.from(user); } diff --git a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java index 68e0bfd..cb2c4d1 100644 --- a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java @@ -15,7 +15,8 @@ public enum AuthErrorCode implements ErrorCode { OAUTH_PROVIDER_ERROR(HttpStatus.BAD_GATEWAY, "A005", "소셜 로그인 서버와 통신 중 오류가 발생했습니다."), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A006", "유효하지 않은 토큰입니다."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "A007", "리프레시 토큰이 유효하지 않습니다. 다시 로그인해주세요."), - FORBIDDEN(HttpStatus.FORBIDDEN, "A008", "접근 권한이 없습니다."); + FORBIDDEN(HttpStatus.FORBIDDEN, "A008", "접근 권한이 없습니다."), + ACCOUNT_DISABLED(HttpStatus.FORBIDDEN, "A009", "이용이 제한된 계정입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/bop/youthpick/auth/service/AuthService.java b/src/main/java/com/bop/youthpick/auth/service/AuthService.java index e36f2a7..5c0d8f8 100644 --- a/src/main/java/com/bop/youthpick/auth/service/AuthService.java +++ b/src/main/java/com/bop/youthpick/auth/service/AuthService.java @@ -82,9 +82,10 @@ public TokenResponse refresh(String refreshToken) { Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); Long userId = jwtTokenProvider.getUserId(claims); + // soft-delete(탈퇴/제재)된 사용자는 refresh token TTL이 남아 있어도 재발급을 거부한다. User user = userRepository - .findById(userId) + .findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new AuthException(AuthErrorCode.UNAUTHORIZED)); if (!refreshTokenStore.matches(userId, refreshToken)) { @@ -106,14 +107,20 @@ public void logout(Long userId) { @Transactional(readOnly = true) public User getCurrentUser(Long userId) { return userRepository - .findById(userId) + .findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new AuthException(AuthErrorCode.UNAUTHORIZED)); } private User findOrCreateUser(OAuthProvider provider, OAuthUserInfo userInfo) { - return userRepository - .findByProviderAndProviderId(provider.name(), userInfo.providerId()) - .orElseGet(() -> createUser(provider, userInfo)); + User user = + userRepository + .findByProviderAndProviderId(provider.name(), userInfo.providerId()) + .orElseGet(() -> createUser(provider, userInfo)); + // 탈퇴/제재(soft-delete)된 계정은 재로그인을 거부한다(재활성 아님, 팀 리뷰 결정사항). + if (user.getDeletedAt() != null) { + throw new AuthException(AuthErrorCode.ACCOUNT_DISABLED); + } + return user; } private User createUser(OAuthProvider provider, OAuthUserInfo userInfo) { diff --git a/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java b/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java index 2092ef1..5c9f9e1 100644 --- a/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java +++ b/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java @@ -22,15 +22,13 @@ public void save(String state, String provider) { redisTemplate.opsForValue().set(key(state), provider, TTL); } - /** state에 저장된 provider와 일치하면 소비(삭제)하고 true를 반환한다. */ + /** + * state를 원자적으로 소비(GETDEL)하고, 저장된 provider와 일치하면 true를 반환한다. GET 후 DELETE 2단계로 나누면 동시 요청 2건이 모두 + * 통과할 수 있어 getAndDelete로 1회 소비를 보장한다. + */ public boolean consume(String state, String provider) { - String key = key(state); - String savedProvider = redisTemplate.opsForValue().get(key); - if (savedProvider == null || !savedProvider.equals(provider)) { - return false; - } - redisTemplate.delete(key); - return true; + String savedProvider = redisTemplate.opsForValue().getAndDelete(key(state)); + return savedProvider != null && savedProvider.equals(provider); } private String key(String state) { diff --git a/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java index 35c555f..7d5ccc7 100644 --- a/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.bop.youthpick.auth.service.RefreshTokenStore; import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.entity.User; @@ -33,13 +34,16 @@ class AdminUserServiceTest { @Mock private UserProfileRepository userProfileRepository; + @Mock private RefreshTokenStore refreshTokenStore; + private AdminUserService adminUserService; private static final Long USER_ID = 1L; @BeforeEach void setUp() { - adminUserService = new AdminUserService(userRepository, userProfileRepository); + adminUserService = + new AdminUserService(userRepository, userProfileRepository, refreshTokenStore); } @Test @@ -140,6 +144,17 @@ void setUp() { verify(user).softDelete(); } + @Test + void soft_delete_시_refresh_token을_즉시_회수한다() { + User user = mock(User.class); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(user.getId()).thenReturn(USER_ID); + + adminUserService.softDelete(USER_ID); + + verify(refreshTokenStore).delete(USER_ID); + } + @Test void 탈퇴_대상_사용자가_없으면_USER_NOT_FOUND_예외를_던진다() { when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); diff --git a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java index 7716eb8..2df382f 100644 --- a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java @@ -12,6 +12,7 @@ import com.bop.youthpick.auth.dto.OAuthUserInfo; import com.bop.youthpick.auth.dto.TokenResponse; +import com.bop.youthpick.auth.exception.AuthErrorCode; import com.bop.youthpick.auth.exception.AuthException; import com.bop.youthpick.user.entity.LoginHistory; import com.bop.youthpick.user.entity.User; @@ -148,13 +149,54 @@ void setUp() { verify(loginHistoryRepository).save(any(LoginHistory.class)); } + @Test + void soft_delete된_사용자면_로그인이_A009로_거부된다() { + when(oAuthStateStore.consume("state-value", "GOOGLE")).thenReturn(true); + when(oAuthClient.exchangeCodeForAccessToken( + eq(OAuthProvider.GOOGLE), + eq("client-id"), + eq("client-secret"), + anyString(), + eq("code"))) + .thenReturn("provider-access-token"); + when(oAuthClient.fetchUserInfo(OAuthProvider.GOOGLE, "provider-access-token")) + .thenReturn(new OAuthUserInfo("GOOGLE", "provider-id-1", "a@a.com", "닉네임")); + User deletedUser = User.createSocialUser("GOOGLE", "provider-id-1", "a@a.com", "닉네임"); + deletedUser.softDelete(); + when(userRepository.findByProviderAndProviderId("GOOGLE", "provider-id-1")) + .thenReturn(Optional.of(deletedUser)); + + assertThatThrownBy(() -> authService.login("google", "code", "state-value")) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.ACCOUNT_DISABLED); + + verify(jwtTokenProvider, never()).createAccessToken(any(), any()); + verify(loginHistoryRepository, never()).save(any()); + } + + @Test + void soft_delete된_사용자면_refresh가_거부된다() { + io.jsonwebtoken.Claims claims = mock(io.jsonwebtoken.Claims.class); + when(jwtTokenProvider.validateRefreshToken("refresh-token")).thenReturn(claims); + when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.refresh("refresh-token")) + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(AuthErrorCode.UNAUTHORIZED); + + verify(jwtTokenProvider, never()).createAccessToken(any(), any()); + } + @Test void refresh_token이_저장된_값과_불일치하면_거부된다() { User user = mock(User.class); io.jsonwebtoken.Claims claims = mock(io.jsonwebtoken.Claims.class); when(jwtTokenProvider.validateRefreshToken("refresh-token")).thenReturn(claims); when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); when(refreshTokenStore.matches(1L, "refresh-token")).thenReturn(false); assertThatThrownBy(() -> authService.refresh("refresh-token")) @@ -171,7 +213,7 @@ void setUp() { when(user.getRole()).thenReturn(com.bop.youthpick.user.entity.Role.USER); when(jwtTokenProvider.validateRefreshToken("refresh-token")).thenReturn(claims); when(jwtTokenProvider.getUserId(claims)).thenReturn(1L); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); when(refreshTokenStore.matches(1L, "refresh-token")).thenReturn(true); when(jwtTokenProvider.createAccessToken(1L, "USER")).thenReturn("new-access"); when(jwtTokenProvider.accessTokenExpirationSeconds()).thenReturn(1800L); diff --git a/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java b/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java index f550879..ede2559 100644 --- a/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java @@ -1,8 +1,6 @@ package com.bop.youthpick.auth.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -28,34 +26,44 @@ void setUp() { } @Test - void 저장된_provider와_일치하면_소비하고_true를_반환한다() { + void 저장된_provider와_일치하면_원자적으로_소비하고_true를_반환한다() { when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("auth:oauth-state:state-1")).thenReturn("GOOGLE"); + when(valueOperations.getAndDelete("auth:oauth-state:state-1")).thenReturn("GOOGLE"); boolean consumed = oAuthStateStore.consume("state-1", "GOOGLE"); assertThat(consumed).isTrue(); - verify(redisTemplate).delete("auth:oauth-state:state-1"); + verify(valueOperations).getAndDelete("auth:oauth-state:state-1"); } @Test - void provider가_다르면_소비하지_않고_false를_반환한다() { + void provider가_다르면_false를_반환한다() { when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("auth:oauth-state:state-1")).thenReturn("KAKAO"); + when(valueOperations.getAndDelete("auth:oauth-state:state-1")).thenReturn("KAKAO"); boolean consumed = oAuthStateStore.consume("state-1", "GOOGLE"); assertThat(consumed).isFalse(); - verify(redisTemplate, never()).delete(any(String.class)); } @Test void state가_없으면_false를_반환한다() { when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.get("auth:oauth-state:unknown")).thenReturn(null); + when(valueOperations.getAndDelete("auth:oauth-state:unknown")).thenReturn(null); boolean consumed = oAuthStateStore.consume("unknown", "GOOGLE"); assertThat(consumed).isFalse(); } + + @Test + void 이미_소비된_state는_다시_소비할_수_없다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.getAndDelete("auth:oauth-state:state-1")) + .thenReturn("GOOGLE") + .thenReturn(null); + + assertThat(oAuthStateStore.consume("state-1", "GOOGLE")).isTrue(); + assertThat(oAuthStateStore.consume("state-1", "GOOGLE")).isFalse(); + } } From 84623b6411f9e6e8992dd26d7d7cce7d4aa603a4 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 21:44:57 +0900 Subject: [PATCH 66/93] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20API=20=EB=AF=B8=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=C2=B7=ED=83=80=EC=9D=B8=20userId=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C(IDOR)=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../global/config/SecurityConfig.java | 3 ++ .../controller/UserProfileController.java | 11 +++++-- .../global/config/SecurityConfigTest.java | 7 +++++ .../controller/UserProfileControllerTest.java | 29 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 981b897..69c13bc 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -91,6 +91,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .authenticated() // 회원 전용 — 로그인 상태 조회/로그아웃, 회원 탈퇴(컨트롤러 미구현, 경로만 선점), // 마이페이지(관심정책/추천/읽음/프로필), 최근 본 정책 + // 온보딩 프로필 제출도 회원 전용 — 컨트롤러에서 본인 여부를 추가 검증한다. + .requestMatchers(HttpMethod.POST, "/api/v1/users/*/profile") + .authenticated() .requestMatchers( "/api/v1/auth/me", "/api/v1/auth/logout", diff --git a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java index 01f4a4a..7b162ef 100644 --- a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java +++ b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java @@ -1,5 +1,7 @@ package com.bop.youthpick.user.controller; +import com.bop.youthpick.auth.exception.AuthErrorCode; +import com.bop.youthpick.auth.exception.AuthException; import com.bop.youthpick.auth.service.CurrentUser; import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.user.dto.UserProfileRequest; @@ -23,10 +25,15 @@ public class UserProfileController { private final UserProfileService userProfileService; - // TODO: 인증 도입 후 @PathVariable userId를 인증 principal 기반으로 교체한다. + // 프론트가 사용 중인 경로라 유지한다. /api/v1/me/profile로의 경로 통일은 추후 과제. @PostMapping("/api/v1/users/{userId}/profile") public ResponseEntity> submit( - @PathVariable Long userId, @Valid @RequestBody UserProfileRequest request) { + @CurrentUser Long currentUserId, + @PathVariable Long userId, + @Valid @RequestBody UserProfileRequest request) { + if (!currentUserId.equals(userId)) { + throw new AuthException(AuthErrorCode.FORBIDDEN); + } UserProfile profile = userProfileService.submit(userId, request); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.ok(UserProfileResponse.from(profile))); diff --git a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java index 70fad22..00c2fa6 100644 --- a/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java +++ b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java @@ -132,6 +132,13 @@ class SecurityConfigTest { assertThat(status).isNotIn(401, 403); } + @Test + void 프로필_제출은_인증없이_접근하면_401() throws Exception { + mockMvc.perform(post("/api/v1/users/1/profile")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + @Test void 회원_탈퇴_경로에_인증없이_접근하면_401() throws Exception { mockMvc.perform(delete("/api/v1/users")).andExpect(status().isUnauthorized()); diff --git a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java index 326008c..b4ad525 100644 --- a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java @@ -79,6 +79,7 @@ void clearContext() { when(profile.getKeywords()).thenReturn("청년,공모전"); when(profile.getStatus()).thenReturn("COMPLETED"); when(userProfileService.submit(eq(1L), any())).thenReturn(profile); + authenticateAs(1L); mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) @@ -106,6 +107,7 @@ void clearContext() { "educationLevel": "UNIV_GRADUATE" } """; + authenticateAs(1L); mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) @@ -125,6 +127,7 @@ void clearContext() { "educationLevel": "UNIV_GRADUATE" } """; + authenticateAs(1L); mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) @@ -137,6 +140,8 @@ void clearContext() { @Test void 어휘에_없는_취업상태면_400과_C001을_반환한다() throws Exception { // 검증이 없던 시절에는 이런 값도 그대로 저장돼, 맞춤정책 취업 축이 조용히 0점이 됐다. + authenticateAs(1L); + mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) .contentType(MediaType.APPLICATION_JSON) @@ -148,6 +153,8 @@ void clearContext() { @Test void 어휘에_없는_학력이면_400과_C001을_반환한다() throws Exception { + authenticateAs(1L); + mockMvc.perform( post("/api/v1/users/{userId}/profile", 1L) .contentType(MediaType.APPLICATION_JSON) @@ -157,6 +164,28 @@ void clearContext() { .andExpect(jsonPath("$.errors[0].field").value("educationLevel")); } + @Test + void 타인의_userId로_프로필을_제출하면_403과_A008을_반환한다() throws Exception { + authenticateAs(1L); + + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 2L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("A008")); + } + + @Test + void 미인증_프로필_제출이면_401과_A001을_반환한다() throws Exception { + mockMvc.perform( + post("/api/v1/users/{userId}/profile", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("A001")); + } + @Test void 온보딩된_사용자면_200과_프로필을_반환한다() throws Exception { UserProfile profile = mock(UserProfile.class); From 7651100707344ddfb645b5e91dbc43528fb700f5 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 21:45:35 +0900 Subject: [PATCH 67/93] =?UTF-8?q?fix:=20BoardErrorCode=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=95=B4=EC=86=8C(COMMENT=5FNOT=5FFOUND=20B005=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=B0=EB=B2=88)=20=EB=B0=8F=20board=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20post=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - post/board 두 패키지가 B001·B002를 서로 다른 의미로 중복 정의하던 문제 해소 (post B002=POST_ACCESS_DENIED 403 vs board B002=COMMENT_NOT_FOUND 404) - post.exception.BoardErrorCode를 정본으로 유지하고 COMMENT_NOT_FOUND를 B005(404)로 재배번해 흡수, 중복 BoardException 제거 - board 패키지의 Comment 엔티티·CommentRepository·AttachmentRepository를 post 패키지로 이동해 커뮤니티 도메인 단일 패키지화, board 패키지 삭제 - PostService·admin 커뮤니티(서비스/DTO)·테스트 import 갱신, 댓글 404 테스트 B005로 갱신 Co-Authored-By: Claude Fable 5 --- .../dto/AdminCommunityCommentResponse.java | 2 +- .../board/service/AdminCommunityService.java | 10 +++++----- .../board/exception/BoardErrorCode.java | 17 ----------------- .../board/exception/BoardException.java | 11 ----------- .../{board => post}/entity/Comment.java | 3 +-- .../post/exception/BoardErrorCode.java | 3 ++- .../repository/AttachmentRepository.java | 2 +- .../repository/CommentRepository.java | 4 ++-- .../bop/youthpick/post/service/PostService.java | 2 +- .../AdminCommunityCommentControllerTest.java | 8 ++++---- .../AdminCommunityPostControllerTest.java | 4 ++-- .../service/AdminCommunityServiceTest.java | 10 +++++----- .../youthpick/post/service/PostServiceTest.java | 2 +- 13 files changed, 25 insertions(+), 53 deletions(-) delete mode 100644 src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java delete mode 100644 src/main/java/com/bop/youthpick/board/exception/BoardException.java rename src/main/java/com/bop/youthpick/{board => post}/entity/Comment.java (94%) rename src/main/java/com/bop/youthpick/{board => post}/repository/AttachmentRepository.java (87%) rename src/main/java/com/bop/youthpick/{board => post}/repository/CommentRepository.java (78%) diff --git a/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java index c5605dd..9981fbb 100644 --- a/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java @@ -1,6 +1,6 @@ package com.bop.youthpick.admin.board.dto; -import com.bop.youthpick.board.entity.Comment; +import com.bop.youthpick.post.entity.Comment; import java.time.LocalDateTime; public record AdminCommunityCommentResponse( diff --git a/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java index 0bc5165..6667e0f 100644 --- a/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java +++ b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java @@ -4,12 +4,12 @@ import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; import com.bop.youthpick.admin.board.dto.AdminCommunityPostResponse; import com.bop.youthpick.admin.board.repository.AdminCommunityPostSpecifications; -import com.bop.youthpick.board.entity.Comment; -import com.bop.youthpick.board.exception.BoardErrorCode; -import com.bop.youthpick.board.exception.BoardException; -import com.bop.youthpick.board.repository.AttachmentRepository; -import com.bop.youthpick.board.repository.CommentRepository; +import com.bop.youthpick.post.entity.Comment; import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.AttachmentRepository; +import com.bop.youthpick.post.repository.CommentRepository; import com.bop.youthpick.post.repository.PostRepository; import jakarta.persistence.criteria.JoinType; import java.time.LocalDate; diff --git a/src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java b/src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java deleted file mode 100644 index 87af69e..0000000 --- a/src/main/java/com/bop/youthpick/board/exception/BoardErrorCode.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.bop.youthpick.board.exception; - -import com.bop.youthpick.global.error.ErrorCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum BoardErrorCode implements ErrorCode { - POST_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "게시글을 찾을 수 없습니다."), - COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "댓글을 찾을 수 없습니다."); - - private final HttpStatus status; - private final String code; - private final String message; -} diff --git a/src/main/java/com/bop/youthpick/board/exception/BoardException.java b/src/main/java/com/bop/youthpick/board/exception/BoardException.java deleted file mode 100644 index 5b19228..0000000 --- a/src/main/java/com/bop/youthpick/board/exception/BoardException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.bop.youthpick.board.exception; - -import com.bop.youthpick.global.error.CustomException; -import com.bop.youthpick.global.error.ErrorCode; - -public class BoardException extends CustomException { - - public BoardException(ErrorCode errorCode) { - super(errorCode); - } -} diff --git a/src/main/java/com/bop/youthpick/board/entity/Comment.java b/src/main/java/com/bop/youthpick/post/entity/Comment.java similarity index 94% rename from src/main/java/com/bop/youthpick/board/entity/Comment.java rename to src/main/java/com/bop/youthpick/post/entity/Comment.java index 20ed84e..013fe06 100644 --- a/src/main/java/com/bop/youthpick/board/entity/Comment.java +++ b/src/main/java/com/bop/youthpick/post/entity/Comment.java @@ -1,7 +1,6 @@ -package com.bop.youthpick.board.entity; +package com.bop.youthpick.post.entity; import com.bop.youthpick.global.entity.BaseEntity; -import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java b/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java index 7a0d955..c68e469 100644 --- a/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java +++ b/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java @@ -11,7 +11,8 @@ public enum BoardErrorCode implements ErrorCode { POST_NOT_FOUND(HttpStatus.NOT_FOUND, "B001", "게시글을 찾을 수 없습니다."), POST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "B002", "작성자만 게시글을 수정하거나 삭제할 수 있습니다."), POLICY_REQUIRED(HttpStatus.BAD_REQUEST, "B003", "질문과 후기에는 정책 연결이 필요합니다."), - FREE_POST_POLICY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "자유글에는 정책을 연결할 수 없습니다."); + FREE_POST_POLICY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "자유글에는 정책을 연결할 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "댓글을 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java b/src/main/java/com/bop/youthpick/post/repository/AttachmentRepository.java similarity index 87% rename from src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java rename to src/main/java/com/bop/youthpick/post/repository/AttachmentRepository.java index b697a6d..77f4982 100644 --- a/src/main/java/com/bop/youthpick/board/repository/AttachmentRepository.java +++ b/src/main/java/com/bop/youthpick/post/repository/AttachmentRepository.java @@ -1,4 +1,4 @@ -package com.bop.youthpick.board.repository; +package com.bop.youthpick.post.repository; import com.bop.youthpick.post.entity.Attachment; import java.util.List; diff --git a/src/main/java/com/bop/youthpick/board/repository/CommentRepository.java b/src/main/java/com/bop/youthpick/post/repository/CommentRepository.java similarity index 78% rename from src/main/java/com/bop/youthpick/board/repository/CommentRepository.java rename to src/main/java/com/bop/youthpick/post/repository/CommentRepository.java index 6e3be81..e5dc432 100644 --- a/src/main/java/com/bop/youthpick/board/repository/CommentRepository.java +++ b/src/main/java/com/bop/youthpick/post/repository/CommentRepository.java @@ -1,6 +1,6 @@ -package com.bop.youthpick.board.repository; +package com.bop.youthpick.post.repository; -import com.bop.youthpick.board.entity.Comment; +import com.bop.youthpick.post.entity.Comment; import java.util.List; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/bop/youthpick/post/service/PostService.java b/src/main/java/com/bop/youthpick/post/service/PostService.java index 30c9983..a406d14 100644 --- a/src/main/java/com/bop/youthpick/post/service/PostService.java +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -1,6 +1,5 @@ package com.bop.youthpick.post.service; -import com.bop.youthpick.board.repository.AttachmentRepository; import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.exception.PolicyErrorCode; @@ -14,6 +13,7 @@ import com.bop.youthpick.post.entity.PostCategory; import com.bop.youthpick.post.exception.BoardErrorCode; import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.AttachmentRepository; import com.bop.youthpick.post.repository.PostRepository; import com.bop.youthpick.post.repository.PostSpecifications; import com.bop.youthpick.user.entity.User; diff --git a/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java index 2c0006d..aaf6282 100644 --- a/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java @@ -7,8 +7,8 @@ import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; import com.bop.youthpick.admin.board.service.AdminCommunityService; -import com.bop.youthpick.board.exception.BoardErrorCode; -import com.bop.youthpick.board.exception.BoardException; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -38,12 +38,12 @@ class AdminCommunityCommentControllerTest { } @Test - void 삭제_대상이_없으면_404와_B002를_반환한다() throws Exception { + void 삭제_대상이_없으면_404와_B005를_반환한다() throws Exception { when(adminCommunityService.deleteComment(2L)) .thenThrow(new BoardException(BoardErrorCode.COMMENT_NOT_FOUND)); mockMvc.perform(delete("/api/v1/admin/community-comments/{commentId}", 2L)) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("B002")); + .andExpect(jsonPath("$.code").value("B005")); } } diff --git a/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java index 1944504..0ea8fbe 100644 --- a/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java @@ -13,9 +13,9 @@ import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; import com.bop.youthpick.admin.board.dto.AdminCommunityPostResponse; import com.bop.youthpick.admin.board.service.AdminCommunityService; -import com.bop.youthpick.board.exception.BoardErrorCode; -import com.bop.youthpick.board.exception.BoardException; import com.bop.youthpick.post.entity.PostCategory; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java index c84c4bc..a006a36 100644 --- a/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java @@ -7,12 +7,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.bop.youthpick.board.entity.Comment; -import com.bop.youthpick.board.exception.BoardErrorCode; -import com.bop.youthpick.board.exception.BoardException; -import com.bop.youthpick.board.repository.AttachmentRepository; -import com.bop.youthpick.board.repository.CommentRepository; +import com.bop.youthpick.post.entity.Comment; import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.AttachmentRepository; +import com.bop.youthpick.post.repository.CommentRepository; import com.bop.youthpick.post.repository.PostRepository; import com.bop.youthpick.user.entity.User; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java index 54acf7f..b752553 100644 --- a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -9,7 +9,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.bop.youthpick.board.repository.AttachmentRepository; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.repository.PolicyRepository; import com.bop.youthpick.post.dto.PostCreateRequest; @@ -21,6 +20,7 @@ import com.bop.youthpick.post.entity.PostCategory; import com.bop.youthpick.post.exception.BoardErrorCode; import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.AttachmentRepository; import com.bop.youthpick.post.repository.PostRepository; import com.bop.youthpick.user.entity.User; import com.bop.youthpick.user.repository.UserRepository; From cea61788bef06927f704adb35e48d2921c290694 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 21:46:49 +0900 Subject: [PATCH 68/93] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=EC=9D=98=20=EC=B2=B4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=91=EA=B7=BC=20=EC=8B=9C=20500=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20P005=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PolicyApplicationService.delete()가 신청 소프트 삭제 시 softDeleteAllByApplicationId로 딸린 체크리스트도 함께 정리한다 (재등록 시 체크리스트 초기화라는 create() reactivate 분기 스펙과 일치) - PolicyApplicationChecklistService.findActive의 부모 확인을 join fetch + 명시적 deleted_at 조건의 리포지토리 쿼리 (findActiveWithApplicationById)로 교체 — LAZY 프록시 초기화가 @SQLRestriction에 걸려 EntityNotFoundException(S001 500)으로 새던 경로를 제거하고, 고아 체크리스트는 CHECKLIST_NOT_FOUND(P005)로 통일 - 도달 불가능했던 checklist.getApplication().isDeleted() 방어 분기 제거 - 목으로는 재현 불가한 JPA 프록시/쿼리 동작을 H2 통합 테스트 (PolicyApplicationChecklistOrphanIntegrationTest)로 검증 Co-Authored-By: Claude Fable 5 --- .../entity/PolicyApplicationChecklist.java | 6 +- .../PolicyApplicationChecklistRepository.java | 22 ++- .../PolicyApplicationChecklistService.java | 16 +-- .../service/PolicyApplicationService.java | 8 +- ...icationChecklistOrphanIntegrationTest.java | 133 ++++++++++++++++++ ...PolicyApplicationChecklistServiceTest.java | 87 ++---------- .../service/PolicyApplicationServiceTest.java | 1 + 7 files changed, 183 insertions(+), 90 deletions(-) create mode 100644 src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistOrphanIntegrationTest.java diff --git a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java index e64f5af..5c2635d 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java @@ -19,8 +19,10 @@ /** * 신청관리별 준비 체크리스트 (제출서류 등). PolicyApplicationChecklistService를 통해서만 생성/변경되고, application * FK(policy_application_checklists.application_id)로 부모 {@link PolicyApplication}에 종속된다 — 부모가 소프트 - * 삭제되면 이 엔티티는 개별적으로는 안 지워지고 남아있는 채로 "고아" 상태가 되는데, 그건 - * PolicyApplicationChecklistService.findActive()가 checklist.getApplication().isDeleted()로 걸러낸다. + * 삭제되면(PolicyApplicationService.delete()) 딸린 체크리스트도 + * PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()로 함께 소프트 삭제된다. 혹시 정리 전에 남아있던 + * "고아" 체크리스트라도 PolicyApplicationChecklistRepository.findActiveWithApplicationById()가 부모의 deleted_at + * 조건까지 쿼리에서 확인하므로 CHECKLIST_NOT_FOUND로 걸러진다. */ @Entity @Table( diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java index 4fb713e..aa7fe27 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java @@ -14,9 +14,16 @@ public interface PolicyApplicationChecklistRepository /** * PolicyApplicationChecklistService.findActive(id, userId)가 이걸 감싼다 — - * update/check/uncheck/delete 네 메서드가 모두 이 조회를 거친다. + * update/check/uncheck/delete 네 메서드가 모두 이 조회를 거친다. {@code join fetch}로 부모 {@link + * com.bop.youthpick.policy.entity.PolicyApplication}을 같은 쿼리에서 즉시 로딩하는 이유: 부모를 LAZY 프록시로 남겨두면 + * 부모만 소프트 삭제된 "고아" 체크리스트에서 프록시 초기화 시점에 부모의 {@code @SQLRestriction("deleted_at IS NULL")}에 걸려 + * {@code EntityNotFoundException}(500)이 터진다. 부모의 {@code deletedAt IS NULL} 조건을 쿼리에 명시해 고아 + * 체크리스트는 아예 빈 결과(→ P005)로 떨어지게 한다. */ - Optional findByIdAndDeletedAtIsNull(Long id); + @Query( + "SELECT c FROM PolicyApplicationChecklist c JOIN FETCH c.application a " + + "WHERE c.id = :id AND c.deletedAt IS NULL AND a.deletedAt IS NULL") + Optional findActiveWithApplicationById(@Param("id") Long id); /** * 체크리스트 노출 순서를 id 오름차순(등록 순서)으로 고정한다. PolicyApplicationChecklistService.getByApplication()에서 사용 @@ -27,11 +34,12 @@ Page findByApplication_IdAndDeletedAtIsNullOrderById /** * 이 리포지토리를 쓰는 다른 모든 메서드와 달리, 이건 PolicyApplicationChecklistService가 아니라 옆 도메인 파일인 - * PolicyApplicationService.create()의 reactivate 분기에서 직접 호출된다 — 사용자가 관심 삭제했던 정책을 재등록하면, 그 신청에 - * 달려있던 예전 체크리스트를 전부 무효화하기 위함이다. {@code flushAutomatically = true}라서, 직전에 - * PolicyApplication.reactivate()로 바뀐 변경분이 먼저 flush된 뒤에 이 벌크 UPDATE가 실행된다(순서 보장). {@code - * clearAutomatically}는 안 켜져 있어서, 같은 트랜잭션 안에서 이미 영속 상태로 로드해둔 PolicyApplicationChecklist가 있다면 이 - * 벌크 UPDATE 이후에도 메모리 값은 갱신되지 않는다 — 지금 호출 지점(create())은 체크리스트 엔티티를 따로 로드하지 않아서 문제되지 않는다. + * PolicyApplicationService에서 직접 호출된다 — ①create()의 reactivate 분기(관심 삭제했던 정책을 재등록하면 예전 체크리스트 전부 + * 무효화), ②delete()(신청 소프트 삭제 시 딸린 체크리스트도 함께 정리해 고아 체크리스트가 남지 않게 함). {@code flushAutomatically = + * true}라서, 직전에 PolicyApplication.reactivate()로 바뀐 변경분이 먼저 flush된 뒤에 이 벌크 UPDATE가 실행된다(순서 보장). + * {@code clearAutomatically}는 안 켜져 있어서, 같은 트랜잭션 안에서 이미 영속 상태로 로드해둔 PolicyApplicationChecklist가 + * 있다면 이 벌크 UPDATE 이후에도 메모리 값은 갱신되지 않는다 — 지금 호출 지점(create()/delete())은 체크리스트 엔티티를 따로 로드하지 않아서 + * 문제되지 않는다. */ @Modifying(flushAutomatically = true) @Query( diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java index dbfffa5..2a76fe4 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java @@ -62,7 +62,8 @@ public void uncheck(Long id, Long userId) { /** * 항목 하나만 소프트 삭제한다. 부모 신청관리 전체가 삭제/재등록될 때 체크리스트를 일괄 정리하는 건 여기가 아니라 {@link * PolicyApplicationChecklistRepository#softDeleteAllByApplicationId}이고, 그건 {@link - * PolicyApplicationService#create}의 reactivate 분기에서 (이 서비스를 거치지 않고) 직접 호출된다. + * PolicyApplicationService#create}의 reactivate 분기와 {@link PolicyApplicationService#delete}에서 (이 + * 서비스를 거치지 않고) 직접 호출된다. */ @Transactional public void delete(Long id, Long userId) { @@ -91,19 +92,18 @@ private PolicyApplication findActiveApplication(Long applicationId, Long userId) } /** - * update/check/uncheck/delete가 공통으로 쓰는 조회. 체크리스트 자신의 {@code deletedAt}뿐 아니라 {@code - * checklist.getApplication().isDeleted()}까지 한 번 더 확인하는 이유: 체크리스트는 안 지워졌는데 부모 PolicyApplication만 - * 나중에 소프트 삭제된 경우를 걸러내기 위해서다(그런 상태의 체크리스트는 "고아"로 취급해 CHECKLIST_NOT_FOUND로 통일). + * update/check/uncheck/delete가 공통으로 쓰는 조회. 체크리스트 자신의 {@code deletedAt}과 부모 PolicyApplication의 + * {@code deletedAt}을 리포지토리 쿼리({@code join fetch} + 명시적 deleted_at 조건)에서 한 번에 확인한다 — 부모만 소프트 삭제된 + * "고아" 체크리스트도 CHECKLIST_NOT_FOUND로 통일된다. 부모를 서비스 코드에서 {@code isDeleted()}로 검사하지 않는 이유: LAZY 프록시 + * 초기화 시점에 부모의 {@code @SQLRestriction}에 걸려 검사에 도달하기 전에 {@code EntityNotFoundException}(500)이 터지기 + * 때문이다(그 검사는 도달 불가능한 죽은 코드였다). */ private PolicyApplicationChecklist findActive(Long id, Long userId) { PolicyApplicationChecklist checklist = applicationChecklistRepository - .findByIdAndDeletedAtIsNull(id) + .findActiveWithApplicationById(id) .orElseThrow( () -> new CustomException(PolicyErrorCode.CHECKLIST_NOT_FOUND)); - if (checklist.getApplication().isDeleted()) { - throw new CustomException(PolicyErrorCode.CHECKLIST_NOT_FOUND); - } checklist.getApplication().verifyOwner(userId); return checklist; } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java index d5f5c26..4d2c6fb 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java @@ -117,12 +117,18 @@ public Page getApplications(Long userId, Pageable pag .map(PolicyApplicationResponse::from); } - /** 신청관리 항목을 소프트 삭제(관심 해제)한다. 소유권 검증 후 처리한다. */ + /** + * 신청관리 항목을 소프트 삭제(관심 해제)한다. 소유권 검증 후 처리하며, 딸린 체크리스트도 {@link + * PolicyApplicationChecklistRepository#softDeleteAllByApplicationId}로 함께 소프트 삭제한다 — 신청만 지우고 + * 체크리스트를 남기면 "고아" 체크리스트가 생기고, "신청 삭제 후 재등록 시 체크리스트 초기화"라는 {@link #create}의 reactivate 분기 스펙과도 + * 어긋나기 때문이다. + */ @Transactional public void delete(Long id, Long userId) { PolicyApplication application = findActive(id); application.verifyOwner(userId); application.delete(); + policyApplicationChecklistRepository.softDeleteAllByApplicationId(id); } /** diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistOrphanIntegrationTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistOrphanIntegrationTest.java new file mode 100644 index 0000000..3dfeb73 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistOrphanIntegrationTest.java @@ -0,0 +1,133 @@ +package com.bop.youthpick.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bop.youthpick.global.config.JpaAuditingConfig; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.ApplicationStatus; +import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; +import com.bop.youthpick.policy.repository.PolicyApplicationRepository; +import com.bop.youthpick.policy.repository.PolicyRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * "부모 신청관리만 소프트 삭제된 고아 체크리스트" 시나리오의 H2 통합 테스트. Mockito 목으로는 JPA LAZY 프록시가 + * {@code @SQLRestriction("deleted_at IS NULL")}에 걸려 {@code EntityNotFoundException}(500)을 던지는 동작이 + * 재현되지 않아서(목은 진짜 엔티티를 돌려주므로 프록시 초기화 자체가 없다), 실제 영속성 컨텍스트 위에서 서비스를 통째로 돌려 검증한다. + */ +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PolicyApplicationChecklistOrphanIntegrationTest { + + @Autowired private PolicyApplicationRepository policyApplicationRepository; + @Autowired private UserRepository userRepository; + @Autowired private PolicyRepository policyRepository; + @Autowired private PolicyApplicationChecklistRepository applicationChecklistRepository; + @Autowired private TestEntityManager entityManager; + + private PolicyApplicationService applicationService; + private PolicyApplicationChecklistService checklistService; + + private Long userId; + private Long applicationId; + private Long checklistId; + + @BeforeEach + void setUp() { + applicationService = + new PolicyApplicationService( + policyApplicationRepository, + userRepository, + policyRepository, + applicationChecklistRepository); + checklistService = + new PolicyApplicationChecklistService( + applicationChecklistRepository, applicationService); + + User user = userRepository.save(User.createSocialUser("kakao", "kakao-1", null, "닉네임")); + userId = user.getId(); + + Policy policy = BeanUtils.instantiateClass(Policy.class); + ReflectionTestUtils.setField(policy, "policyNo", "P001"); + ReflectionTestUtils.setField(policy, "title", "청년 일자리 지원"); + ReflectionTestUtils.setField(policy, "visibility", PolicyVisibility.VISIBLE); + entityManager.persistAndFlush(policy); + + PolicyApplication application = + PolicyApplication.create(user, policy, ApplicationStatus.INTERESTED, null, null); + entityManager.persistAndFlush(application); + applicationId = application.getId(); + + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application, "서류 A"); + entityManager.persistAndFlush(checklist); + checklistId = checklist.getId(); + + // 이후 서비스 호출이 1차 캐시가 아니라 DB 조회(프록시 포함)를 타도록 영속성 컨텍스트를 비운다. + entityManager.clear(); + } + + @Test + void 신청을_삭제하면_체크리스트도_함께_soft_delete된다() { + applicationService.delete(applicationId, userId); + entityManager.flush(); + entityManager.clear(); + + assertThat( + applicationChecklistRepository + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .getContent()) + .isEmpty(); + assertThat(applicationChecklistRepository.findActiveWithApplicationById(checklistId)) + .isEmpty(); + } + + @Test + void 신청_soft_delete_후_체크리스트를_체크하면_CHECKLIST_NOT_FOUND를_던진다() { + applicationService.delete(applicationId, userId); + entityManager.flush(); + entityManager.clear(); + + assertThatThrownBy(() -> checklistService.check(checklistId, userId)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } + + /** + * 정리 로직이 돌기 전에 이미 만들어진(체크리스트는 살아있고 부모만 소프트 삭제된) 고아 데이터 회귀 테스트. 수정 전 코드에서는 {@code + * checklist.getApplication()} LAZY 프록시 초기화가 부모의 {@code @SQLRestriction}에 걸려 {@code + * EntityNotFoundException} → S001 500으로 샜다. 이제는 조회 쿼리가 부모 deleted_at 조건까지 걸어 빈 결과 → P005(404)로 + * 떨어져야 한다. + */ + @Test + void 부모만_삭제된_고아_체크리스트를_체크하면_500이_아니라_CHECKLIST_NOT_FOUND를_던진다() { + PolicyApplication application = entityManager.find(PolicyApplication.class, applicationId); + ReflectionTestUtils.setField(application, "deletedAt", LocalDateTime.now()); + entityManager.persistAndFlush(application); + entityManager.clear(); + + assertThatThrownBy(() -> checklistService.check(checklistId, userId)) + .isInstanceOf(CustomException.class) + .extracting(ex -> ((CustomException) ex).getErrorCode()) + .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java index d9c2cb3..d68f9c7 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java @@ -97,7 +97,7 @@ private PolicyApplication application() { void update_체크리스트_내용을_수정한다() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); PolicyApplicationChecklist result = checklistService.update(5L, USER_ID, "서류 다시 준비"); @@ -107,7 +107,7 @@ private PolicyApplication application() { @Test void update_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> checklistService.update(5L, USER_ID, "수정")) @@ -120,7 +120,7 @@ private PolicyApplication application() { void update_소유자가_아니면_FORBIDDEN_예외를_던진다() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); assertThatThrownBy(() -> checklistService.update(5L, OTHER_USER_ID, "수정")) @@ -129,26 +129,11 @@ private PolicyApplication application() { .isEqualTo(AuthErrorCode.FORBIDDEN); } - @Test - void update_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - PolicyApplication deletedApplication = application(); - deletedApplication.delete(); - PolicyApplicationChecklist checklist = - PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) - .thenReturn(Optional.of(checklist)); - - assertThatThrownBy(() -> checklistService.update(5L, USER_ID, "수정")) - .isInstanceOf(CustomException.class) - .extracting(ex -> ((CustomException) ex).getErrorCode()) - .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); - } - @Test void check_체크상태로_변경한다() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); checklistService.check(5L, USER_ID); @@ -161,7 +146,7 @@ private PolicyApplication application() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); checklist.check(); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); checklistService.uncheck(5L, USER_ID); @@ -171,7 +156,7 @@ private PolicyApplication application() { @Test void check_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> checklistService.check(5L, USER_ID)) @@ -182,7 +167,7 @@ private PolicyApplication application() { @Test void uncheck_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> checklistService.uncheck(5L, USER_ID)) @@ -193,7 +178,7 @@ private PolicyApplication application() { @Test void delete_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> checklistService.delete(5L, USER_ID)) @@ -206,7 +191,7 @@ private PolicyApplication application() { void delete_soft_delete한다() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); checklistService.delete(5L, USER_ID); @@ -214,57 +199,15 @@ private PolicyApplication application() { assertThat(checklist.getDeletedAt()).isNotNull(); } - @Test - void check_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - PolicyApplication deletedApplication = application(); - deletedApplication.delete(); - PolicyApplicationChecklist checklist = - PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) - .thenReturn(Optional.of(checklist)); - - assertThatThrownBy(() -> checklistService.check(5L, USER_ID)) - .isInstanceOf(CustomException.class) - .extracting(ex -> ((CustomException) ex).getErrorCode()) - .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); - } - - @Test - void uncheck_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - PolicyApplication deletedApplication = application(); - deletedApplication.delete(); - PolicyApplicationChecklist checklist = - PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); - checklist.check(); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) - .thenReturn(Optional.of(checklist)); - - assertThatThrownBy(() -> checklistService.uncheck(5L, USER_ID)) - .isInstanceOf(CustomException.class) - .extracting(ex -> ((CustomException) ex).getErrorCode()) - .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); - } - - @Test - void delete_상위_신청관리가_삭제되었으면_CHECKLIST_NOT_FOUND_예외를_던진다() { - PolicyApplication deletedApplication = application(); - deletedApplication.delete(); - PolicyApplicationChecklist checklist = - PolicyApplicationChecklist.create(deletedApplication, "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) - .thenReturn(Optional.of(checklist)); - - assertThatThrownBy(() -> checklistService.delete(5L, USER_ID)) - .isInstanceOf(CustomException.class) - .extracting(ex -> ((CustomException) ex).getErrorCode()) - .isEqualTo(PolicyErrorCode.CHECKLIST_NOT_FOUND); - } + // "상위 신청관리가 삭제된 고아 체크리스트 → CHECKLIST_NOT_FOUND" 케이스는 이제 리포지토리 쿼리 + // (findActiveWithApplicationById의 부모 deleted_at 조건)가 담당한다. 목으로는 JPA 프록시/쿼리 동작이 + // 재현되지 않으므로 H2 통합 테스트(PolicyApplicationChecklistOrphanIntegrationTest)에서 검증한다. @Test void check_소유자가_아니면_FORBIDDEN_예외를_던진다() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); assertThatThrownBy(() -> checklistService.check(5L, OTHER_USER_ID)) @@ -278,7 +221,7 @@ private PolicyApplication application() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); checklist.check(); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); assertThatThrownBy(() -> checklistService.uncheck(5L, OTHER_USER_ID)) @@ -291,7 +234,7 @@ private PolicyApplication application() { void delete_소유자가_아니면_FORBIDDEN_예외를_던진다() { PolicyApplicationChecklist checklist = PolicyApplicationChecklist.create(application(), "제출 서류 준비"); - when(applicationChecklistRepository.findByIdAndDeletedAtIsNull(5L)) + when(applicationChecklistRepository.findActiveWithApplicationById(5L)) .thenReturn(Optional.of(checklist)); assertThatThrownBy(() -> checklistService.delete(5L, OTHER_USER_ID)) diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java index 61d41da..2f7ed9c 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java @@ -549,6 +549,7 @@ void setUp() { policyApplicationService.delete(10L, USER_ID); assertThat(existing.isDeleted()).isTrue(); + verify(policyApplicationChecklistRepository).softDeleteAllByApplicationId(10L); } @Test From 260a4f41b5575a441735c88ec6ed7016a19a1167 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Wed, 22 Jul 2026 21:58:06 +0900 Subject: [PATCH 69/93] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=EC=A0=95=EC=B1=85=EC=9D=98=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=9E=AC=EB=85=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/policy/dto/AdminPolicyResponse.java | 4 ++- .../repository/AdminPolicySpecifications.java | 12 +++++++-- .../policy/service/AdminPolicyService.java | 6 ++++- .../bop/youthpick/policy/entity/Policy.java | 12 +++++++-- .../policy/repository/PolicyRepository.java | 5 ++-- .../service/PolicyChatAccessService.java | 3 ++- .../policy/service/PolicyService.java | 3 ++- .../V16__add_policies_admin_hidden.sql | 8 ++++++ .../controller/AdminPolicyControllerTest.java | 5 +++- .../AdminPolicySpecificationsTest.java | 20 ++++++++++++-- .../service/AdminPolicyServiceTest.java | 14 ++++++++-- .../youthpick/policy/entity/PolicyTest.java | 11 ++++++++ .../repository/PolicyRepositoryTest.java | 26 +++++++++++++++++++ .../service/PolicyChatAccessServiceTest.java | 2 +- .../policy/service/PolicyServiceTest.java | 14 ++++++---- 15 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 src/main/resources/db/migration/V16__add_policies_admin_hidden.sql diff --git a/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java index 18d9537..422ab45 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java @@ -19,6 +19,7 @@ public record AdminPolicyResponse( String applicationUrl, int viewCount, PolicyVisibility visibilityStatus, + boolean adminHidden, List regionCodes, LocalDateTime createdAt, LocalDateTime updatedAt, @@ -36,7 +37,8 @@ public static AdminPolicyResponse from(Policy policy, List regionCodes) policy.getApplicationEndDate(), policy.getApplicationUrl(), policy.getViewCount(), - policy.getVisibility(), + policy.isAdminHidden() ? PolicyVisibility.HIDDEN : policy.getVisibility(), + policy.isAdminHidden(), regionCodes, policy.getCreatedAt(), policy.getUpdatedAt(), diff --git a/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java index 2089d7c..b7c69da 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java +++ b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java @@ -32,8 +32,16 @@ private static Specification visibilityEquals(String visibilityStatus) { if (visibilityStatus == null || visibilityStatus.isBlank()) { return null; } - PolicyVisibility visibility = PolicyVisibility.valueOf(visibilityStatus); - return (root, query, cb) -> cb.equal(root.get("visibility"), visibility); + if ("VISIBLE".equals(visibilityStatus)) { + return (root, query, cb) -> + cb.and( + cb.equal(root.get("visibility"), PolicyVisibility.VISIBLE), + cb.isFalse(root.get("adminHidden"))); + } + return (root, query, cb) -> + cb.or( + cb.equal(root.get("visibility"), PolicyVisibility.HIDDEN), + cb.isTrue(root.get("adminHidden"))); } /** 정책의 [applicationStartDate, applicationEndDate] 구간이 [startDate, endDate]와 겹치는 정책만 남긴다. */ diff --git a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java index dfdfc69..26292db 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java @@ -75,7 +75,11 @@ public AdminPolicyResponse update(Long policyId, AdminPolicyUpdateRequest reques @Transactional public AdminPolicyResponse updateVisibility(Long policyId, String visibilityStatus) { Policy policy = findPolicy(policyId); - policy.changeVisibility(PolicyVisibility.valueOf(visibilityStatus)); + if (PolicyVisibility.HIDDEN.name().equals(visibilityStatus)) { + policy.hideByAdmin(); + } else { + policy.unhideByAdmin(); + } return AdminPolicyResponse.from(policy, regionCodesOf(policyId)); } diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java index 2f62f2e..bb8cee5 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Policy.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -241,6 +241,10 @@ public class Policy extends BaseEntity { @Column(length = 20, nullable = false) private PolicyVisibility visibility = PolicyVisibility.VISIBLE; + /** 관리자 수동 숨김 여부. 수집 배치의 누락/재등장 상태({@link #visibility})와 독립적으로 유지한다. */ + @Column(name = "admin_hidden", nullable = false) + private boolean adminHidden; + /** 수집에서 안 보인 연속 횟수 — 3회 도달 시 HIDDEN, 재등장 시 0 리셋 (V2, 기획 §6) */ @Column(name = "missing_count", nullable = false) private int missingCount; @@ -444,8 +448,12 @@ public static Policy create( @Column(name = "deleted_at") private LocalDateTime deletedAt; - public void changeVisibility(PolicyVisibility visibility) { - this.visibility = visibility; + public void hideByAdmin() { + this.adminHidden = true; + } + + public void unhideByAdmin() { + this.adminHidden = false; } public void softDelete() { diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index 970e9e5..3c3cbe9 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -27,7 +27,8 @@ public interface PolicyRepository long countByVisibilityAndDeletedAtIsNull(PolicyVisibility visibility); - Optional findByIdAndVisibilityAndDeletedAtIsNull(Long id, PolicyVisibility visibility); + Optional findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + Long id, PolicyVisibility visibility); /** * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함하되, @@ -42,7 +43,7 @@ public interface PolicyRepository * Sort}로는 파라미터에 따라 달라지는 순서를 표현할 수 없기 때문이다. 호출자는 정렬 없는 Pageable을 넘긴다. */ @Query( - "select p from Policy p where p.visibility = :visibility and p.deletedAt is null" + "select p from Policy p where p.visibility = :visibility and p.adminHidden = false and p.deletedAt is null" + " and (p.applicationEndDate is null or p.applicationEndDate >= :today)" + " and (p.applicationEndDate is not null" + " or p.businessPeriodEnd is null or p.businessPeriodEnd >= :today)" diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java index fcffdf1..11a6060 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java @@ -18,7 +18,8 @@ public class PolicyChatAccessService { @Transactional(readOnly = true) public Policy requireVisiblePolicy(Long policyId) { return policyRepository - .findByIdAndVisibilityAndDeletedAtIsNull(policyId, PolicyVisibility.VISIBLE) + .findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + policyId, PolicyVisibility.VISIBLE) .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); } } diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 10c6a23..671cb36 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -124,7 +124,8 @@ private static String toLikePattern(@Nullable String keyword) { public PolicyDetailResponse getDetail(Long policyId, @Nullable Long userId) { Policy policy = policyRepository - .findByIdAndVisibilityAndDeletedAtIsNull(policyId, PolicyVisibility.VISIBLE) + .findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + policyId, PolicyVisibility.VISIBLE) .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); if (userId != null) { diff --git a/src/main/resources/db/migration/V16__add_policies_admin_hidden.sql b/src/main/resources/db/migration/V16__add_policies_admin_hidden.sql new file mode 100644 index 0000000..d4042d8 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_policies_admin_hidden.sql @@ -0,0 +1,8 @@ +ALTER TABLE policies + ADD COLUMN admin_hidden BOOLEAN NOT NULL DEFAULT FALSE; + +-- 기존 HIDDEN은 관리자 화면에서 수동으로 숨긴 데이터와 배치 누락 데이터를 구분할 이력이 없다. +-- 기존 관리자 숨김이 다음 배치에서 다시 공개되지 않도록 보수적으로 보존한다. +UPDATE policies +SET admin_hidden = TRUE +WHERE visibility = 'HIDDEN'; diff --git a/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java index 1deee31..ebaad0e 100644 --- a/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java @@ -52,6 +52,7 @@ class AdminPolicyControllerTest { "https://example.com", 0, PolicyVisibility.VISIBLE, + true, List.of("11110"), LocalDateTime.now(), LocalDateTime.now(), @@ -146,7 +147,8 @@ class AdminPolicyControllerTest { patch("/api/v1/admin/policies/{policyId}/visibility", 1L) .contentType(MediaType.APPLICATION_JSON) .content("{\"visibilityStatus\":\"HIDDEN\"}")) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.adminHidden").value(true)); } @Test @@ -175,6 +177,7 @@ class AdminPolicyControllerTest { "https://example.com", 0, PolicyVisibility.VISIBLE, + false, List.of("11110"), LocalDateTime.now(), LocalDateTime.now(), diff --git a/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java index ac3bdaf..9ca8e75 100644 --- a/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java @@ -39,6 +39,9 @@ void setUp() { LocalDate.of(2026, 6, 1), LocalDate.of(2026, 8, 31))); policyRepository.save(newPolicy("P003", "일자리", PolicyVisibility.VISIBLE, null, null)); + Policy hiddenByAdmin = newPolicy("P004", "주거", PolicyVisibility.VISIBLE, null, null); + hiddenByAdmin.hideByAdmin(); + policyRepository.save(hiddenByAdmin); } // 배치 수집(온통청년 API) 경로가 아직 없어 Policy 인스턴스를 만들 공개 팩토리가 없다. @@ -75,7 +78,20 @@ private Policy newPolicy( policyRepository.findAll( AdminPolicySpecifications.filter(null, "HIDDEN", null, null)); - assertThat(result).extracting(Policy::getPolicyNo).containsExactly("P002"); + assertThat(result) + .extracting(Policy::getPolicyNo) + .containsExactlyInAnyOrder("P002", "P004"); + } + + @Test + void visibilityStatus_VISIBLE은_배치와_관리자_상태가_모두_노출인_정책만_반환한다() { + List result = + policyRepository.findAll( + AdminPolicySpecifications.filter(null, "VISIBLE", null, null)); + + assertThat(result) + .extracting(Policy::getPolicyNo) + .containsExactlyInAnyOrder("P001", "P003"); } @Test @@ -105,6 +121,6 @@ private Policy newPolicy( List result = policyRepository.findAll(AdminPolicySpecifications.filter(null, null, null, null)); - assertThat(result).hasSize(3); + assertThat(result).hasSize(4); } } diff --git a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java index 2c5c154..eb3e001 100644 --- a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java @@ -12,7 +12,6 @@ import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyRegion; -import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; @@ -136,7 +135,18 @@ void setUp() { adminPolicyService.updateVisibility(POLICY_ID, "HIDDEN"); - verify(policy).changeVisibility(PolicyVisibility.HIDDEN); + verify(policy).hideByAdmin(); + } + + @Test + void 노출상태_VISIBLE은_관리자_숨김을_해제한다() { + Policy policy = mock(Policy.class); + when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyRegionRepository.findByPolicyIdIn(eq(List.of(POLICY_ID)))).thenReturn(List.of()); + + adminPolicyService.updateVisibility(POLICY_ID, "VISIBLE"); + + verify(policy).unhideByAdmin(); } @Test diff --git a/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java b/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java index 47ed62b..4f5bacc 100644 --- a/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java +++ b/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java @@ -46,4 +46,15 @@ class PolicyTest { assertThat(policy.getMissingCount()).isZero(); assertThat(policy.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); } + + @Test + void 관리자_숨김은_수집_갱신_후에도_유지된다() { + Policy policy = policy("R2026-001", "옛 제목", LocalDateTime.of(2026, 1, 1, 0, 0)); + policy.hideByAdmin(); + + policy.updateFrom(policy("R2026-001", "새 제목", LocalDateTime.of(2026, 7, 1, 0, 0))); + + assertThat(policy.isAdminHidden()).isTrue(); + assertThat(policy.getVisibility()).isEqualTo(PolicyVisibility.VISIBLE); + } } diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java index c19f819..4a1a75e 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -114,6 +114,32 @@ class PolicyRepositoryTest { assertThat(page.getContent()).extracting(Policy::getPolicyNo).containsExactly("P001"); } + @Test + void 관리자_숨김_정책은_공개_카드와_상세_조회에서_제외한다() { + Policy visible = policyRepository.save(newPolicy("P001", PolicyVisibility.VISIBLE, null)); + Policy hiddenByAdmin = newPolicy("P002", PolicyVisibility.VISIBLE, null); + hiddenByAdmin.hideByAdmin(); + policyRepository.save(hiddenByAdmin); + + Page cards = + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + null, + null, + null, + null, + null, + null, + Pageable.unpaged()); + + assertThat(cards.getContent()).extracting(Policy::getId).containsExactly(visible.getId()); + assertThat( + policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + hiddenByAdmin.getId(), PolicyVisibility.VISIBLE)) + .isEmpty(); + } + @Test void 신청마감일이_없어도_사업기간이_이미_지났으면_카드_목록에서_제외한다() { LocalDate today = LocalDate.of(2026, 7, 21); diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java index 592d02e..b460a8f 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatAccessServiceTest.java @@ -22,7 +22,7 @@ class PolicyChatAccessServiceTest { @Test void 숨김_삭제_미존재_정책은_모두_POLICY_NOT_FOUND로_거부한다() { - when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull( + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( 10L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.empty()); diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index d6d0eb2..a038b92 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -59,7 +59,8 @@ void setUp() { @Test void 노출_중인_정책이면_상세와_지역을_이름까지_반환한다() { Policy policy = newPolicy(1L, "청년 월세 지원"); - when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); when(policyRegionRepository.findByPolicyIdIn(List.of(1L))) .thenReturn(List.of(newPolicyRegion(policy, "11680", "서울특별시", "강남구"))); @@ -73,7 +74,7 @@ void setUp() { @Test void 없거나_삭제_숨김된_정책이면_POLICY_NOT_FOUND_예외를_던진다() { - when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull( + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( 99L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.empty()); @@ -86,7 +87,8 @@ void setUp() { @Test void 로그인_사용자의_조회는_최근_본_정책으로_기록한다() { Policy policy = newPolicy(1L, "청년 월세 지원"); - when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); @@ -98,7 +100,8 @@ void setUp() { @Test void 기록_저장이_실패해도_상세_조회는_정상_응답한다() { Policy policy = newPolicy(1L, "청년 월세 지원"); - when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); doThrow(new DataAccessResourceFailureException("DB 연결 실패")) @@ -114,7 +117,8 @@ void setUp() { @Test void 비회원_조회는_기록하지_않는다() { Policy policy = newPolicy(1L, "청년 월세 지원"); - when(policyRepository.findByIdAndVisibilityAndDeletedAtIsNull(1L, PolicyVisibility.VISIBLE)) + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); From 3065f7368405f6b73e782a81b61d0ce1bbc4e757 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Thu, 23 Jul 2026 00:20:40 +0900 Subject: [PATCH 70/93] =?UTF-8?q?fix:=20=EB=B9=84=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=98=20=EB=B9=84=EA=B5=90=EC=99=80=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EB=93=B1=EB=A1=9D=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?(#177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../policy/repository/PolicyRepository.java | 3 + .../service/PolicyApplicationService.java | 20 ++++--- .../service/PolicyComparisonService.java | 5 +- .../service/PolicyApplicationServiceTest.java | 55 ++++++++++++++----- .../service/PolicyComparisonServiceTest.java | 20 +++++-- 5 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index 3c3cbe9..a88c731 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -30,6 +30,9 @@ public interface PolicyRepository Optional findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( Long id, PolicyVisibility visibility); + List findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + Collection ids, PolicyVisibility visibility); + /** * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함하되, * businessPeriodEnd(사업기간 종료일)가 있고 이미 지났다면 제외한다 — aplyPrdSeCd가 진짜 상시(0057002)가 아닌데도 diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java index d5f5c26..03ec521 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java @@ -5,6 +5,7 @@ import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; import com.bop.youthpick.policy.repository.PolicyApplicationRepository; @@ -44,6 +45,15 @@ public PolicyApplication create( ApplicationStatus status, String memo, LocalDateTime endAt) { + User user = + userRepository + .findById(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + Policy policy = + policyRepository + .findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + policyId, PolicyVisibility.VISIBLE) + .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); PolicyApplication existing = policyApplicationRepository .findIncludingDeletedByUserIdAndPolicyId(userId, policyId) @@ -54,18 +64,10 @@ public PolicyApplication create( if (!existing.isDeleted()) { throw new CustomException(PolicyErrorCode.POLICY_ALREADY_EXISTS); } - existing.reactivate(status, normalizedMemo, resolveEndAt(endAt, existing.getPolicy())); + existing.reactivate(status, normalizedMemo, resolveEndAt(endAt, policy)); policyApplicationChecklistRepository.softDeleteAllByApplicationId(existing.getId()); return existing; } - User user = - userRepository - .findById(userId) - .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); - Policy policy = - policyRepository - .findById(policyId) - .orElseThrow(() -> new CustomException(PolicyErrorCode.POLICY_NOT_FOUND)); PolicyApplication application = PolicyApplication.create( user, policy, status, normalizedMemo, resolveEndAt(endAt, policy)); diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java index 6ade5e3..4bc1412 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java @@ -4,6 +4,7 @@ import com.bop.youthpick.policy.dto.PolicyComparisonItemResponse; import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.entity.Policy; +import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; @@ -36,7 +37,9 @@ public List compare(List policyIds) { throw new CustomException(PolicyErrorCode.INVALID_COMPARISON_REQUEST); } - List policies = policyRepository.findAllById(policyIds); + List policies = + policyRepository.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + policyIds, PolicyVisibility.VISIBLE); if (policies.size() != policyIds.size()) { throw new CustomException(PolicyErrorCode.POLICY_NOT_FOUND); } diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java index 61d41da..94895bb 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java @@ -14,6 +14,7 @@ import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyApplication; +import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyApplicationChecklistRepository; import com.bop.youthpick.policy.repository.PolicyApplicationRepository; @@ -66,7 +67,9 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(mock(Policy.class))); when(policyApplicationRepository.save(any(PolicyApplication.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -86,7 +89,9 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); when(policyApplicationRepository.save(any(PolicyApplication.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -104,7 +109,9 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); when(policyApplicationRepository.save(any(PolicyApplication.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -124,7 +131,9 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); when(policyApplicationRepository.save(any(PolicyApplication.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -144,7 +153,9 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); LocalDateTime tooLateEndAt = LocalDateTime.of(2026, 9, 1, 0, 0); assertThatThrownBy( @@ -166,7 +177,9 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(mock(Policy.class))); when(policyApplicationRepository.save(any(PolicyApplication.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -179,6 +192,10 @@ void setUp() { @Test void 이미_등록된_행이_있으면_POLICY_ALREADY_EXISTS_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(mock(Policy.class))); PolicyApplication existing = PolicyApplication.create( mock(User.class), @@ -201,6 +218,10 @@ void setUp() { @Test void soft_delete된_행이_있으면_재활성화한다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(mock(Policy.class))); PolicyApplication existing = PolicyApplication.create( mock(User.class), @@ -227,6 +248,10 @@ void setUp() { void soft_delete된_행을_재활성화할때_마감일을_지정하지_않으면_정책_마감일을_사용한다() { Policy policy = mock(Policy.class); when(policy.getApplicationEndDate()).thenReturn(LocalDate.of(2026, 10, 1)); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); PolicyApplication existing = PolicyApplication.create( mock(User.class), policy, ApplicationStatus.INTERESTED, null, null); @@ -244,6 +269,10 @@ void setUp() { @Test void soft_delete된_행을_재활성화하면_이전_체크리스트를_모두_소프트딜리트한다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(mock(Policy.class))); PolicyApplication existing = PolicyApplication.create( mock(User.class), @@ -267,7 +296,9 @@ void setUp() { USER_ID, POLICY_ID)) .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(mock(Policy.class))); when(policyApplicationRepository.save(any(PolicyApplication.class))) .thenThrow( new DataIntegrityViolationException("uk_policy_applications_user_policy")); @@ -287,9 +318,6 @@ void setUp() { @Test void 존재하지_않는_사용자면_USER_NOT_FOUND_예외를_던진다() { - when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( - USER_ID, POLICY_ID)) - .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); assertThatThrownBy( @@ -307,11 +335,10 @@ void setUp() { @Test void 존재하지_않는_정책이면_POLICY_NOT_FOUND_예외를_던진다() { - when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( - USER_ID, POLICY_ID)) - .thenReturn(Optional.empty()); when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); - when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.empty()); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.empty()); assertThatThrownBy( () -> diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java index 512e40d..3e583f5 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java @@ -11,6 +11,7 @@ import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyRegion; +import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.policy.entity.Region; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.repository.PolicyRegionRepository; @@ -43,7 +44,9 @@ void setUp() { Policy first = newPolicy(1L, "청년 월세 지원"); Policy second = newPolicy(2L, "청년 취업 장려금"); // repository가 요청과 다른 순서로 반환해도 응답은 요청 순서(2, 1)를 따라야 한다. - when(policyRepository.findAllById(List.of(2L, 1L))).thenReturn(List.of(first, second)); + when(policyRepository.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(2L, 1L), PolicyVisibility.VISIBLE)) + .thenReturn(List.of(first, second)); List policies = policyComparisonService.compare(List.of(2L, 1L)); @@ -56,7 +59,9 @@ void setUp() { void 정책별_지역을_묶어서_담고_지역이_없는_정책은_빈_목록으로_내려준다() { Policy first = newPolicy(1L, "청년 월세 지원"); Policy second = newPolicy(2L, "청년 취업 장려금"); - when(policyRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(first, second)); + when(policyRepository.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 2L), PolicyVisibility.VISIBLE)) + .thenReturn(List.of(first, second)); when(policyRegionRepository.findWithRegionByPolicyIdIn(List.of(1L, 2L))) .thenReturn( List.of( @@ -79,13 +84,17 @@ void setUp() { .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue( "errorCode", PolicyErrorCode.INVALID_COMPARISON_REQUEST); - verify(policyRepository, never()).findAllById(List.of(1L, 1L)); + verify(policyRepository, never()) + .findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 1L), PolicyVisibility.VISIBLE); } @Test void 존재하지_않는_정책이_섞여있으면_POLICY_NOT_FOUND_예외를_던진다() { Policy first = newPolicy(1L, "청년 월세 지원"); - when(policyRepository.findAllById(List.of(1L, 2L))).thenReturn(List.of(first)); + when(policyRepository.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 2L), PolicyVisibility.VISIBLE)) + .thenReturn(List.of(first)); assertThatThrownBy(() -> policyComparisonService.compare(List.of(1L, 2L))) .isInstanceOf(CustomException.class) @@ -97,7 +106,8 @@ void setUp() { Policy first = newPolicy(1L, "청년 월세 지원"); Policy second = newPolicy(2L, "청년 취업 장려금"); Policy third = newPolicy(3L, "청년 도약계좌"); - when(policyRepository.findAllById(List.of(1L, 2L, 3L))) + when(policyRepository.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 2L, 3L), PolicyVisibility.VISIBLE)) .thenReturn(List.of(first, second, third)); List policies = From e26c2d399bd070b7566f80952b0d3875fad61324 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Thu, 23 Jul 2026 00:30:30 +0900 Subject: [PATCH 71/93] =?UTF-8?q?fix:=20=EB=AA=A9=EB=A1=9D=20API=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EA=B3=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=9D=84=20=EB=B0=A9=EC=96=B4=20(#178)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/error/GlobalExceptionHandler.java | 11 +++++++++++ src/main/resources/application.yml | 1 + src/test/resources/application.yml | 1 + 3 files changed, 13 insertions(+) diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index ddf503c..ace9397 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -136,6 +137,16 @@ public ResponseEntity handleMethodNotSupported() { return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } + @ExceptionHandler(PropertyReferenceException.class) + public ResponseEntity handlePropertyReference( + PropertyReferenceException exception) { + ErrorResponse.FieldErrorDetail detail = + new ErrorResponse.FieldErrorDetail("sort", "", "정렬 기준이 올바르지 않습니다."); + ErrorCode errorCode = GlobalErrorCode.INVALID_INPUT_VALUE; + return ResponseEntity.status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, List.of(detail))); + } + // 개발자가 예상치 못한 에러 — application_logs 테이블에 남도록 ERROR로 로깅한다. @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 70f1fc0..48e1d03 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,7 @@ spring: # 프론트가 페이지 번호를 1부터 쓰므로(page=1이 첫 페이지) 맞춘다. # PageMeta.from()도 응답의 page를 +1해서 요청/응답 모두 1-based로 통일한다. one-indexed-parameters: true + max-page-size: 100 servlet: multipart: max-file-size: ${FILE_MAX_SIZE:10MB} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 968e935..e07e715 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -18,6 +18,7 @@ spring: web: pageable: one-indexed-parameters: true + max-page-size: 100 youthpick: file: From b2c63dce2ab9030fba04f50e1392cabd89255f48 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Thu, 23 Jul 2026 00:43:48 +0900 Subject: [PATCH 72/93] =?UTF-8?q?fix:=20CORS=20=ED=97=88=EC=9A=A9=20Origin?= =?UTF-8?q?=EC=9D=84=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=99=B8=EB=B6=80=ED=99=94=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bop/youthpick/auth/controller/AuthController.java | 5 +++-- .../com/bop/youthpick/global/config/CorsProperties.java | 7 +++++++ .../com/bop/youthpick/global/config/SecurityConfig.java | 7 ++----- .../com/bop/youthpick/global/config/WebSocketConfig.java | 3 ++- src/main/resources/application.yml | 2 ++ .../youthpick/auth/controller/AuthControllerTest.java | 9 +++++++++ src/test/resources/application.yml | 5 +++++ 7 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/global/config/CorsProperties.java diff --git a/src/main/java/com/bop/youthpick/auth/controller/AuthController.java b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java index 775fb43..2a241b0 100644 --- a/src/main/java/com/bop/youthpick/auth/controller/AuthController.java +++ b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java @@ -11,7 +11,7 @@ import com.bop.youthpick.auth.service.CurrentUser; import com.bop.youthpick.auth.service.RefreshTokenCookieSupport; import com.bop.youthpick.global.common.ApiResponse; -import com.bop.youthpick.global.config.SecurityConfig; +import com.bop.youthpick.global.config.CorsProperties; import com.bop.youthpick.user.entity.User; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -36,6 +36,7 @@ public class AuthController { private final AuthService authService; private final RefreshTokenCookieSupport refreshTokenCookieSupport; + private final CorsProperties corsProperties; @GetMapping("/oauth/{provider}/authorization-url") public ApiResponse authorizationUrl( @@ -92,7 +93,7 @@ private ResponseEntity> withRefreshCookie( } private void verifyOrigin(String origin) { - if (StringUtils.hasText(origin) && !SecurityConfig.ALLOWED_ORIGINS.contains(origin)) { + if (StringUtils.hasText(origin) && !corsProperties.allowedOrigins().contains(origin)) { throw new AuthException(AuthErrorCode.FORBIDDEN); } } diff --git a/src/main/java/com/bop/youthpick/global/config/CorsProperties.java b/src/main/java/com/bop/youthpick/global/config/CorsProperties.java new file mode 100644 index 0000000..cccdb63 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/CorsProperties.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.global.config; + +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "youthpick.cors") +public record CorsProperties(List allowedOrigins) {} diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 981b897..33311d7 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -35,13 +35,10 @@ @RequiredArgsConstructor public class SecurityConfig { - /** 프론트엔드 배포 주소. refresh token 쿠키 Origin 검증({@code AuthController})과 CORS 설정이 이 목록을 공유한다. */ - public static final List ALLOWED_ORIGINS = - List.of("http://localhost:3000", "http://localhost:5173", "http://localhost:5174"); - private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; private final RestAccessDeniedHandler restAccessDeniedHandler; private final JwtTokenProvider jwtTokenProvider; + private final CorsProperties corsProperties; @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -119,7 +116,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(ALLOWED_ORIGINS); + configuration.setAllowedOrigins(corsProperties.allowedOrigins()); configuration.setAllowedMethods( List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept")); diff --git a/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java index 3d88b86..30dfdc8 100644 --- a/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java @@ -29,11 +29,12 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final PolicyChatInboundInterceptor policyChatInboundInterceptor; private final ObjectMapper objectMapper; + private final CorsProperties corsProperties; @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/api/ws") - .setAllowedOrigins(SecurityConfig.ALLOWED_ORIGINS.toArray(String[]::new)); + .setAllowedOrigins(corsProperties.allowedOrigins().toArray(String[]::new)); registry.setPreserveReceiveOrder(true); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 70f1fc0..84d8275 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,6 +30,8 @@ spring: max-request-size: ${FILE_MAX_REQUEST_SIZE:11MB} youthpick: + cors: + allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,http://localhost:5174} file: max-size: ${FILE_MAX_SIZE:10MB} minio: diff --git a/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java b/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java index 0f80559..31f3187 100644 --- a/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java @@ -15,11 +15,13 @@ import com.bop.youthpick.auth.dto.TokenResponse; import com.bop.youthpick.auth.service.AuthService; import com.bop.youthpick.auth.service.RefreshTokenCookieSupport; +import com.bop.youthpick.global.config.CorsProperties; import com.bop.youthpick.user.entity.Role; import com.bop.youthpick.user.entity.User; import jakarta.servlet.http.Cookie; import java.time.Duration; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -43,6 +45,13 @@ class AuthControllerTest { @MockitoBean private RefreshTokenCookieSupport refreshTokenCookieSupport; + @MockitoBean private CorsProperties corsProperties; + + @BeforeEach + void setUp() { + when(corsProperties.allowedOrigins()).thenReturn(List.of("http://localhost:5173")); + } + @Test void 인가_url_요청시_ApiResponse로_url을_반환한다() throws Exception { when(authService.buildAuthorizationUrl("google")) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 968e935..c305b23 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -20,6 +20,11 @@ spring: one-indexed-parameters: true youthpick: + cors: + allowed-origins: + - http://localhost:3000 + - http://localhost:5173 + - http://localhost:5174 file: max-size: 10MB minio: From 2ebdb71e6f9e3ac20e9fed19fabbeb48c443f131 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Thu, 23 Jul 2026 01:03:46 +0900 Subject: [PATCH 73/93] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=9E=A5?= =?UTF-8?q?=EC=95=A0=20=EB=A1=9C=EA=B7=B8=EC=99=80=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../youthpick/file/service/FileService.java | 4 +++ .../global/config/TaskExecutionConfig.java | 2 ++ .../bop/youthpick/log/entity/SearchLog.java | 8 ++++++ .../log/service/SearchLogService.java | 27 +++++++++++++++++++ .../policy/service/PolicyService.java | 5 ++++ .../policy/service/PolicyServiceTest.java | 7 ++++- 6 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/bop/youthpick/log/service/SearchLogService.java diff --git a/src/main/java/com/bop/youthpick/file/service/FileService.java b/src/main/java/com/bop/youthpick/file/service/FileService.java index 3e82ec7..7bd59f4 100644 --- a/src/main/java/com/bop/youthpick/file/service/FileService.java +++ b/src/main/java/com/bop/youthpick/file/service/FileService.java @@ -11,10 +11,12 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @Service +@Slf4j public class FileService { private static final Set ALLOWED_CONTENT_TYPES = @@ -51,6 +53,7 @@ public FileUploadResponse upload(Long userId, MultipartFile file) { file.getContentType(), metadata); } catch (IOException | ObjectStorageException exception) { + log.warn("파일 저장소 업로드에 실패했습니다. fileId={}, userId={}", fileId, userId, exception); throw new FileException(FileErrorCode.STORAGE_UNAVAILABLE); } @@ -76,6 +79,7 @@ public FileDownload download(UUID fileId) { if (exception.isObjectNotFound()) { throw new FileException(FileErrorCode.FILE_NOT_FOUND); } + log.warn("파일 저장소 조회에 실패했습니다. fileId={}", fileId, exception); throw new FileException(FileErrorCode.STORAGE_UNAVAILABLE); } } diff --git a/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java index eb563b4..abddca8 100644 --- a/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java @@ -5,9 +5,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration +@EnableAsync public class TaskExecutionConfig { @Bean diff --git a/src/main/java/com/bop/youthpick/log/entity/SearchLog.java b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java index 0b51059..96b8113 100644 --- a/src/main/java/com/bop/youthpick/log/entity/SearchLog.java +++ b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java @@ -47,4 +47,12 @@ public class SearchLog { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + + public static SearchLog create(String query, int resultCount) { + SearchLog searchLog = new SearchLog(); + searchLog.query = query; + searchLog.normalized = query.trim().toLowerCase(); + searchLog.resultCount = resultCount; + return searchLog; + } } diff --git a/src/main/java/com/bop/youthpick/log/service/SearchLogService.java b/src/main/java/com/bop/youthpick/log/service/SearchLogService.java new file mode 100644 index 0000000..5e52e30 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/service/SearchLogService.java @@ -0,0 +1,27 @@ +package com.bop.youthpick.log.service; + +import com.bop.youthpick.log.entity.SearchLog; +import com.bop.youthpick.log.repository.SearchLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SearchLogService { + + private final SearchLogRepository searchLogRepository; + + @Async + @Transactional + public void record(String query, int resultCount) { + try { + searchLogRepository.save(SearchLog.create(query, resultCount)); + } catch (RuntimeException exception) { + log.warn("검색 로그 적재에 실패했습니다.", exception); + } + } +} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 10c6a23..22b2d8d 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -1,6 +1,7 @@ package com.bop.youthpick.policy.service; import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.log.service.SearchLogService; import com.bop.youthpick.policy.dto.PolicyCardResponse; import com.bop.youthpick.policy.dto.PolicyDetailResponse; import com.bop.youthpick.policy.dto.RegionResponse; @@ -33,6 +34,7 @@ public class PolicyService { private final PolicyRepository policyRepository; private final PolicyRegionRepository policyRegionRepository; private final PolicyRecentViewService policyRecentViewService; + private final SearchLogService searchLogService; private static final String NATIONWIDE_REGION = "전국"; @@ -73,6 +75,9 @@ public Page getCards( ageMax, trimToNull(jobCode), unsorted); + if (keywordPattern != null) { + searchLogService.record(trimToNull(keyword), (int) page.getTotalElements()); + } List policyIds = page.getContent().stream().map(Policy::getId).toList(); Map> sidoNamesByPolicyId = diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index d6d0eb2..646684a 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.when; import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.log.service.SearchLogService; import com.bop.youthpick.policy.dto.PolicyCardResponse; import com.bop.youthpick.policy.dto.PolicyDetailResponse; import com.bop.youthpick.policy.dto.RegionResponse; @@ -46,6 +47,7 @@ class PolicyServiceTest { @Mock private PolicyRepository policyRepository; @Mock private PolicyRegionRepository policyRegionRepository; @Mock private PolicyRecentViewService policyRecentViewService; + @Mock private SearchLogService searchLogService; private PolicyService policyService; @@ -53,7 +55,10 @@ class PolicyServiceTest { void setUp() { policyService = new PolicyService( - policyRepository, policyRegionRepository, policyRecentViewService); + policyRepository, + policyRegionRepository, + policyRecentViewService, + searchLogService); } @Test From ef04beab36a7d7f395755cda6f979a36764db3b4 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Thu, 23 Jul 2026 01:36:05 +0900 Subject: [PATCH 74/93] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20category=EB=A5=BC=20enum=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/bop/youthpick/post/controller/PostController.java | 6 ++++-- .../bop/youthpick/post/controller/PostControllerTest.java | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java index 4b9a5a7..2996c93 100644 --- a/src/main/java/com/bop/youthpick/post/controller/PostController.java +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -6,6 +6,7 @@ import com.bop.youthpick.post.dto.PostDetailResponse; import com.bop.youthpick.post.dto.PostSummaryResponse; import com.bop.youthpick.post.dto.PostUpdateRequest; +import com.bop.youthpick.post.entity.PostCategory; import com.bop.youthpick.post.service.PostService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -36,12 +37,13 @@ public ResponseEntity> create( @GetMapping // 리스폰스엔티티로 감싸야함 수정필요 public ApiResponse> findAll( - @RequestParam(required = false) String category, + @RequestParam(required = false) PostCategory category, @RequestParam(required = false) String query, @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - Page page = postService.findAll(category, query, pageable); + Page page = + postService.findAll(category == null ? null : category.name(), query, pageable); return ApiResponse.ok(page.getContent(), page); } diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java index 2117d5d..bc01309 100644 --- a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -134,6 +134,13 @@ void setUp() { verify(postService).findAll(eq("FREE"), eq("잡담"), any()); } + @Test + void 유효하지_않은_목록_카테고리는_400과_C001을_반환한다() throws Exception { + mockMvc.perform(get("/api/v1/posts").param("category", "INVALID")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + @Test void 게시글_상세를_조회한다() throws Exception { when(postService.findById(eq(3L), eq(1L), any())) From 48161b102891a7ee969b88c6936e306caa522bba Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 23 Jul 2026 03:25:20 +0900 Subject: [PATCH 75/93] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EA=B2=AC=EA=B3=A0=EC=84=B1=20(#187)=20(#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 로그 비동기화 및 인프라 견고성 (#187) * chore: spotless 포맷 적용 --- .../youthpick/global/config/TaskExecutionConfig.java | 11 +++++++++++ .../log/config/ApplicationLogDataSource.java | 1 + .../bop/youthpick/post/controller/PostController.java | 1 - .../bop/youthpick/sync/service/PolicySyncService.java | 4 ++-- .../sync/service/PolicySyncStartupRunner.java | 6 +++++- src/main/resources/log4j2.xml | 7 ++++++- 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java index abddca8..1a0794d 100644 --- a/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java @@ -23,4 +23,15 @@ TaskExecutor applicationTaskExecutor() { executor.setThreadNamePrefix("application-"); return executor; } + + @Bean + TaskExecutor batchTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setQueueCapacity(1); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + executor.setThreadNamePrefix("batch-sync-"); + return executor; + } } diff --git a/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java b/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java index e3f5323..cbebe5a 100644 --- a/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java +++ b/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java @@ -39,6 +39,7 @@ private static HikariDataSource build() { config.setPoolName("application-log-jdbc-appender"); config.setMaximumPoolSize(2); config.setMinimumIdle(0); + config.setConnectionTimeout(2000); return new HikariDataSource(config); } diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java index 150122e..7a0db16 100644 --- a/src/main/java/com/bop/youthpick/post/controller/PostController.java +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -10,7 +10,6 @@ import com.bop.youthpick.post.service.PostService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import jakarta.validation.constraints.Pattern; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java index 1c997e9..0694010 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java @@ -35,7 +35,7 @@ public class PolicySyncService { private final PolicySyncLock policySyncLock; - private final TaskExecutor taskExecutor; + private final TaskExecutor batchTaskExecutor; private final PolicyApiClient policyApiClient; private final PolicyMapper policyMapper; private final PolicyUpsertWriter policyUpsertWriter; @@ -72,7 +72,7 @@ public void startFullSyncAsync() { policySyncLock .tryAcquire() .orElseThrow(() -> new CustomException(SyncErrorCode.SYNC_ALREADY_RUNNING)); - taskExecutor.execute( + batchTaskExecutor.execute( () -> { try { doRunFullSync(); diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java index 48ea594..e40af0e 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java @@ -35,6 +35,10 @@ public void run(ApplicationArguments args) { return; } log.info("policies 비어 있음 — 초기 수집을 시작합니다"); - policySyncService.runFullSync(); + try { + policySyncService.runFullSync(); + } catch (RuntimeException e) { + log.warn("초기 수집 실패 — 외부 API 혹은 DB 장애가 의심됩니다. 수동 실행을 수행하거나 확인하세요.", e); + } } } diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml index 188f2c6..c6d7d1b 100644 --- a/src/main/resources/log4j2.xml +++ b/src/main/resources/log4j2.xml @@ -7,12 +7,17 @@ + + + + + - + From f999c188c406ace5ac7f8b34221895be092f57d7 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 23 Jul 2026 03:25:24 +0900 Subject: [PATCH 76/93] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9A=A9=EC=96=B4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=92=20=EA=B2=80=EC=A6=9D=20(#189)=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 프로필 용어 리팩토링 및 값 검증 (#189) * chore: spotless 포맷 적용 --- .../admin/user/dto/AdminUserProfileResponse.java | 4 ++-- .../policy/service/RecommendedPolicyService.java | 2 +- .../bop/youthpick/user/dto/UserProfileRequest.java | 2 +- .../bop/youthpick/user/dto/UserProfileResponse.java | 4 ++-- .../com/bop/youthpick/user/entity/UserProfile.java | 12 ++++++------ .../youthpick/user/service/UserProfileService.java | 4 ++-- .../V17__rename_user_profiles_merry_status.sql | 1 + .../user/controller/AdminUserControllerTest.java | 2 +- .../admin/user/service/AdminUserServiceTest.java | 4 ++-- .../user/controller/UserProfileControllerTest.java | 10 +++++----- .../user/service/UserProfileServiceTest.java | 4 ++-- 11 files changed, 25 insertions(+), 24 deletions(-) create mode 100644 src/main/resources/db/migration/V17__rename_user_profiles_merry_status.sql diff --git a/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java index 8c6621e..5f1a6fb 100644 --- a/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java @@ -9,7 +9,7 @@ public record AdminUserProfileResponse( Integer birthYear, String employmentStatus, String educationLevel, - String merryStatus, + String maritalStatus, List major, List specialCondition, Integer income, @@ -23,7 +23,7 @@ public static AdminUserProfileResponse from(UserProfile profile) { profile.getBirthYear(), profile.getEmploymentStatus(), profile.getEducationLevel(), - profile.getMerryStatus(), + profile.getMaritalStatus(), splitToList(profile.getMajor()), splitToList(profile.getSpecialCondition()), profile.getIncome(), diff --git a/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java b/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java index 24e4a84..684829d 100644 --- a/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java @@ -117,7 +117,7 @@ public List getRecommendations( userSidoName, userJobCode, userSchoolCode, - YouthPolicyCodeMapper.toMaritalCode(profile.getMerryStatus()), + YouthPolicyCodeMapper.toMaritalCode(profile.getMaritalStatus()), mapAll(profile.getMajor(), YouthPolicyCodeMapper::toMajorCode), mapAll( profile.getSpecialCondition(), diff --git a/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java index 7492cda..8ea388e 100644 --- a/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java +++ b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java @@ -31,7 +31,7 @@ public record UserProfileRequest( @Pattern(regexp = EducationLevel.PATTERN, message = "학력 코드 형식이 올바르지 않습니다.") String educationLevel, @Pattern(regexp = MaritalStatus.PATTERN, message = "결혼여부 코드 형식이 올바르지 않습니다.") - String merryStatus, + String maritalStatus, List<@Pattern(regexp = Major.PATTERN, message = "전공 코드 형식이 올바르지 않습니다.") String> major, List<@Pattern(regexp = SpecialCondition.PATTERN, message = "특화조건 코드 형식이 올바르지 않습니다.") String> specialCondition, diff --git a/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java b/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java index 02ea5e1..5de4035 100644 --- a/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java +++ b/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java @@ -11,7 +11,7 @@ public record UserProfileResponse( String regionCode, String employmentStatus, String educationLevel, - String merryStatus, + String maritalStatus, List major, List specialCondition, Integer income, @@ -26,7 +26,7 @@ public static UserProfileResponse from(UserProfile profile) { profile.getRegion().getCode(), profile.getEmploymentStatus(), profile.getEducationLevel(), - profile.getMerryStatus(), + profile.getMaritalStatus(), splitToList(profile.getMajor()), splitToList(profile.getSpecialCondition()), profile.getIncome(), diff --git a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java index 5e74a35..8c022fb 100644 --- a/src/main/java/com/bop/youthpick/user/entity/UserProfile.java +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -49,8 +49,8 @@ public class UserProfile extends BaseEntity { @Column(name = "education_level", length = 16, nullable = false) private String educationLevel; - @Column(name = "merry_status", length = 16) - private String merryStatus; + @Column(name = "marital_status", length = 16) + private String maritalStatus; @Column(length = 255) private String major; @@ -78,7 +78,7 @@ public static UserProfile create( Integer birthYear, String employmentStatus, String educationLevel, - String merryStatus, + String maritalStatus, String major, String specialCondition, Integer income, @@ -90,7 +90,7 @@ public static UserProfile create( profile.birthYear = birthYear; profile.employmentStatus = employmentStatus; profile.educationLevel = educationLevel; - profile.merryStatus = merryStatus; + profile.maritalStatus = maritalStatus; profile.major = major; profile.specialCondition = specialCondition; profile.income = income; @@ -104,7 +104,7 @@ public void update( Integer birthYear, String employmentStatus, String educationLevel, - String merryStatus, + String maritalStatus, String major, String specialCondition, Integer income, @@ -114,7 +114,7 @@ public void update( this.birthYear = birthYear; this.employmentStatus = employmentStatus; this.educationLevel = educationLevel; - this.merryStatus = merryStatus; + this.maritalStatus = maritalStatus; this.major = major; this.specialCondition = specialCondition; this.income = income; diff --git a/src/main/java/com/bop/youthpick/user/service/UserProfileService.java b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java index c07f95c..dc6110f 100644 --- a/src/main/java/com/bop/youthpick/user/service/UserProfileService.java +++ b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java @@ -53,7 +53,7 @@ public UserProfile submit(Long userId, UserProfileRequest request) { request.birthYear(), request.employmentStatus(), request.educationLevel(), - request.merryStatus(), + request.maritalStatus(), joinToCommaString(request.major()), joinToCommaString(request.specialCondition()), request.income(), @@ -86,7 +86,7 @@ public UserProfile update(Long userId, UserProfileRequest request) { request.birthYear(), request.employmentStatus(), request.educationLevel(), - request.merryStatus(), + request.maritalStatus(), joinToCommaString(request.major()), joinToCommaString(request.specialCondition()), request.income(), diff --git a/src/main/resources/db/migration/V17__rename_user_profiles_merry_status.sql b/src/main/resources/db/migration/V17__rename_user_profiles_merry_status.sql new file mode 100644 index 0000000..db0cb4a --- /dev/null +++ b/src/main/resources/db/migration/V17__rename_user_profiles_merry_status.sql @@ -0,0 +1 @@ +ALTER TABLE user_profiles CHANGE COLUMN merry_status marital_status VARCHAR(16) NULL COMMENT '결혼여부 코드 (선택)'; diff --git a/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java index f22733d..e89de3e 100644 --- a/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java @@ -82,7 +82,7 @@ class AdminUserControllerTest { mockMvc.perform(get("/api/v1/admin/users/{userId}/profile", 1L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.userId").value(1)) - .andExpect(jsonPath("$.data.merryStatus").value("SINGLE")) + .andExpect(jsonPath("$.data.maritalStatus").value("SINGLE")) .andExpect(jsonPath("$.data.major[0]").value("COMPUTER_SCIENCE")) .andExpect(jsonPath("$.data.specialCondition[0]").value("LOW_INCOME")) .andExpect(jsonPath("$.data.income").value(3000)) diff --git a/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java index 7d5ccc7..95102a7 100644 --- a/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java @@ -75,7 +75,7 @@ void setUp() { when(profile.getRegion()).thenReturn(region); when(region.getSidoName()).thenReturn("서울특별시"); when(region.getName()).thenReturn("강남구"); - when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMaritalStatus()).thenReturn("SINGLE"); when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); when(profile.getIncome()).thenReturn(3000); @@ -84,7 +84,7 @@ void setUp() { assertThat(result.userId()).isEqualTo(USER_ID); assertThat(result.regionLabel()).isEqualTo("서울특별시 강남구"); - assertThat(result.merryStatus()).isEqualTo("SINGLE"); + assertThat(result.maritalStatus()).isEqualTo("SINGLE"); assertThat(result.major()).containsExactly("COMPUTER_SCIENCE"); assertThat(result.specialCondition()).containsExactly("LOW_INCOME"); assertThat(result.income()).isEqualTo(3000); diff --git a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java index 6e6c09e..27b9ffa 100644 --- a/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java +++ b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java @@ -44,7 +44,7 @@ class UserProfileControllerTest { "regionCode": "11110", "employmentStatus": "EMPLOYED", "educationLevel": "UNIV_GRADUATE", - "merryStatus": "SINGLE", + "maritalStatus": "SINGLE", "major": ["ENGINEERING"], "specialCondition": ["BASIC_LIVELIHOOD"], "income": 3000, @@ -71,7 +71,7 @@ void clearContext() { when(profile.getBirthYear()).thenReturn(2000); when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); when(profile.getEducationLevel()).thenReturn("UNIV_GRADUATE"); - when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMaritalStatus()).thenReturn("SINGLE"); when(profile.getMajor()).thenReturn("ENGINEERING"); when(profile.getSpecialCondition()).thenReturn("BASIC_LIVELIHOOD"); when(profile.getIncome()).thenReturn(3000); @@ -91,7 +91,7 @@ void clearContext() { .andExpect(jsonPath("$.data.id").value(10)) .andExpect(jsonPath("$.data.userId").value(1)) .andExpect(jsonPath("$.data.regionCode").value("11110")) - .andExpect(jsonPath("$.data.merryStatus").value("SINGLE")) + .andExpect(jsonPath("$.data.maritalStatus").value("SINGLE")) .andExpect(jsonPath("$.data.major[0]").value("ENGINEERING")) .andExpect(jsonPath("$.data.specialCondition[0]").value("BASIC_LIVELIHOOD")) .andExpect(jsonPath("$.data.income").value(3000)) @@ -205,7 +205,7 @@ void clearContext() { when(profile.getBirthYear()).thenReturn(2000); when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); when(profile.getEducationLevel()).thenReturn("UNIV_GRADUATE"); - when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMaritalStatus()).thenReturn("SINGLE"); when(profile.getMajor()).thenReturn("ENGINEERING"); when(profile.getSpecialCondition()).thenReturn("BASIC_LIVELIHOOD"); when(profile.getIncome()).thenReturn(3000); @@ -252,7 +252,7 @@ void clearContext() { when(profile.getBirthYear()).thenReturn(2000); when(profile.getEmploymentStatus()).thenReturn("EMPLOYED"); when(profile.getEducationLevel()).thenReturn("UNIVERSITY"); - when(profile.getMerryStatus()).thenReturn("SINGLE"); + when(profile.getMaritalStatus()).thenReturn("SINGLE"); when(profile.getMajor()).thenReturn("COMPUTER_SCIENCE"); when(profile.getSpecialCondition()).thenReturn("LOW_INCOME"); when(profile.getIncome()).thenReturn(3000); diff --git a/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java index fc2223f..aebc7a7 100644 --- a/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java +++ b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java @@ -71,7 +71,7 @@ void setUp() { assertThat(result.getBirthYear()).isEqualTo(REQUEST.birthYear()); assertThat(result.getEmploymentStatus()).isEqualTo(REQUEST.employmentStatus()); assertThat(result.getEducationLevel()).isEqualTo(REQUEST.educationLevel()); - assertThat(result.getMerryStatus()).isEqualTo(REQUEST.merryStatus()); + assertThat(result.getMaritalStatus()).isEqualTo(REQUEST.maritalStatus()); assertThat(result.getMajor()).isEqualTo("COMPUTER_SCIENCE"); assertThat(result.getSpecialCondition()).isEqualTo("LOW_INCOME"); assertThat(result.getIncome()).isEqualTo(REQUEST.income()); @@ -172,7 +172,7 @@ void setUp() { assertThat(result.getBirthYear()).isEqualTo(REQUEST.birthYear()); assertThat(result.getEmploymentStatus()).isEqualTo(REQUEST.employmentStatus()); assertThat(result.getEducationLevel()).isEqualTo(REQUEST.educationLevel()); - assertThat(result.getMerryStatus()).isEqualTo(REQUEST.merryStatus()); + assertThat(result.getMaritalStatus()).isEqualTo(REQUEST.maritalStatus()); assertThat(result.getMajor()).isEqualTo("COMPUTER_SCIENCE"); assertThat(result.getSpecialCondition()).isEqualTo("LOW_INCOME"); assertThat(result.getIncome()).isEqualTo(REQUEST.income()); From 485bce4de3998cbb2c5d117d3136d2a3d8504439 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 23 Jul 2026 03:25:29 +0900 Subject: [PATCH 77/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20API=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BF=BC=EB=A6=AC=20=EC=B5=9C=EC=A0=81=ED=99=94=20?= =?UTF-8?q?(#191)=20(#192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 정책 신청 API 리팩토링 및 쿼리 최적화 (#191) * chore: spotless 포맷 적용 * fix: PolicyServiceTest stub findByPolicyIdIn → findWithRegionByPolicyIdIn --- .../PolicyApplicationController.java | 28 ++++++++----------- .../policy/controller/RegionController.java | 9 ++---- .../PolicyApplicationEndAtUpdateRequest.java | 7 +++++ .../PolicyApplicationMemoUpdateRequest.java | 6 ++++ .../policy/service/PolicyService.java | 2 +- .../policy/service/RegionService.java | 22 +++++++++++++++ .../PolicyApplicationControllerTest.java | 27 ++++++++++++------ .../controller/RegionControllerTest.java | 10 +++---- .../policy/service/PolicyServiceTest.java | 8 +++--- 9 files changed, 78 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationEndAtUpdateRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationMemoUpdateRequest.java create mode 100644 src/main/java/com/bop/youthpick/policy/service/RegionService.java diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java index c6db9fa..d23bee8 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java @@ -4,6 +4,8 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.global.error.CustomException; import com.bop.youthpick.policy.dto.PolicyApplicationCreateRequest; +import com.bop.youthpick.policy.dto.PolicyApplicationEndAtUpdateRequest; +import com.bop.youthpick.policy.dto.PolicyApplicationMemoUpdateRequest; import com.bop.youthpick.policy.dto.PolicyApplicationResponse; import com.bop.youthpick.policy.entity.ApplicationStatus; import com.bop.youthpick.policy.entity.PolicyApplication; @@ -12,14 +14,11 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; -import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -101,32 +100,27 @@ public ApiResponse changeStatus( } /** - * {@code PATCH /api/v1/policy-applications/{id}/memo} — 메모만 단독 수정. 컨트롤러는 {@code @Size}로 길이만 - * 검증하고, 빈 문자열/공백을 null로 통일하는 정규화는 서비스 계층이 담당한다 — 여기서 값을 가공하지 않는다. + * {@code PATCH /api/v1/policy-applications/{id}/memo} — 메모만 단독 수정. 컨트롤러는 {@code @Size}로 DTO 필드 + * 길이를 검증하고, 빈 문자열/공백을 null로 통일하는 정규화는 서비스 계층이 담당한다. */ @PatchMapping("/{id}/memo") public ApiResponse updateMemo( @CurrentUser Long userId, @PathVariable Long id, - @RequestParam @Size(max = 2000, message = "메모는 2000자를 초과할 수 없습니다.") String memo) { - PolicyApplication application = policyApplicationService.updateMemo(id, userId, memo); + @Valid @RequestBody PolicyApplicationMemoUpdateRequest request) { + PolicyApplication application = + policyApplicationService.updateMemo(id, userId, request.memo()); return ApiResponse.ok(PolicyApplicationResponse.from(application)); } - /** - * {@code PATCH /api/v1/policy-applications/{id}/end-at} — memo와 달리 endAt은 생략 시 필수 에러가 아니라 마감일 - * 초기화(clear)로 동작한다. {@code LocalDateTime}처럼 String이 아닌 타입은 Spring이 "파라미터 생략"과 "빈 문자열(endAt=)"을 - * 바인딩 단계에서 이미 null로 합쳐 버려 required=true로는 이 둘을 구분할 수 없다(둘 다 "필수값 없음" 에러가 됨) — 그래서 memo처럼 필수로 - * 강제하지 않고 의도적으로 생략=초기화로 둔다. 정책 마감일을 넘는지 검증하는 로직은 {@link PolicyApplicationService#updateEndAt}에 - * 있다. - */ + /** {@code PATCH /api/v1/policy-applications/{id}/end-at} — 마감일 단독 수정. */ @PatchMapping("/{id}/end-at") public ApiResponse updateEndAt( @CurrentUser Long userId, @PathVariable Long id, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - LocalDateTime endAt) { - PolicyApplication application = policyApplicationService.updateEndAt(id, userId, endAt); + @Valid @RequestBody PolicyApplicationEndAtUpdateRequest request) { + PolicyApplication application = + policyApplicationService.updateEndAt(id, userId, request.endAt()); return ApiResponse.ok(PolicyApplicationResponse.from(application)); } diff --git a/src/main/java/com/bop/youthpick/policy/controller/RegionController.java b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java index 6148008..aa265a0 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/RegionController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java @@ -2,7 +2,7 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.RegionResponse; -import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.policy.service.RegionService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -15,13 +15,10 @@ @RequiredArgsConstructor public class RegionController { - private final RegionRepository regionRepository; + private final RegionService regionService; @GetMapping public ApiResponse> list() { - return ApiResponse.ok( - regionRepository.findAllByOrderBySidoNameAscNameAsc().stream() - .map(RegionResponse::from) - .toList()); + return ApiResponse.ok(regionService.getAllRegions()); } } diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationEndAtUpdateRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationEndAtUpdateRequest.java new file mode 100644 index 0000000..8f6897b --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationEndAtUpdateRequest.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.policy.dto; + +import java.time.LocalDateTime; +import org.springframework.format.annotation.DateTimeFormat; + +public record PolicyApplicationEndAtUpdateRequest( + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endAt) {} diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationMemoUpdateRequest.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationMemoUpdateRequest.java new file mode 100644 index 0000000..4801fb5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationMemoUpdateRequest.java @@ -0,0 +1,6 @@ +package com.bop.youthpick.policy.dto; + +import jakarta.validation.constraints.Size; + +public record PolicyApplicationMemoUpdateRequest( + @Size(max = 2000, message = "메모는 2000자를 초과할 수 없습니다.") String memo) {} diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index 133de61..94bcefc 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -146,7 +146,7 @@ public PolicyDetailResponse getDetail(Long policyId, @Nullable Long userId) { } List regions = - policyRegionRepository.findByPolicyIdIn(List.of(policyId)).stream() + policyRegionRepository.findWithRegionByPolicyIdIn(List.of(policyId)).stream() .map(policyRegion -> RegionResponse.from(policyRegion.getRegion())) .toList(); return PolicyDetailResponse.from(policy, regions); diff --git a/src/main/java/com/bop/youthpick/policy/service/RegionService.java b/src/main/java/com/bop/youthpick/policy/service/RegionService.java new file mode 100644 index 0000000..cb9131a --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/RegionService.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.policy.service; + +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.repository.RegionRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RegionService { + + private final RegionRepository regionRepository; + + @Transactional(readOnly = true) + public List getAllRegions() { + return regionRepository.findAllByOrderBySidoNameAscNameAsc().stream() + .map(RegionResponse::from) + .toList(); + } +} diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java index ccbf684..7c68375 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java @@ -270,7 +270,8 @@ class PolicyApplicationControllerTest { () -> mockMvc.perform( patch("/api/v1/policy-applications/{id}/memo", 10L) - .param("memo", "새 메모")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memo\":\"새 메모\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.memo").value("새 메모"))); } @@ -283,7 +284,8 @@ class PolicyApplicationControllerTest { () -> mockMvc.perform( patch("/api/v1/policy-applications/{id}/memo", 10L) - .param("memo", tooLongMemo)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memo\":\"" + tooLongMemo + "\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("C001"))); } @@ -292,7 +294,9 @@ class PolicyApplicationControllerTest { void updateMemo_파라미터를_생략하면_400과_C001을_반환한다() throws Exception { withAuthenticatedPrincipal( () -> - mockMvc.perform(patch("/api/v1/policy-applications/{id}/memo", 10L)) + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/memo", 10L) + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("C001"))); } @@ -312,7 +316,8 @@ class PolicyApplicationControllerTest { () -> mockMvc.perform( patch("/api/v1/policy-applications/{id}/memo", 10L) - .param("memo", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memo\":\"\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.memo").value(""))); } @@ -332,7 +337,8 @@ class PolicyApplicationControllerTest { () -> mockMvc.perform( patch("/api/v1/policy-applications/{id}/end-at", 10L) - .param("endAt", "2026-12-31T23:59:00")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"endAt\":\"2026-12-31T23:59:00\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.endAt").value("2026-12-31T23:59:00"))); } @@ -353,7 +359,8 @@ class PolicyApplicationControllerTest { () -> mockMvc.perform( patch("/api/v1/policy-applications/{id}/end-at", 10L) - .param("endAt", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"endAt\":null}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.endAt").doesNotExist())); } @@ -372,7 +379,10 @@ class PolicyApplicationControllerTest { withAuthenticatedPrincipal( () -> - mockMvc.perform(patch("/api/v1/policy-applications/{id}/end-at", 10L)) + mockMvc.perform( + patch("/api/v1/policy-applications/{id}/end-at", 10L) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.endAt").doesNotExist())); } @@ -383,7 +393,8 @@ class PolicyApplicationControllerTest { () -> mockMvc.perform( patch("/api/v1/policy-applications/{id}/end-at", 10L) - .param("endAt", "not-a-date")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"endAt\":\"not-a-date\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("C001"))); } diff --git a/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java index c2962d2..05f9da2 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java +++ b/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java @@ -5,8 +5,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.bop.youthpick.policy.entity.Region; -import com.bop.youthpick.policy.repository.RegionRepository; +import com.bop.youthpick.policy.dto.RegionResponse; +import com.bop.youthpick.policy.service.RegionService; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -21,12 +21,12 @@ class RegionControllerTest { @Autowired private MockMvc mockMvc; - @MockitoBean private RegionRepository regionRepository; + @MockitoBean private RegionService regionService; @Test void 지역_목록은_200과_전체_목록을_반환한다() throws Exception { - Region region = Region.create("11110", "서울특별시", "종로구"); - when(regionRepository.findAllByOrderBySidoNameAscNameAsc()).thenReturn(List.of(region)); + RegionResponse response = new RegionResponse("11110", "서울특별시", "종로구"); + when(regionService.getAllRegions()).thenReturn(List.of(response)); mockMvc.perform(get("/api/v1/regions")) .andExpect(status().isOk()) diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index d0bc1e9..4dfda4e 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -67,7 +67,7 @@ void setUp() { when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); - when(policyRegionRepository.findByPolicyIdIn(List.of(1L))) + when(policyRegionRepository.findWithRegionByPolicyIdIn(List.of(1L))) .thenReturn(List.of(newPolicyRegion(policy, "11680", "서울특별시", "강남구"))); PolicyDetailResponse response = policyService.getDetail(1L, null); @@ -95,7 +95,7 @@ void setUp() { when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); - when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); + when(policyRegionRepository.findWithRegionByPolicyIdIn(anyList())).thenReturn(List.of()); policyService.getDetail(1L, 7L); @@ -108,7 +108,7 @@ void setUp() { when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); - when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); + when(policyRegionRepository.findWithRegionByPolicyIdIn(anyList())).thenReturn(List.of()); doThrow(new DataAccessResourceFailureException("DB 연결 실패")) .when(policyRecentViewService) .record(7L, policy); @@ -125,7 +125,7 @@ void setUp() { when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( 1L, PolicyVisibility.VISIBLE)) .thenReturn(Optional.of(policy)); - when(policyRegionRepository.findByPolicyIdIn(anyList())).thenReturn(List.of()); + when(policyRegionRepository.findWithRegionByPolicyIdIn(anyList())).thenReturn(List.of()); policyService.getDetail(1L, null); From 892d31712808a3444f8a13912467830393735dde Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 23 Jul 2026 03:25:33 +0900 Subject: [PATCH 78/93] =?UTF-8?q?fix:=20=EC=A1=B0=ED=9A=8C=EC=88=98=20IP?= =?UTF-8?q?=20=EC=8A=A4=ED=91=B8=ED=95=91=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20Redis=20=EC=BA=90=EC=8B=9C=20=EC=98=A4=EB=B2=84=ED=97=A4?= =?UTF-8?q?=EB=93=9C=20=EC=B0=A8=EB=8B=A8=20(#193)=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 조회수 IP 스푸핑 방지 및 Redis 캐시 오버헤드 차단 (#193) * chore: spotless 포맷 적용 --- .../board/dto/AdminAttachmentResponse.java | 3 +-- .../post/controller/PostController.java | 27 +------------------ .../bop/youthpick/post/entity/Attachment.java | 4 --- .../youthpick/post/service/PostService.java | 4 ++- src/main/resources/application.yml | 4 +++ .../V18__remove_attachments_file_size.sql | 1 + .../AdminCommunityPostControllerTest.java | 1 - 7 files changed, 10 insertions(+), 34 deletions(-) create mode 100644 src/main/resources/db/migration/V18__remove_attachments_file_size.sql diff --git a/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java index d941e28..c78a27b 100644 --- a/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java @@ -4,13 +4,12 @@ import java.time.LocalDateTime; public record AdminAttachmentResponse( - Long id, Long postId, String fileKey, Long fileSize, LocalDateTime createdAt) { + Long id, Long postId, String fileKey, LocalDateTime createdAt) { public static AdminAttachmentResponse from(Attachment attachment) { return new AdminAttachmentResponse( attachment.getId(), attachment.getPost().getId(), attachment.getFileUrl(), - attachment.getFileSize(), attachment.getCreatedAt()); } } diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java index 7a0db16..8c7a912 100644 --- a/src/main/java/com/bop/youthpick/post/controller/PostController.java +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -56,32 +56,7 @@ public ApiResponse findById( @PathVariable Long postId, @CurrentUser(required = false) Long userId, HttpServletRequest request) { - String ipAddress = getClientIp(request); - return ApiResponse.ok(postService.findById(postId, userId, ipAddress)); - } - - private String getClientIp(HttpServletRequest request) { - String ip = request.getHeader("X-Forwarded-For"); - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("Proxy-Client-IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("WL-Proxy-Client-IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("HTTP_CLIENT_IP"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getHeader("HTTP_X_FORWARDED_FOR"); - } - if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { - ip = request.getRemoteAddr(); - } - - if (ip != null && ip.contains(",")) { - ip = ip.split(",")[0].trim(); - } - return ip; + return ApiResponse.ok(postService.findById(postId, userId, request.getRemoteAddr())); } @PatchMapping("/{postId}") diff --git a/src/main/java/com/bop/youthpick/post/entity/Attachment.java b/src/main/java/com/bop/youthpick/post/entity/Attachment.java index cfc6989..bf6849b 100644 --- a/src/main/java/com/bop/youthpick/post/entity/Attachment.java +++ b/src/main/java/com/bop/youthpick/post/entity/Attachment.java @@ -26,10 +26,6 @@ public class Attachment extends BaseEntity { @Column(name = "file_url", length = 500, nullable = false) private String fileUrl; - /** byte */ - @Column(name = "file_size") - private Long fileSize; - public static Attachment create(Post post, String fileUrl) { Attachment attachment = new Attachment(); attachment.post = post; diff --git a/src/main/java/com/bop/youthpick/post/service/PostService.java b/src/main/java/com/bop/youthpick/post/service/PostService.java index 6e3820d..841af12 100644 --- a/src/main/java/com/bop/youthpick/post/service/PostService.java +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -60,11 +60,13 @@ public Page findAll(String category, String query, Pageable @Transactional public PostDetailResponse findById(Long postId, Long userId, String ipAddress) { + Post post = findPost(postId); String identifier = userId != null ? "user:" + userId : "ip:" + ipAddress; if (postViewLogStore.isFirstView(postId, identifier)) { postRepository.incrementViewCount(postId); + post.incrementViewCount(); } - return PostDetailResponse.from(findPost(postId)); + return PostDetailResponse.from(post); } @Transactional diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 526762e..f80e298 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -58,3 +58,7 @@ youthpick: enabled: false # 새벽 4시: 온통청년 갱신 반영 + 트래픽 최저 시간대 cron: "0 0 4 * * *" + +server: + forward-headers-strategy: framework + diff --git a/src/main/resources/db/migration/V18__remove_attachments_file_size.sql b/src/main/resources/db/migration/V18__remove_attachments_file_size.sql new file mode 100644 index 0000000..486459a --- /dev/null +++ b/src/main/resources/db/migration/V18__remove_attachments_file_size.sql @@ -0,0 +1 @@ +ALTER TABLE attachments DROP COLUMN file_size; diff --git a/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java index 0ea8fbe..52d858e 100644 --- a/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java @@ -101,7 +101,6 @@ class AdminCommunityPostControllerTest { 3L, 1L, "https://example.com/file.pdf", - 1024L, LocalDateTime.now()))); mockMvc.perform(get("/api/v1/admin/community-posts/{postId}/attachments", 1L)) From 9b0e0076319ff2127ca35eda352cee2b40064a73 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 23 Jul 2026 03:25:37 +0900 Subject: [PATCH 79/93] =?UTF-8?q?fix:=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20(=EB=B0=B1=EC=98=A4=ED=94=84=20&=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9E=84=EA=B3=84=EC=B9=98)=20(#195)=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 동기화 배치 안정성 강화 (백오프 & 실패 임계치) (#195) * chore: spotless 포맷 적용 --- .../policy/service/AdminPolicyService.java | 22 +++++----- .../sync/service/PolicyApiClient.java | 11 +++++ .../sync/service/PolicySyncService.java | 44 +++++++++++++------ .../service/AdminPolicyServiceTest.java | 6 +-- 4 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java index 26292db..23b021b 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java @@ -67,7 +67,9 @@ public AdminPolicyResponse update(Long policyId, AdminPolicyUpdateRequest reques request.applicationUrl()); policyRegionRepository.deleteByPolicyId(policyId); - regions.forEach(region -> policyRegionRepository.save(PolicyRegion.create(policy, region))); + List policyRegions = + regions.stream().map(region -> PolicyRegion.create(policy, region)).toList(); + policyRegionRepository.saveAll(policyRegions); return AdminPolicyResponse.from(policy, request.regionCodes()); } @@ -97,16 +99,14 @@ private Policy findPolicy(Long policyId) { } private List resolveRegions(List regionCodes) { - return regionCodes.stream() - .map( - code -> - regionRepository - .findById(code) - .orElseThrow( - () -> - new CustomException( - PolicyErrorCode.REGION_NOT_FOUND))) - .toList(); + if (regionCodes == null || regionCodes.isEmpty()) { + return List.of(); + } + List regions = regionRepository.findAllById(regionCodes); + if (regions.size() != regionCodes.size()) { + throw new CustomException(PolicyErrorCode.REGION_NOT_FOUND); + } + return regions; } private List regionCodesOf(Long policyId) { diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java b/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java index 51315e8..d37c1c5 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java @@ -69,6 +69,7 @@ public List fetchAll() { private YouthPolicyApiResponse.Result fetchPageWithRetry(int pageNum) { RuntimeException lastFailure = null; + long delay = 1000; for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { return requestPage(pageNum); @@ -81,6 +82,16 @@ private YouthPolicyApiResponse.Result fetchPageWithRetry(int pageNum) { MAX_ATTEMPTS, e.getMessage()); lastFailure = e; + if (attempt < MAX_ATTEMPTS) { + try { + long jitter = (long) (Math.random() * 500); + Thread.sleep(delay + jitter); + delay *= 2; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new PolicySyncException("정책 수집 중 인터럽트 발생", ie); + } + } } } throw new PolicySyncException( diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java index 0694010..8ff9347 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java @@ -94,19 +94,35 @@ private PolicyBatchHistory doRunFullSync() { SyncPlan plan = plan(items); PolicyWriteResult writeResult = policyUpsertWriter.upsertAll(plan.upserts()); int missingMarked = policyUpsertWriter.hideMissing(plan.missingPolicyNos()); - history.succeed( - writeResult.newCount(), - writeResult.updatedCount(), - plan.unchangedCount(), - missingMarked, - writeResult.errorCount()); - log.info( - "정책 수집 완료 — 신규 {} / 변경 {} / 유지 {} / 누락 {} / 실패 {}", - writeResult.newCount(), - writeResult.updatedCount(), - plan.unchangedCount(), - missingMarked, - writeResult.errorCount()); + + int totalProcessed = plan.upserts().size(); + double errorRate = 0.0; + if (totalProcessed > 0) { + errorRate = (double) writeResult.errorCount() / totalProcessed; + } + + if (errorRate > 0.1) { + String errorMsg = + "정책 수집 실패율 10% 초과 — 에러 건수: %d/%d (%.2f%%)" + .formatted( + writeResult.errorCount(), totalProcessed, errorRate * 100); + history.fail(errorMsg); + log.error("정책 수집 완료되었으나 실패율 기준 초과로 작업 실패 처리함: {}", errorMsg); + } else { + history.succeed( + writeResult.newCount(), + writeResult.updatedCount(), + plan.unchangedCount(), + missingMarked, + writeResult.errorCount()); + log.info( + "정책 수집 완료 — 신규 {} / 변경 {} / 유지 {} / 누락 {} / 실패 {}", + writeResult.newCount(), + writeResult.updatedCount(), + plan.unchangedCount(), + missingMarked, + writeResult.errorCount()); + } return historyRepository.save(history); } catch (RuntimeException e) { history.fail(e.getMessage()); @@ -148,6 +164,8 @@ private SyncPlan plan(List items) { // 변경으로 취급해 updateFrom이 VISIBLE 복구 + missing_count 리셋을 수행하게 한다. if (snapshot != null && snapshot.visibility() == PolicyVisibility.VISIBLE + && snapshot.lastModifiedAt() != null + && policy.getLastModifiedAt() != null && Objects.equals(snapshot.lastModifiedAt(), policy.getLastModifiedAt())) { unchangedCount++; continue; diff --git a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java index eb3e001..a6afa56 100644 --- a/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java @@ -88,7 +88,7 @@ void setUp() { Policy policy = mock(Policy.class); Region region = mock(Region.class); when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(policy)); - when(regionRepository.findById("11110")).thenReturn(Optional.of(region)); + when(regionRepository.findAllById(List.of("11110"))).thenReturn(List.of(region)); adminPolicyService.update(POLICY_ID, UPDATE_REQUEST); @@ -103,7 +103,7 @@ void setUp() { LocalDate.of(2026, 12, 31), "https://example.com"); verify(policyRegionRepository).deleteByPolicyId(POLICY_ID); - verify(policyRegionRepository).save(any(PolicyRegion.class)); + verify(policyRegionRepository).saveAll(any()); } @Test @@ -119,7 +119,7 @@ void setUp() { @Test void 존재하지_않는_지역코드로_수정하면_REGION_NOT_FOUND_예외를_던진다() { when(policyRepository.findById(POLICY_ID)).thenReturn(Optional.of(mock(Policy.class))); - when(regionRepository.findById("11110")).thenReturn(Optional.empty()); + when(regionRepository.findAllById(List.of("11110"))).thenReturn(List.of()); assertThatThrownBy(() -> adminPolicyService.update(POLICY_ID, UPDATE_REQUEST)) .isInstanceOf(CustomException.class) From d6fb1d1a9b63652923cc22bfa4ebddacae84cce1 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 23 Jul 2026 03:25:42 +0900 Subject: [PATCH 80/93] =?UTF-8?q?fix:=20API=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=B0=8F=20=EB=A1=9C=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=8B=88=EC=8A=A4=20=EA=B0=95=ED=99=94=20(#197)=20(#1?= =?UTF-8?q?98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: API 명세 갱신 및 로버스트니스 강화 (#197) * chore: spotless 포맷 적용 * fix: AuthService @Slf4j 누락 추가 --- docs/api-spec.md | 8 +- .../bop/youthpick/YouthpickApplication.java | 2 + .../auth/exception/AuthErrorCode.java | 5 +- .../youthpick/auth/service/AuthService.java | 8 +- .../auth/service/JwtTokenProvider.java | 2 + .../file/scheduler/FileCleanupScheduler.java | 87 +++++++++++++++++++ .../global/error/GlobalExceptionHandler.java | 12 +++ .../sync/service/PolicySyncScheduler.java | 4 - .../auth/service/AuthServiceTest.java | 28 ++++++ .../auth/service/JwtTokenProviderTest.java | 4 +- 10 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/file/scheduler/FileCleanupScheduler.java diff --git a/docs/api-spec.md b/docs/api-spec.md index 50431c5..cc0ae27 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -42,8 +42,8 @@ Notion `API 명세 DB`의 현재 데이터를 기준으로 생성한 백엔드 A | 신청관리 | 신청 등록 | `POST` | `/api/v1/policy-applications` | 회원 | body: policyId, status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED), memo 선택, endAt 선택 | | 신청관리 | 신청 목록 조회 | `GET` | `/api/v1/policy-applications` | 회원 | query: page 기본 1, size 기본 20 | | 신청관리 | 상태 변경 | `PATCH` | `/api/v1/policy-applications/{id}/status` | 회원 | path: id. query: status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED) | -| 신청관리 | 메모 수정 | `PATCH` | `/api/v1/policy-applications/{id}/memo` | 회원 | path: id. query: memo (필수, 최대 2000자) | -| 신청관리 | 마감일 수정 | `PATCH` | `/api/v1/policy-applications/{id}/end-at` | 회원 | path: id. query: endAt 선택(ISO-8601 date-time, 생략 시 마감일 초기화) | +| 신청관리 | 메모 수정 | `PATCH` | `/api/v1/policy-applications/{id}/memo` | 회원 | path: id. body: memo (필수, 최대 2000자) | +| 신청관리 | 마감일 수정 | `PATCH` | `/api/v1/policy-applications/{id}/end-at` | 회원 | path: id. body: endAt 선택(ISO-8601 date-time, 생략 시 마감일 초기화) | | 신청관리 | 신청 삭제 | `DELETE` | `/api/v1/policy-applications/{id}` | 회원 | path: id | | 신청관리 | 체크리스트 추가 | `POST` | `/api/v1/policy-application-checklists` | 회원 | body: applicationId, message | | 신청관리 | 체크리스트 조회 | `GET` | `/api/v1/policy-application-checklists/application/{applicationId}` | 회원 | path: applicationId. query: page 기본 1, size 기본 20 | @@ -288,7 +288,7 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 | 메서드 | `PATCH` | | 경로 | `/api/v1/policy-applications/{id}/memo` | | 권한 | 회원 | -| 파라미터 | path: id. query: memo (필수, 최대 2000자) | +| 파라미터 | path: id. body: memo (필수, 최대 2000자) | ### 마감일 수정 @@ -299,7 +299,7 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 | 메서드 | `PATCH` | | 경로 | `/api/v1/policy-applications/{id}/end-at` | | 권한 | 회원 | -| 파라미터 | path: id. query: endAt 선택(ISO-8601 date-time, 생략 시 마감일 초기화) | +| 파라미터 | path: id. body: endAt 선택(ISO-8601 date-time, 생략 시 마감일 초기화) | ### 신청 삭제 diff --git a/src/main/java/com/bop/youthpick/YouthpickApplication.java b/src/main/java/com/bop/youthpick/YouthpickApplication.java index ecb4f66..292bc72 100644 --- a/src/main/java/com/bop/youthpick/YouthpickApplication.java +++ b/src/main/java/com/bop/youthpick/YouthpickApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling public class YouthpickApplication { public static void main(String[] args) { diff --git a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java index cb2c4d1..35773e9 100644 --- a/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java @@ -16,7 +16,10 @@ public enum AuthErrorCode implements ErrorCode { INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A006", "유효하지 않은 토큰입니다."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "A007", "리프레시 토큰이 유효하지 않습니다. 다시 로그인해주세요."), FORBIDDEN(HttpStatus.FORBIDDEN, "A008", "접근 권한이 없습니다."), - ACCOUNT_DISABLED(HttpStatus.FORBIDDEN, "A009", "이용이 제한된 계정입니다."); + ACCOUNT_DISABLED(HttpStatus.FORBIDDEN, "A009", "이용이 제한된 계정입니다."), + REDIS_CONNECTION_FAILURE( + HttpStatus.SERVICE_UNAVAILABLE, "A010", "인증 서비스가 일시적으로 원활하지 않습니다. 잠시 후 다시 시도해주세요."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A011", "만료된 토큰입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/bop/youthpick/auth/service/AuthService.java b/src/main/java/com/bop/youthpick/auth/service/AuthService.java index 5c0d8f8..271ec2c 100644 --- a/src/main/java/com/bop/youthpick/auth/service/AuthService.java +++ b/src/main/java/com/bop/youthpick/auth/service/AuthService.java @@ -11,6 +11,7 @@ import io.jsonwebtoken.Claims; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,7 @@ * 카카오/구글/네이버 OAuth2 authorization code 로그인 흐름. 인증은 JWT access/refresh token으로 발급하며, refresh token은 * {@code RefreshTokenStore}(Redis, TTL)로 관리한다. */ +@Slf4j @Service @RequiredArgsConstructor public class AuthService { @@ -73,7 +75,11 @@ public TokenResponse login(String registrationId, String code, String state) { OAuthUserInfo userInfo = oAuthClient.fetchUserInfo(provider, accessToken); User user = findOrCreateUser(provider, userInfo); - loginHistoryRepository.save(LoginHistory.create(user)); + try { + loginHistoryRepository.save(LoginHistory.create(user)); + } catch (RuntimeException e) { + log.warn("로그인 이력(LoginHistory) 저장 실패 - 로그인 프로세스는 정상 진행합니다. userId={}", user.getId(), e); + } return issueTokens(user); } diff --git a/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java b/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java index 71918f3..0f50cd9 100644 --- a/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java +++ b/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java @@ -80,6 +80,8 @@ private Claims requireClaims(String token, String expectedType) { private Claims parseClaims(String token) { try { return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload(); + } catch (io.jsonwebtoken.ExpiredJwtException e) { + throw new AuthException(AuthErrorCode.EXPIRED_TOKEN); } catch (JwtException | IllegalArgumentException e) { throw new AuthException(AuthErrorCode.INVALID_TOKEN); } diff --git a/src/main/java/com/bop/youthpick/file/scheduler/FileCleanupScheduler.java b/src/main/java/com/bop/youthpick/file/scheduler/FileCleanupScheduler.java new file mode 100644 index 0000000..578cf02 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/scheduler/FileCleanupScheduler.java @@ -0,0 +1,87 @@ +package com.bop.youthpick.file.scheduler; + +import com.bop.youthpick.file.service.MinioProperties; +import com.bop.youthpick.post.repository.AttachmentRepository; +import io.minio.ListObjectsArgs; +import io.minio.MinioClient; +import io.minio.RemoveObjectArgs; +import io.minio.Result; +import io.minio.messages.Item; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FileCleanupScheduler { + + private final MinioClient minioClient; + private final MinioProperties properties; + private final AttachmentRepository attachmentRepository; + + // 매주 일요일 새벽 3시에 고아 파일 정리 실행 (0 0 3 * * SUN) + @Scheduled(cron = "0 0 3 * * SUN") + public void cleanupOrphanFiles() { + log.info("고아 파일(미참조 이미지) 정리 스케줄러 시작"); + try { + // 1. DB의 모든 첨부파일 URL을 가져온 후 파일명(또는 키) 추출 + Set dbFileKeys = + attachmentRepository.findAll().stream() + .map(attachment -> extractFileKey(attachment.getFileUrl())) + .filter(key -> key != null) + .collect(Collectors.toSet()); + + // 2. MinIO 버킷의 모든 객체 목록 긁어오기 + Iterable> results = + minioClient.listObjects( + ListObjectsArgs.builder().bucket(properties.bucket()).build()); + + List orphanKeys = new ArrayList<>(); + for (Result result : results) { + Item item = result.get(); + String objectKey = item.objectName(); + // DB에 해당 objectKey가 존재하지 않으면 고아 파일로 판단 + if (!dbFileKeys.contains(objectKey)) { + orphanKeys.add(objectKey); + } + } + + log.info("발견된 고아 파일 개수: {}개", orphanKeys.size()); + + // 3. 고아 파일 삭제 진행 + for (String key : orphanKeys) { + try { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(properties.bucket()) + .object(key) + .build()); + log.info("고아 파일 삭제 완료: {}", key); + } catch (Exception e) { + log.error("고아 파일 삭제 실패: {}", key, e); + } + } + + log.info("고아 파일 정리 스케줄러 종료"); + } catch (Exception e) { + log.error("고아 파일 정리 스케줄러 작업 중 오류 발생", e); + } + } + + private String extractFileKey(String fileUrl) { + if (fileUrl == null) { + return null; + } + int lastSlashIndex = fileUrl.lastIndexOf('/'); + if (lastSlashIndex >= 0) { + return fileUrl.substring(lastSlashIndex + 1); + } + return fileUrl; + } +} diff --git a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java index 9b57368..ab0b7fb 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -216,6 +216,18 @@ public ResponseEntity handleHandlerMethodValidation( .body(ErrorResponse.of(errorCode, details)); } + // Redis 연결 실패 혹은 쿼리 타임아웃 시 503(REDIS_CONNECTION_FAILURE) 처리 + @ExceptionHandler({ + org.springframework.data.redis.RedisConnectionFailureException.class, + org.springframework.dao.QueryTimeoutException.class + }) + public ResponseEntity handleRedisConnectionFailure(Exception exception) { + log.error("Redis 통신 오류", exception); + com.bop.youthpick.auth.exception.AuthErrorCode errorCode = + com.bop.youthpick.auth.exception.AuthErrorCode.REDIS_CONNECTION_FAILURE; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); + } + // 개발자가 예상치 못한 에러 — application_logs 테이블에 남도록 ERROR로 로깅한다. @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception exception) { diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java index c78ff92..6ed4a1a 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java @@ -3,19 +3,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; /** * 정책 전량 수집을 매일 1회 실행하는 스케줄러. 로컬 기본 off — 팀원이 앱을 켤 때마다 실 API를 호출하는 사고 방지 * (`youthpick.sync.scheduler.enabled`). - * - *

{@code @EnableScheduling}을 이 클래스에 두어 스케줄러가 꺼져 있으면 스케줄링 인프라 자체가 뜨지 않는다. */ @Slf4j @Component -@EnableScheduling @RequiredArgsConstructor @ConditionalOnProperty(name = "youthpick.sync.scheduler.enabled", havingValue = "true") public class PolicySyncScheduler { diff --git a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java index 2df382f..904eeef 100644 --- a/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java @@ -265,6 +265,34 @@ void setUp() { verify(refreshTokenStore).delete(1L); } + @Test + void 로그인_히스토리_저장에_실패해도_로그인은_성공한다() { + when(oAuthStateStore.consume("state-value", "GOOGLE")).thenReturn(true); + when(oAuthClient.exchangeCodeForAccessToken( + eq(OAuthProvider.GOOGLE), + eq("client-id"), + eq("client-secret"), + anyString(), + eq("code"))) + .thenReturn("provider-access-token"); + when(oAuthClient.fetchUserInfo(OAuthProvider.GOOGLE, "provider-access-token")) + .thenReturn(new OAuthUserInfo("GOOGLE", "provider-id-1", "a@a.com", "닉네임")); + User user = User.createSocialUser("GOOGLE", "provider-id-1", "a@a.com", "닉네임"); + when(userRepository.findByProviderAndProviderId("GOOGLE", "provider-id-1")) + .thenReturn(Optional.of(user)); + when(jwtTokenProvider.createAccessToken(any(), any())).thenReturn("jwt-access"); + when(jwtTokenProvider.createRefreshToken(any())).thenReturn("jwt-refresh"); + when(jwtTokenProvider.refreshTokenExpiration()).thenReturn(Duration.ofDays(14)); + when(jwtTokenProvider.accessTokenExpirationSeconds()).thenReturn(1800L); + when(loginHistoryRepository.save(any(LoginHistory.class))) + .thenThrow(new RuntimeException("DB 저장소 장애")); + + TokenResponse tokens = authService.login("google", "code", "state-value"); + + assertThat(tokens.accessToken()).isEqualTo("jwt-access"); + verify(loginHistoryRepository).save(any(LoginHistory.class)); + } + private Map parseQuery(String url) { String query = URI.create(url).getRawQuery(); Map params = new HashMap<>(); diff --git a/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java b/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java index e5ab58d..e194a64 100644 --- a/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java +++ b/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java @@ -80,7 +80,9 @@ void setUp() { await(); assertThatThrownBy(() -> expiredTokenProvider.validateAccessToken(token)) - .isInstanceOf(AuthException.class); + .isInstanceOf(AuthException.class) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(com.bop.youthpick.auth.exception.AuthErrorCode.EXPIRED_TOKEN); } private void await() { From d9a0eb06e42805e8bf40ba12c852f5dfead4f51c Mon Sep 17 00:00:00 2001 From: myh7754 Date: Thu, 23 Jul 2026 14:41:17 +0900 Subject: [PATCH 81/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=EC=97=90=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=EB=AA=85=20=EB=B6=80=EB=B6=84=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 정책 키워드 검색에 지역명 부분일치 포함 검색창에 "서울"처럼 지역명을 입력해도 title/keywords/description 등 텍스트 컬럼에 그 문자열이 없으면 결과에서 빠졌다. keyword 조건에 PolicyRegion.sidoName LIKE 매칭을 추가해 지역명 검색도 함께 걸리게 한다. Closes #199 Co-Authored-By: Claude Sonnet 5 * fix: 키워드 지역명 매칭 시에도 전국 정책을 뒤로 미는 정렬 적용 기존 정렬은 sidoName(명시적 지역 필터) 파라미터일 때만 전국 정책을 뒤로 밀었다. 이번에 keyword로 지역명 매칭을 추가하면서, 전국 정책은 모든 시도에 지역 행이 걸려 있어 keyword="서울" 같은 검색에도 항상 걸리는데 이 demote 규칙이 빠져 있어 지역 특화 정책보다 먼저 뜨는지 여부가 순전히 최신순(id desc) 우연에 달려 있었다. 같은 demote 규칙을 keyword 지역 매칭 케이스에도 적용한다. Refs #199 --------- Co-authored-by: Claude Sonnet 5 --- .../policy/repository/PolicyRepository.java | 21 +++++--- .../repository/PolicyRepositoryTest.java | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java index a88c731..9291321 100644 --- a/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -38,12 +38,15 @@ List findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( * businessPeriodEnd(사업기간 종료일)가 있고 이미 지났다면 제외한다 — aplyPrdSeCd가 진짜 상시(0057002)가 아닌데도 * aplyYmd(신청기간)가 비어 applicationEndDate만 null인 정책(예: 0057003 지역 단발성 모집)이 이미 끝났음에도 "상시"로 계속 노출되는 * 문제를 막는다(#123). category는 표준 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE - * 부분일치(escape '!'). sidoName은 시도명 EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 - * 포함된다. age는 요청 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. jobCode는 - * 온통청년 취업상태 코드 하나로, 해당 코드를 가진 정책과 '제한없음'({@link Policy#JOB_CODE_UNRESTRICTED}) 정책을 함께 통과시킨다. + * 부분일치(escape '!')에 더해 정책 지역(시도명) 부분일치도 포함한다(#199) — 검색창에 "서울"처럼 지역명을 입력해도 그 지역 정책이 결과에 잡히게 하기 + * 위함이다. sidoName은 시도명 EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 포함된다. age는 + * 요청 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. jobCode는 온통청년 취업상태 코드 + * 하나로, 해당 코드를 가진 정책과 '제한없음'({@link Policy#JOB_CODE_UNRESTRICTED}) 정책을 함께 통과시킨다. * *

정렬은 Pageable이 아니라 이 쿼리가 고정한다. "조건 없는 정책을 뒤로"는 필터가 걸렸을 때만 적용해야 하는데, Spring Data {@code - * Sort}로는 파라미터에 따라 달라지는 순서를 표현할 수 없기 때문이다. 호출자는 정렬 없는 Pageable을 넘긴다. + * Sort}로는 파라미터에 따라 달라지는 순서를 표현할 수 없기 때문이다. 호출자는 정렬 없는 Pageable을 넘긴다. 전국 정책 후순위 규칙은 sidoName 필터뿐 + * 아니라 keyword가 지역명과 매칭된 경우에도 적용한다(#199 후속) — 전국 정책은 모든 시도에 지역 행이 걸려 있어 keyword="서울" 같은 검색에도 항상 + * 걸리는데, 이 규칙이 없으면 지역 특화 정책보다 먼저 뜨는지 여부가 순전히 최신순 우연에 달리게 된다. */ @Query( "select p from Policy p where p.visibility = :visibility and p.adminHidden = false and p.deletedAt is null" @@ -56,7 +59,9 @@ List findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + " or p.keywords like :keyword escape '!'" + " or p.description like :keyword escape '!'" + " or p.supportContent like :keyword escape '!'" - + " or p.organizationName like :keyword escape '!')" + + " or p.organizationName like :keyword escape '!'" + + " or exists (select pr2 from PolicyRegion pr2" + + " where pr2.policy = p and pr2.region.sidoName like :keyword escape '!'))" + " and (:sidoName is null or exists (" + " select pr from PolicyRegion pr" + " where pr.policy = p and pr.region.sidoName = :sidoName))" @@ -68,7 +73,11 @@ List findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + Policy.JOB_CODE_UNRESTRICTED + ",%')" + " order by" - + " case when :sidoName is not null and p.nationwide = true then 1 else 0 end," + + " case when p.nationwide = true and (" + + " :sidoName is not null" + + " or (:keyword is not null and exists (select pr3 from PolicyRegion pr3" + + " where pr3.policy = p and pr3.region.sidoName like :keyword escape '!')))" + + " then 1 else 0 end," + " case when :jobCode is not null and (p.jobCodes is null" + " or concat(',', p.jobCodes, ',') like '%," + Policy.JOB_CODE_UNRESTRICTED diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java index 4a1a75e..58bbbc3 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -254,6 +254,55 @@ class PolicyRepositoryTest { .containsExactly(normal.getPolicyNo(), nationwide.getPolicyNo()); } + @Test + void 키워드가_지역명과_부분일치하면_제목에_없어도_그_지역_정책이_검색된다() { + Region seoul = regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + Region busan = regionRepository.save(Region.create("26000", "부산광역시", "부산광역시")); + Policy seoulPolicy = saveLinked("P-SEOUL", false, seoul); + saveLinked("P-BUSAN", false, busan); + + Page page = + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + null, + "%서울%", + null, + null, + null, + null, + PageRequest.of(0, 20)); + + assertThat(page.getContent()) + .extracting(Policy::getPolicyNo) + .containsExactly(seoulPolicy.getPolicyNo()); + } + + @Test + void 키워드가_지역명과_매칭되면_전국_정책을_뒤로_밀어_그_지역_전용_정책이_먼저_나온다() { + Region seoul = regionRepository.save(Region.create("11000", "서울특별시", "서울특별시")); + // 전국 정책을 지역전용 정책보다 나중에(더 높은 id로) 저장해, id desc만 적용되면 전국 정책이 + // 앞에 오게 만든다 — sidoName 필터와 달리 keyword 매칭에서는 이 demote가 빠져있었다(#199 후속). + Policy seoulOnly = saveLinked("P-SEOUL-ONLY", false, seoul); + Policy nationwide = saveLinked("P-ALL-LATER", true, seoul); + + Page page = + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + null, + "%서울%", + null, + null, + null, + null, + PageRequest.of(0, 20)); + + assertThat(page.getContent()) + .extracting(Policy::getPolicyNo) + .containsExactly(seoulOnly.getPolicyNo(), nationwide.getPolicyNo()); + } + private Policy saveLinked(String policyNo, boolean nationwide, Region... regions) { Policy policy = BeanUtils.instantiateClass(Policy.class); ReflectionTestUtils.setField(policy, "policyNo", policyNo); From 83ebe70b724591193afa4a9f27b95724b0321cb3 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Thu, 23 Jul 2026 14:43:38 +0900 Subject: [PATCH 82/93] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EC=9A=94=EC=95=BD=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 게시글 목록 응답에 본문 요약 필드 추가 (#202) Closes #201 --- .../youthpick/post/dto/PostSummaryResponse.java | 15 +++++++++++++++ .../post/controller/PostControllerTest.java | 4 ++-- .../youthpick/post/service/PostServiceTest.java | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java b/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java index 1af38c6..4d457c5 100644 --- a/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java +++ b/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java @@ -11,9 +11,14 @@ public record PostSummaryResponse( String policyTitle, String category, String title, + String contentExcerpt, int viewCount, LocalDateTime createdAt) { + // 목록 카드 미리보기용으로 본문을 잘라서 내려준다. 태그가 잘려도 프론트는 텍스트만 + // 추출해 보여주므로(dangerouslySetInnerHTML로 렌더하지 않음) 문제되지 않는다. + private static final int CONTENT_EXCERPT_MAX_LENGTH = 300; + public static PostSummaryResponse from(Post post) { Long policyId = post.getPolicy() == null ? null : post.getPolicy().getId(); String policyTitle = post.getPolicy() == null ? null : post.getPolicy().getTitle(); @@ -25,7 +30,17 @@ public static PostSummaryResponse from(Post post) { policyTitle, post.getCategory().name(), post.getTitle(), + truncate(post.getContent()), post.getViewCount(), post.getCreatedAt()); } + + private static String truncate(String content) { + if (content == null) { + return ""; + } + return content.length() > CONTENT_EXCERPT_MAX_LENGTH + ? content.substring(0, CONTENT_EXCERPT_MAX_LENGTH) + : content; + } } diff --git a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java index d992c98..343d46d 100644 --- a/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -124,7 +124,7 @@ void setUp() { @Test void 게시글_목록과_페이지_정보를_조회한다() throws Exception { PostSummaryResponse summary = - new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "제목", 0, null); + new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "제목", "내용", 0, null); when(postService.findAll(eq(null), eq(null), any())) .thenReturn(new PageImpl<>(List.of(summary))); @@ -137,7 +137,7 @@ void setUp() { @Test void 카테고리와_검색어_파라미터를_그대로_서비스에_전달한다() throws Exception { PostSummaryResponse summary = - new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "잡담글", 0, null); + new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "잡담글", "내용", 0, null); when(postService.findAll(eq("FREE"), eq("잡담"), any())) .thenReturn(new PageImpl<>(List.of(summary))); diff --git a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java index 6f246eb..03c69ff 100644 --- a/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -147,6 +147,7 @@ void setUp() { assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().getFirst().title()).isEqualTo("제목"); + assertThat(result.getContent().getFirst().contentExcerpt()).isEqualTo("내용"); } @Test From ec6b7acd50c9cb8c7eea8916ac110d1c281b9589 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Thu, 23 Jul 2026 16:18:13 +0900 Subject: [PATCH 83/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EC=8B=A4=ED=8C=A8=EC=9C=A8=20=EC=B4=88=EA=B3=BC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EC=9D=98=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=20=EC=9D=B4=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=ED=94=84=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doRunFullSync()의 실패율 초과 메시지에서 "10%"의 %가 이스케이프 안 돼 있어 String.formatted()가 UnknownFormatConversionException을 던졌다. 정확히 실패율이 10%를 넘는 순간에만 터지는 버그라, policy_batch_history에 남아야 할 "실패율 10% 초과 — 에러 건수: X/Y" 메시지가 의미 없는 "Conversion = ' '"로 덮여 실제 원인 파악이 안 됐다. "10%%"로 이스케이프. 회귀 테스트: title 컬럼 길이(300) 초과로 일부 저장이 실패해 실패율이 10%를 넘는 시나리오에서, 수정 전에는 동일한 UnknownFormatConversionException으로 실패하고 수정 후에는 FAILED 이력에 의도한 메시지가 정상 기록됨을 확인. Closes #203 Co-authored-by: Claude Sonnet 5 --- .../sync/service/PolicySyncService.java | 2 +- .../sync/service/PolicySyncServiceTest.java | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java index 8ff9347..9566cfa 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java @@ -103,7 +103,7 @@ private PolicyBatchHistory doRunFullSync() { if (errorRate > 0.1) { String errorMsg = - "정책 수집 실패율 10% 초과 — 에러 건수: %d/%d (%.2f%%)" + "정책 수집 실패율 10%% 초과 — 에러 건수: %d/%d (%.2f%%)" .formatted( writeResult.errorCount(), totalProcessed, errorRate * 100); history.fail(errorMsg); diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java index 1b3a901..b1a16c8 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java @@ -211,6 +211,26 @@ void cleanUp() { assertThat(back.getMissingCount()).isZero(); } + @Test + void 실패율이_10퍼센트를_넘으면_크래시_없이_실패_사유를_기록한다() throws IOException { + // title(길이 300) 초과로 DB 저장이 실패하는 건을 섞어 실패율 10%를 넘긴다(#203) — + // 실패 메시지 포맷 문자열의 "10%"가 이스케이프 안 돼 있으면 여기서 UnknownFormatConversionException이 터진다. + String tooLongTitle = "가".repeat(301); + List items = new java.util.ArrayList<>(); + for (int i = 0; i < 8; i++) { + items.add(item("{\"plcyNo\":\"P-OK-" + i + "\",\"plcyNm\":\"정상 정책 " + i + "\"}")); + } + for (int i = 0; i < 2; i++) { + items.add(item("{\"plcyNo\":\"P-BAD-" + i + "\",\"plcyNm\":\"" + tooLongTitle + "\"}")); + } + when(policyApiClient.fetchAll()).thenReturn(items); + + PolicyBatchHistory result = policySyncService.runFullSync(); + + assertThat(result.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(result.getFailureMessage()).contains("정책 수집 실패율 10% 초과").contains("2/10"); + } + @Test void 수동_실행은_락_거부시_409용_CustomException을_던지고_아무_작업도_하지_않는다() { when(policySyncLock.tryAcquire()).thenReturn(Optional.empty()); From d9c883ee7f8c34b8aa2b06d66bc0baf5687d4eef Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 14:20:29 +0900 Subject: [PATCH 84/93] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20CRUD=20API=20=EC=B6=94=EA=B0=80=20(#212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 댓글 생성/목록조회/수정/삭제, 대댓글(1단) 지원 - CommentErrorCode를 B006~B008로 재배번(BoardErrorCode.COMMENT_NOT_FOUND의 B005와 충돌 방지) - 정상/예외/경계 케이스 테스트 추가 Co-authored-by: Claude Sonnet 5 --- .../dto/AdminCommunityCommentResponse.java | 2 +- .../board/service/AdminCommunityService.java | 4 +- .../comment/controller/CommentController.java | 63 +++++ .../comment/dto/CommentCreateRequest.java | 9 + .../comment/dto/CommentResponse.java | 25 ++ .../comment/dto/CommentUpdateRequest.java | 8 + .../{post => comment}/entity/Comment.java | 16 +- .../comment/exception/CommentErrorCode.java | 18 ++ .../comment/exception/CommentException.java | 10 + .../comment/repository/CommentRepository.java | 25 ++ .../comment/service/CommentService.java | 149 ++++++++++++ .../post/repository/CommentRepository.java | 12 - .../service/AdminCommunityServiceTest.java | 8 +- .../controller/CommentControllerTest.java | 151 ++++++++++++ .../comment/service/CommentServiceTest.java | 216 ++++++++++++++++++ 15 files changed, 695 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/bop/youthpick/comment/controller/CommentController.java create mode 100644 src/main/java/com/bop/youthpick/comment/dto/CommentCreateRequest.java create mode 100644 src/main/java/com/bop/youthpick/comment/dto/CommentResponse.java create mode 100644 src/main/java/com/bop/youthpick/comment/dto/CommentUpdateRequest.java rename src/main/java/com/bop/youthpick/{post => comment}/entity/Comment.java (76%) create mode 100644 src/main/java/com/bop/youthpick/comment/exception/CommentErrorCode.java create mode 100644 src/main/java/com/bop/youthpick/comment/exception/CommentException.java create mode 100644 src/main/java/com/bop/youthpick/comment/repository/CommentRepository.java create mode 100644 src/main/java/com/bop/youthpick/comment/service/CommentService.java delete mode 100644 src/main/java/com/bop/youthpick/post/repository/CommentRepository.java create mode 100644 src/test/java/com/bop/youthpick/comment/controller/CommentControllerTest.java create mode 100644 src/test/java/com/bop/youthpick/comment/service/CommentServiceTest.java diff --git a/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java index 9981fbb..ac83ba4 100644 --- a/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java @@ -1,6 +1,6 @@ package com.bop.youthpick.admin.board.dto; -import com.bop.youthpick.post.entity.Comment; +import com.bop.youthpick.comment.entity.Comment; import java.time.LocalDateTime; public record AdminCommunityCommentResponse( diff --git a/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java index 6667e0f..51ac881 100644 --- a/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java +++ b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java @@ -4,12 +4,12 @@ import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; import com.bop.youthpick.admin.board.dto.AdminCommunityPostResponse; import com.bop.youthpick.admin.board.repository.AdminCommunityPostSpecifications; -import com.bop.youthpick.post.entity.Comment; +import com.bop.youthpick.comment.entity.Comment; +import com.bop.youthpick.comment.repository.CommentRepository; import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.post.exception.BoardErrorCode; import com.bop.youthpick.post.exception.BoardException; import com.bop.youthpick.post.repository.AttachmentRepository; -import com.bop.youthpick.post.repository.CommentRepository; import com.bop.youthpick.post.repository.PostRepository; import jakarta.persistence.criteria.JoinType; import java.time.LocalDate; diff --git a/src/main/java/com/bop/youthpick/comment/controller/CommentController.java b/src/main/java/com/bop/youthpick/comment/controller/CommentController.java new file mode 100644 index 0000000..cce7fb2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/controller/CommentController.java @@ -0,0 +1,63 @@ +package com.bop.youthpick.comment.controller; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.comment.dto.CommentCreateRequest; +import com.bop.youthpick.comment.dto.CommentResponse; +import com.bop.youthpick.comment.dto.CommentUpdateRequest; +import com.bop.youthpick.comment.service.CommentService; +import com.bop.youthpick.global.common.ApiResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/posts/{postId}/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @PostMapping + public ResponseEntity> create( + @CurrentUser Long userId, + @PathVariable Long postId, + @Valid @RequestBody CommentCreateRequest request) { + CommentResponse res = commentService.create(userId, postId, request); + + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(res)); + } + + @GetMapping + public ApiResponse> findAll( + @PathVariable Long postId, @CurrentUser(required = false) Long userId) { + return commentService.getAllByPostId(postId); + } + + @PatchMapping("/{commentId}") + public ApiResponse update( + @CurrentUser Long userId, + @PathVariable Long postId, + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request) { + return ApiResponse.ok(commentService.update(userId, commentId, request)); + } + + @DeleteMapping("/{commentId}") + public ApiResponse delete( + // [추가] @CurrentUser: JWT 토큰에서 로그인한 사용자의 id를 꺼내 주입해 준다. + // 작성자 본인만 삭제할 수 있는지 Service에서 검사해야 하므로 함께 넘긴다. + @CurrentUser Long userId, @PathVariable Long postId, @PathVariable Long commentId) { + commentService.delete(userId, commentId); + return ApiResponse.ok(null); + } +} diff --git a/src/main/java/com/bop/youthpick/comment/dto/CommentCreateRequest.java b/src/main/java/com/bop/youthpick/comment/dto/CommentCreateRequest.java new file mode 100644 index 0000000..ca16ec1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/dto/CommentCreateRequest.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentCreateRequest( + @NotBlank(message = "댓글 내용은 필수입니다.") @Size(max = 400, message = "댓글은 400자 이하여야 합니다.") + String content, + Long parentId) {} diff --git a/src/main/java/com/bop/youthpick/comment/dto/CommentResponse.java b/src/main/java/com/bop/youthpick/comment/dto/CommentResponse.java new file mode 100644 index 0000000..ac8254b --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/dto/CommentResponse.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.comment.dto; + +import com.bop.youthpick.comment.entity.Comment; +import java.time.LocalDateTime; + +public record CommentResponse( + Long id, + Long parentId, + Long authorId, + String authorNickname, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + + public static CommentResponse from(Comment comment) { + return new CommentResponse( + comment.getId(), + comment.getParent() != null ? comment.getParent().getId() : null, + comment.getUser().getId(), + comment.getUser().getNickname(), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/comment/dto/CommentUpdateRequest.java b/src/main/java/com/bop/youthpick/comment/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..498da33 --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/dto/CommentUpdateRequest.java @@ -0,0 +1,8 @@ +package com.bop.youthpick.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + @NotBlank(message = "댓글 내용은 필수입니다.") @Size(max = 400, message = "댓글은 400자 이하여야 합니다.") + String content) {} diff --git a/src/main/java/com/bop/youthpick/post/entity/Comment.java b/src/main/java/com/bop/youthpick/comment/entity/Comment.java similarity index 76% rename from src/main/java/com/bop/youthpick/post/entity/Comment.java rename to src/main/java/com/bop/youthpick/comment/entity/Comment.java index 013fe06..6e746a1 100644 --- a/src/main/java/com/bop/youthpick/post/entity/Comment.java +++ b/src/main/java/com/bop/youthpick/comment/entity/Comment.java @@ -1,6 +1,7 @@ -package com.bop.youthpick.post.entity; +package com.bop.youthpick.comment.entity; import com.bop.youthpick.global.entity.BaseEntity; +import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -46,6 +47,19 @@ public class Comment extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + public static Comment create(Post post, User user, Comment parent, String content) { + Comment comment = new Comment(); + comment.post = post; + comment.user = user; + comment.parent = parent; + comment.content = content; + return comment; + } + + public void updateContent(String content) { + this.content = content; + } + public void softDelete() { this.deletedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/bop/youthpick/comment/exception/CommentErrorCode.java b/src/main/java/com/bop/youthpick/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..5689c2e --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/exception/CommentErrorCode.java @@ -0,0 +1,18 @@ +package com.bop.youthpick.comment.exception; + +import com.bop.youthpick.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CommentErrorCode implements ErrorCode { + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B006", "댓글을 찾을 수 없습니다."), + COMMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "B007", "작성자만 댓글을 수정하거나 삭제할 수 있습니다."), + REPLY_DEPTH_EXCEEDED(HttpStatus.BAD_REQUEST, "B008", "대댓글에는 답글을 남길 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/bop/youthpick/comment/exception/CommentException.java b/src/main/java/com/bop/youthpick/comment/exception/CommentException.java new file mode 100644 index 0000000..7aca263 --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/exception/CommentException.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.comment.exception; + +import com.bop.youthpick.global.error.CustomException; + +public class CommentException extends CustomException { + + public CommentException(CommentErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/bop/youthpick/comment/repository/CommentRepository.java b/src/main/java/com/bop/youthpick/comment/repository/CommentRepository.java new file mode 100644 index 0000000..0b0a694 --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/repository/CommentRepository.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.comment.repository; + +import com.bop.youthpick.comment.entity.Comment; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"user", "parent"}) + List findByPostId(Long postId); + + /** + * 삭제되지 않은 댓글만 조회한다. + * + *

이 프로젝트는 댓글을 DB에서 진짜로 지우지 않고 deleted_at 컬럼에 삭제 시각만 기록한다(soft delete). 그래서 위의 + * findByPostId()는 삭제된 댓글까지 전부 가져온다. 관리자 화면은 삭제된 댓글도 봐야 하므로 그 메서드는 그대로 두고, 일반 사용자용 조회는 이 메서드를 + * 쓴다. + * + *

메서드 이름만 지어주면 Spring Data JPA가 SQL을 자동으로 만들어 준다. AndDeletedAtIsNull = "그리고 deleted_at 이 + * NULL인 것만" + */ + @EntityGraph(attributePaths = {"user", "parent"}) + List findByPostIdAndDeletedAtIsNull(Long postId); +} diff --git a/src/main/java/com/bop/youthpick/comment/service/CommentService.java b/src/main/java/com/bop/youthpick/comment/service/CommentService.java new file mode 100644 index 0000000..e0d8e87 --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/service/CommentService.java @@ -0,0 +1,149 @@ +package com.bop.youthpick.comment.service; + +import com.bop.youthpick.comment.dto.CommentCreateRequest; +import com.bop.youthpick.comment.dto.CommentResponse; +import com.bop.youthpick.comment.dto.CommentUpdateRequest; +import com.bop.youthpick.comment.entity.Comment; +import com.bop.youthpick.comment.exception.CommentErrorCode; +import com.bop.youthpick.comment.exception.CommentException; +import com.bop.youthpick.comment.repository.CommentRepository; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.PostRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.exception.UserError; +import com.bop.youthpick.user.exception.UserException; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + + @Transactional + public CommentResponse create(Long uid, Long pid, CommentCreateRequest req) { + Post post = + postRepository + .findById(pid) + .orElseThrow(() -> new BoardException(BoardErrorCode.POST_NOT_FOUND)); + + User user = + userRepository + .findById(uid) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + + Comment parent = resolveParent(req.parentId(), pid); + + Comment comment = Comment.create(post, user, parent, req.content()); + + commentRepository.save(comment); + + return CommentResponse.from(comment); + } + + // parentId가 있으면 대댓글이다. 이 프로젝트의 댓글은 1단까지만 허용한다(Comment.java 클래스 주석 참고) — + // 부모 댓글이 이미 다른 댓글의 대댓글이면 답글의 답글이 되므로 막는다. + private Comment resolveParent(Long parentId, Long postId) { + if (parentId == null) { + return null; + } + + Comment parent = + commentRepository + .findById(parentId) + .orElseThrow( + () -> new CommentException(CommentErrorCode.COMMENT_NOT_FOUND)); + + if (!parent.getPost().getId().equals(postId)) { + throw new CommentException(CommentErrorCode.COMMENT_NOT_FOUND); + } + + if (parent.getParent() != null) { + throw new CommentException(CommentErrorCode.REPLY_DEPTH_EXCEEDED); + } + + return parent; + } + + /** 댓글을 수정하는 메서드 */ + @Transactional + public CommentResponse update(Long uid, Long commentId, CommentUpdateRequest req) { + Comment comment = + commentRepository + .findById(commentId) + .orElseThrow( + () -> new CommentException(CommentErrorCode.COMMENT_NOT_FOUND)); + + if (!comment.getUser().getId().equals(uid)) { + throw new CommentException(CommentErrorCode.COMMENT_ACCESS_DENIED); + } + + comment.updateContent(req.content()); + + return CommentResponse.from(comment); + } + + /** 댓글을 삭제하는 메서드 */ + @Transactional + public void delete(Long uid, Long commentId) { + // TODO: 1. id를 통해 댓글을 조회한다. -Post id 가 아니라 comment id + Comment comment = + commentRepository + .findById(commentId) + + // TODO: 2. 댓글이 존재하지 않으면 404 에러를 낸다. + .orElseThrow( + () -> new CommentException(CommentErrorCode.COMMENT_NOT_FOUND)); + + // [추가] 3. 작성자 본인인지 확인한다. + // 이 검사가 없으면 로그인만 한 사람은 누구나 남의 댓글을 지울 수 있다. + // uid 는 JWT 토큰에서 꺼낸 "지금 요청한 사람"의 id 이고, + // comment.getUser().getId() 는 "이 댓글을 쓴 사람"의 id 다. 둘이 같아야 삭제를 허용한다. + // + // Long 은 int 같은 기본 타입이 아니라 객체라서 == 로 비교하면 + // 값이 같아도 다르다고 나올 수 있다. 그래서 반드시 .equals() 를 쓴다. + if (!comment.getUser().getId().equals(uid)) { + throw new CommentException(CommentErrorCode.COMMENT_ACCESS_DENIED); + } + + comment.softDelete(); + } + + /** 해당 포스트에 대한 댓글 전체 조회 메서드 */ + @Transactional(readOnly = true) + public ApiResponse> getAllByPostId(Long pId) { + postRepository + .findById(pId) + .orElseThrow(() -> new BoardException(BoardErrorCode.POST_NOT_FOUND)); + + // TODO: 3. 해당 포스트에 존재하는 댓글들을 List로 조회한다. + // [수정] findByPostId -> findByPostIdAndDeletedAtIsNull + // 삭제된 댓글은 deleted_at 에 시각만 기록되고 DB에 그대로 남아 있어서, + // findByPostId 를 쓰면 이미 지운 댓글까지 목록에 나온다. 삭제 안 된 것만 가져오도록 바꿨다. + List comment = commentRepository.findByPostIdAndDeletedAtIsNull(pId); + + // TODO: 4. 조회한 결과들을 CommentResponse 타입으로 바꾼다. + List convertResult = + comment.stream() + .map( + oneDbLine -> { + CommentResponse commentResponse = + CommentResponse.from(oneDbLine); + return commentResponse; + }) + .toList(); + // comment.stream().map(CommentResponse::from) + + // TODO: 5. 결과를 반환한다. + return ApiResponse.ok(convertResult); + } +} diff --git a/src/main/java/com/bop/youthpick/post/repository/CommentRepository.java b/src/main/java/com/bop/youthpick/post/repository/CommentRepository.java deleted file mode 100644 index e5dc432..0000000 --- a/src/main/java/com/bop/youthpick/post/repository/CommentRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.bop.youthpick.post.repository; - -import com.bop.youthpick.post.entity.Comment; -import java.util.List; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CommentRepository extends JpaRepository { - - @EntityGraph(attributePaths = {"user", "parent"}) - List findByPostId(Long postId); -} diff --git a/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java index a006a36..72d6750 100644 --- a/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java +++ b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java @@ -3,16 +3,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; -import com.bop.youthpick.post.entity.Comment; +import com.bop.youthpick.comment.entity.Comment; +import com.bop.youthpick.comment.repository.CommentRepository; import com.bop.youthpick.post.entity.Post; import com.bop.youthpick.post.exception.BoardErrorCode; import com.bop.youthpick.post.exception.BoardException; import com.bop.youthpick.post.repository.AttachmentRepository; -import com.bop.youthpick.post.repository.CommentRepository; import com.bop.youthpick.post.repository.PostRepository; import com.bop.youthpick.user.entity.User; import java.util.List; diff --git a/src/test/java/com/bop/youthpick/comment/controller/CommentControllerTest.java b/src/test/java/com/bop/youthpick/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..d756155 --- /dev/null +++ b/src/test/java/com/bop/youthpick/comment/controller/CommentControllerTest.java @@ -0,0 +1,151 @@ +package com.bop.youthpick.comment.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bop.youthpick.auth.service.CurrentUser; +import com.bop.youthpick.comment.dto.CommentResponse; +import com.bop.youthpick.comment.service.CommentService; +import com.bop.youthpick.global.common.ApiResponse; +import com.bop.youthpick.global.error.GlobalExceptionHandler; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@ExtendWith(MockitoExtension.class) +class CommentControllerTest { + + @Mock private CommentService commentService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + CommentController controller = new CommentController(commentService); + mockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new TestCurrentUserArgumentResolver()) + .build(); + } + + @Test + void 댓글을_생성하면_201을_반환한다() throws Exception { + when(commentService.create(eq(1L), eq(3L), any())).thenReturn(response(10L, null, "내용")); + + mockMvc.perform( + post("/api/v1/posts/{postId}/comments", 3L) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"content": "내용"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id").value(10)) + .andExpect(jsonPath("$.data.content").value("내용")); + } + + @Test + void 내용이_비어있으면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + post("/api/v1/posts/{postId}/comments", 3L) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"content": ""} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 게시글의_댓글_목록을_조회한다() throws Exception { + when(commentService.getAllByPostId(3L)) + .thenReturn(ApiResponse.ok(List.of(response(10L, null, "내용")))); + + mockMvc.perform(get("/api/v1/posts/{postId}/comments", 3L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(10)); + } + + @Test + void 댓글을_수정하면_200을_반환한다() throws Exception { + when(commentService.update(eq(1L), eq(10L), any())) + .thenReturn(response(10L, null, "수정된 내용")); + + mockMvc.perform( + patch("/api/v1/posts/{postId}/comments/{commentId}", 3L, 10L) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"content": "수정된 내용"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").value("수정된 내용")); + } + + @Test + void 수정_내용이_비어있으면_400과_C001을_반환한다() throws Exception { + mockMvc.perform( + patch("/api/v1/posts/{postId}/comments/{commentId}", 3L, 10L) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"content": ""} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001")); + } + + @Test + void 댓글을_삭제한다() throws Exception { + doNothing().when(commentService).delete(1L, 10L); + + mockMvc.perform(delete("/api/v1/posts/{postId}/comments/{commentId}", 3L, 10L)) + .andExpect(status().isOk()); + + verify(commentService).delete(1L, 10L); + } + + private CommentResponse response(Long id, Long parentId, String content) { + return new CommentResponse( + id, parentId, 1L, "작성자", content, LocalDateTime.now(), LocalDateTime.now()); + } + + private static class TestCurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + return 1L; + } + } +} diff --git a/src/test/java/com/bop/youthpick/comment/service/CommentServiceTest.java b/src/test/java/com/bop/youthpick/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..f56c581 --- /dev/null +++ b/src/test/java/com/bop/youthpick/comment/service/CommentServiceTest.java @@ -0,0 +1,216 @@ +package com.bop.youthpick.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bop.youthpick.comment.dto.CommentCreateRequest; +import com.bop.youthpick.comment.dto.CommentResponse; +import com.bop.youthpick.comment.dto.CommentUpdateRequest; +import com.bop.youthpick.comment.entity.Comment; +import com.bop.youthpick.comment.exception.CommentErrorCode; +import com.bop.youthpick.comment.exception.CommentException; +import com.bop.youthpick.comment.repository.CommentRepository; +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.exception.BoardErrorCode; +import com.bop.youthpick.post.exception.BoardException; +import com.bop.youthpick.post.repository.PostRepository; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.repository.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @Mock private CommentRepository commentRepository; + @Mock private PostRepository postRepository; + @Mock private UserRepository userRepository; + + private CommentService commentService; + + @BeforeEach + void setUp() { + commentService = new CommentService(commentRepository, postRepository, userRepository); + } + + @Test + void 최상위_댓글을_생성한다() { + Post post = post(1L); + User user = user(1L); + CommentCreateRequest request = new CommentCreateRequest("내용", null); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + CommentResponse response = commentService.create(1L, 1L, request); + + assertThat(response.parentId()).isNull(); + assertThat(response.content()).isEqualTo("내용"); + verify(commentRepository, never()).findById(any()); + } + + @Test + void 대댓글을_생성하면_parentId가_설정된다() { + Post post = post(1L); + User user = user(1L); + Comment parent = comment(10L, post, user(2L), null); + CommentCreateRequest request = new CommentCreateRequest("답글", 10L); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(commentRepository.findById(10L)).thenReturn(Optional.of(parent)); + + CommentResponse response = commentService.create(1L, 1L, request); + + assertThat(response.parentId()).isEqualTo(10L); + } + + @Test + void 존재하지_않는_게시글에_댓글을_달면_POST_NOT_FOUND_예외가_발생한다() { + CommentCreateRequest request = new CommentCreateRequest("내용", null); + when(postRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> commentService.create(1L, 1L, request)) + .isInstanceOf(BoardException.class) + .extracting(exception -> ((BoardException) exception).getErrorCode()) + .isEqualTo(BoardErrorCode.POST_NOT_FOUND); + } + + @Test + void 존재하지_않는_부모_댓글에_대댓글을_달면_COMMENT_NOT_FOUND_예외가_발생한다() { + Post post = post(1L); + User user = user(1L); + CommentCreateRequest request = new CommentCreateRequest("답글", 99L); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(commentRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> commentService.create(1L, 1L, request)) + .isInstanceOf(CommentException.class) + .extracting(exception -> ((CommentException) exception).getErrorCode()) + .isEqualTo(CommentErrorCode.COMMENT_NOT_FOUND); + } + + @Test + void 대댓글에_다시_답글을_달면_REPLY_DEPTH_EXCEEDED_예외가_발생한다() { + Post post = post(1L); + User user = user(1L); + Comment grandParent = comment(5L, post, user(2L), null); + Comment parentReply = comment(10L, post, user(2L), grandParent); + CommentCreateRequest request = new CommentCreateRequest("답글의 답글", 10L); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(commentRepository.findById(10L)).thenReturn(Optional.of(parentReply)); + + assertThatThrownBy(() -> commentService.create(1L, 1L, request)) + .isInstanceOf(CommentException.class) + .extracting(exception -> ((CommentException) exception).getErrorCode()) + .isEqualTo(CommentErrorCode.REPLY_DEPTH_EXCEEDED); + } + + @Test + void 작성자_본인이_댓글을_수정한다() { + Comment comment = comment(3L, post(1L), user(1L), null); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 내용"); + when(commentRepository.findById(3L)).thenReturn(Optional.of(comment)); + + CommentResponse response = commentService.update(1L, 3L, request); + + assertThat(response.content()).isEqualTo("수정된 내용"); + } + + @Test + void 다른_사용자는_댓글을_수정할_수_없다() { + Comment comment = comment(3L, post(1L), user(1L), null); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 내용"); + when(commentRepository.findById(3L)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.update(2L, 3L, request)) + .isInstanceOf(CommentException.class) + .extracting(exception -> ((CommentException) exception).getErrorCode()) + .isEqualTo(CommentErrorCode.COMMENT_ACCESS_DENIED); + } + + @Test + void 존재하지_않는_댓글을_수정하면_COMMENT_NOT_FOUND_예외가_발생한다() { + CommentUpdateRequest request = new CommentUpdateRequest("수정된 내용"); + when(commentRepository.findById(3L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> commentService.update(1L, 3L, request)) + .isInstanceOf(CommentException.class) + .extracting(exception -> ((CommentException) exception).getErrorCode()) + .isEqualTo(CommentErrorCode.COMMENT_NOT_FOUND); + } + + @Test + void 작성자_본인이_댓글을_삭제한다() { + Comment comment = comment(3L, post(1L), user(1L), null); + when(commentRepository.findById(3L)).thenReturn(Optional.of(comment)); + + commentService.delete(1L, 3L); + + assertThat(comment.getDeletedAt()).isNotNull(); + } + + @Test + void 다른_사용자는_댓글을_삭제할_수_없다() { + Comment comment = comment(3L, post(1L), user(1L), null); + when(commentRepository.findById(3L)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.delete(2L, 3L)) + .isInstanceOf(CommentException.class) + .extracting(exception -> ((CommentException) exception).getErrorCode()) + .isEqualTo(CommentErrorCode.COMMENT_ACCESS_DENIED); + } + + @Test + void 게시글의_댓글_목록을_조회한다() { + Post post = post(1L); + Comment comment = comment(3L, post, user(1L), null); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(commentRepository.findByPostIdAndDeletedAtIsNull(1L)).thenReturn(List.of(comment)); + + var response = commentService.getAllByPostId(1L); + + assertThat(response.data()).hasSize(1); + assertThat(response.data().getFirst().content()).isEqualTo("내용"); + } + + @Test + void 존재하지_않는_게시글의_댓글을_조회하면_POST_NOT_FOUND_예외가_발생한다() { + when(postRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> commentService.getAllByPostId(1L)) + .isInstanceOf(BoardException.class) + .extracting(exception -> ((BoardException) exception).getErrorCode()) + .isEqualTo(BoardErrorCode.POST_NOT_FOUND); + } + + private Post post(Long id) { + Post post = mock(Post.class); + lenient().when(post.getId()).thenReturn(id); + return post; + } + + private User user(Long id) { + User user = mock(User.class); + lenient().when(user.getId()).thenReturn(id); + return user; + } + + private Comment comment(Long id, Post post, User user, Comment parent) { + Comment comment = Comment.create(post, user, parent, "내용"); + ReflectionTestUtils.setField(comment, "id", id); + return comment; + } +} From 4c15b62eed44d1f6b62755c2277dd1d6b6723c52 Mon Sep 17 00:00:00 2001 From: myh7754 Date: Fri, 24 Jul 2026 14:20:46 +0900 Subject: [PATCH 85/93] =?UTF-8?q?fix:=20=EC=A0=95=EC=B1=85=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=EC=9D=84=20=EB=A7=A4=20=ED=9A=8C=EC=B0=A8=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EC=8B=9C=ED=82=A4=EB=8D=98=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=83=81=ED=95=9C=20=ED=99=95=EB=8C=80=20?= =?UTF-8?q?(#209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit business_period_etc(bizPrdEtcCn)는 자유 서술 텍스트인데 V1에서 날짜 컬럼 옆에 묶이며 VARCHAR(64)로 잡혀, 64자 넘는 사업기간 설명을 가진 정책 11건이 매 회차 Data truncation으로 저장 실패했다. 같은 성격의 earnEtcCn/addAplyQlfcCndCn/ptcpPrpTrgtCn과 동일하게 TEXT로 맞춘다. 함께 넓히는 컬럼: - reference_url1 등 URL 3종 VARCHAR(500) -> VARCHAR(1000) (원본에 500자 넘는 URL 1건 존재 — 나머지 1건의 실패 원인) - application_period_raw VARCHAR(64) -> VARCHAR(255) (aplyYmd 원문은 신청기간 2개만 돼도 43자라 3개째에서 같은 방식으로 터진다) Closes #207 Co-authored-by: Claude Opus 4.8 (1M context) --- .../bop/youthpick/policy/entity/Policy.java | 10 +++++----- ...V19__widen_policy_text_and_url_columns.sql | 19 +++++++++++++++++++ .../repository/PolicyRepositoryTest.java | 17 +++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V19__widen_policy_text_and_url_columns.sql diff --git a/src/main/java/com/bop/youthpick/policy/entity/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java index bb8cee5..67ea035 100644 --- a/src/main/java/com/bop/youthpick/policy/entity/Policy.java +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -138,7 +138,7 @@ public class Policy extends BaseEntity { private String applicationPeriodType; /** aplyYmd 원문 */ - @Column(name = "application_period_raw", length = 64) + @Column(name = "application_period_raw", length = 255) private String applicationPeriodRaw; /** [파생] 신청 시작일 */ @@ -158,7 +158,7 @@ public class Policy extends BaseEntity { private LocalDate businessPeriodEnd; /** bizPrdEtcCn */ - @Column(name = "business_period_etc", length = 64) + @Column(name = "business_period_etc", columnDefinition = "TEXT") private String businessPeriodEtc; // ---- 규모 ---- @@ -174,15 +174,15 @@ public class Policy extends BaseEntity { // ---- 링크/신청 ---- /** aplyUrlAddr */ - @Column(name = "application_url", length = 500) + @Column(name = "application_url", length = 1000) private String applicationUrl; /** refUrlAddr1 */ - @Column(name = "reference_url1", length = 500) + @Column(name = "reference_url1", length = 1000) private String referenceUrl1; /** refUrlAddr2 */ - @Column(name = "reference_url2", length = 500) + @Column(name = "reference_url2", length = 1000) private String referenceUrl2; /** plcyAplyMthdCn */ diff --git a/src/main/resources/db/migration/V19__widen_policy_text_and_url_columns.sql b/src/main/resources/db/migration/V19__widen_policy_text_and_url_columns.sql new file mode 100644 index 0000000..35c8be5 --- /dev/null +++ b/src/main/resources/db/migration/V19__widen_policy_text_and_url_columns.sql @@ -0,0 +1,19 @@ +-- 온통청년 원본이 컬럼 상한을 넘겨 정책 저장이 매 회차 실패하던 문제(#207). +-- MySQL은 MODIFY 시 COMMENT를 다시 지정하지 않으면 지우므로 원본 주석을 그대로 유지한다. + +-- bizPrdEtcCn은 자유 서술 텍스트인데 V1에서 날짜 컬럼(bizPrdBgngYmd/bizPrdEndYmd) 옆에 묶이면서 +-- 짧은 값으로 가정돼 VARCHAR(64)로 잡혔다. 같은 성격의 earnEtcCn/addAplyQlfcCndCn/ptcpPrpTrgtCn과 +-- 동일하게 TEXT로 맞춘다. +ALTER TABLE policies + MODIFY COLUMN business_period_etc TEXT NULL COMMENT 'bizPrdEtcCn'; + +-- 쿼리스트링이 붙은 원본 URL이 500자를 넘는 사례가 있다. +ALTER TABLE policies + MODIFY COLUMN application_url VARCHAR(1000) NULL COMMENT 'aplyUrlAddr', + MODIFY COLUMN reference_url1 VARCHAR(1000) NULL COMMENT 'refUrlAddr1', + MODIFY COLUMN reference_url2 VARCHAR(1000) NULL COMMENT 'refUrlAddr2'; + +-- aplyYmd 원문은 신청기간이 여러 개면 '20260101 ~ 20261231\N20270101 ~ 20271231'처럼 늘어난다. +-- 2개만 돼도 43자라 기존 64자 상한은 3개째에서 터진다. +ALTER TABLE policies + MODIFY COLUMN application_period_raw VARCHAR(255) NULL COMMENT 'aplyYmd 원문'; diff --git a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java index 58bbbc3..0ac6ea9 100644 --- a/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -303,6 +303,23 @@ class PolicyRepositoryTest { .containsExactly(seoulOnly.getPolicyNo(), nationwide.getPolicyNo()); } + @Test + void 사업기간_기타내용과_URL은_원본이_길어도_잘리지_않고_저장된다() { + // 온통청년 원본에는 64자를 넘는 사업기간 설명과 500자를 넘는 URL이 실제로 있고, + // 예전 상한(각 64/500자)에서는 이 정책들이 매 회차 통째로 저장 실패했다(#207). + String longEtc = "사업 기간 관련 상세 안내 ".repeat(30); + String longUrl = "https://www.example.go.kr/apply?" + "param=value&".repeat(60); + Policy policy = newPolicy("P-LONG", PolicyVisibility.VISIBLE, null); + ReflectionTestUtils.setField(policy, "businessPeriodEtc", longEtc); + ReflectionTestUtils.setField(policy, "referenceUrl1", longUrl); + + policyRepository.saveAndFlush(policy); + + Policy saved = policyRepository.findByPolicyNoIn(List.of("P-LONG")).getFirst(); + assertThat(saved.getBusinessPeriodEtc()).isEqualTo(longEtc); + assertThat(saved.getReferenceUrl1()).isEqualTo(longUrl); + } + private Policy saveLinked(String policyNo, boolean nationwide, Region... regions) { Policy policy = BeanUtils.instantiateClass(Policy.class); ReflectionTestUtils.setField(policy, "policyNo", policyNo); From 6a948b4a5d1feb2b1e8987827f5b4e27104c46a7 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 14:21:12 +0900 Subject: [PATCH 86/93] =?UTF-8?q?chore:=20docker=20compose=EB=A1=9C=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8+=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EA=B8=B0=EB=8F=99=20=EB=B0=8F=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20CORS=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=20(#211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #210 Co-authored-by: Claude Sonnet 5 --- .claude/rules/infra.md | 14 ++++++++++-- .dockerignore | 10 +++++++++ .env.example | 9 ++++++++ AGENTS.md | 2 +- Dockerfile | 19 ++++++++++++++++ docker-compose.yml | 50 ++++++++++++++++++++++++++++++++++++------ 6 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.claude/rules/infra.md b/.claude/rules/infra.md index d017e49..6885d39 100644 --- a/.claude/rules/infra.md +++ b/.claude/rules/infra.md @@ -8,11 +8,14 @@ paths: # 실행 / Docker / API 수동 검증 -- **앱(백엔드)은 컨테이너화하지 않는다.** 로컬 IDE / Gradle로 실행하고, `docker-compose.yml`은 로컬 인프라(MySQL, Redis)만 띄운다. +- 평소 개발은 로컬 IDE / Gradle로 앱을 실행하고, `docker-compose.yml`의 인프라(MySQL, Redis, MinIO)만 띄우는 쪽을 권장한다. +- `docker-compose.yml`에는 앱 컨테이너(`backend`, `frontend`)도 정의되어 있어 `--build`로 전체를 한 번에 띄울 수도 있다(빠른 로컬 통합 확인, 프론트-백엔드 동시 데모 등). `backend`용 [`Dockerfile`](../../Dockerfile)은 `./gradlew bootJar -x test`로 빌드하고, `frontend`는 `../front-end/Dockerfile`(nginx)을 사용한다. 컨테이너 안에서는 `localhost` 대신 서비스명(`mysql`/`redis`/`minio`)으로 접속하도록 `docker-compose.yml`의 `backend.environment`가 `.env`의 `DB_URL`/`REDIS_HOST`/`MINIO_ENDPOINT` 값을 오버라이드한다. ```bash -docker compose up -d # MySQL + Redis 기동 (앱 실행 전 필수) +docker compose up -d mysql redis minio # 인프라만 기동 (앱은 IDE/Gradle로 실행) +docker compose up -d --build # 인프라 + 백엔드 + 프론트 전체 기동 docker compose ps +docker compose logs -f backend docker compose down # 중지 (볼륨 보존) docker compose down -v # 중지 + 볼륨 삭제(초기화) ``` @@ -21,6 +24,13 @@ docker compose down -v # 중지 + 볼륨 삭제(초기화) - 테스트는 H2 in-memory(MySQL 모드, `create-drop`)로 Docker 없이 돈다. 테스트 설정은 `src/test/resources/application.yml`이 main 설정을 가리는(shadowing) 구조이므로 main 설정 파일명을 바꾸면 테스트 쪽도 같이 맞춘다. - Compose/인프라 변경 시 가능한 범위로 `docker compose config`와 기동 smoke를 확인한다. Docker가 없으면 PR 검증 결과에 명시한다. +## 단일 서버 도메인 배포 (CORS) + +- 프론트(`frontend`, nginx)가 `/api`를 `frontend/nginx.conf`에서 `backend` 컨테이너로 리버스 프록시한다. 브라우저 입장에서는 프론트와 API가 같은 도메인(origin)이라 일반적인 CORS preflight 문제가 없다. +- 그래도 WebSocket(`/api/ws`) Origin 검증(`WebSocketConfig`)과 `/api/v1/auth/token/refresh`의 Origin 가드(`AuthController`)는 요청의 `Origin` 헤더를 `ALLOWED_ORIGINS`와 직접 비교한다. 브라우저는 same-origin이어도 POST 등 요청에 `Origin` 헤더를 붙이므로, **배포 도메인을 `ALLOWED_ORIGINS`에 프로토콜까지 정확히 추가하지 않으면 403이 난다.** (`.env.example` 참고, 예: `https://youthpick.samchon.cloud`) +- 프론트 이미지는 `front-end/Dockerfile`에서 `VITE_API_BASE_URL=/api`로 빌드타임 고정한다(도메인 무관하게 항상 상대경로) — 로컬 `front-end/.env`의 절대경로(`http://localhost:8080/api`)는 vite dev 전용이며 Docker 빌드에는 반영되지 않는다. +- OAuth를 쓰면 `OAUTH_FRONTEND_CALLBACK_URI`도 배포 도메인의 콜백 경로로 바꾸고, 각 provider 콘솔의 redirect_uri도 함께 갱신한다. + ## API 수동 검증 - 한글 query parameter는 raw URL에 넣지 말고 `--data-urlencode`를 사용한다: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..52f0584 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gradle +.idea +.github +build +docs +bin +*.md +.env +.env.example diff --git a/.env.example b/.env.example index 1baa257..34a9703 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,14 @@ MINIO_CONNECT_TIMEOUT=3s MINIO_WRITE_TIMEOUT=30s MINIO_READ_TIMEOUT=30s +# ===== CORS / 배포 도메인 ===== +# 프론트(nginx)가 /api를 backend 컨테이너로 리버스 프록시하는 배포 구성(front-end/nginx.conf)에서는 +# 브라우저 기준 요청이 same-origin이라 일반 API 호출엔 CORS가 발생하지 않는다. +# 다만 WebSocket Origin 검증(WebSocketConfig)과 /auth/token/refresh의 Origin 가드(AuthController)는 +# 이 목록으로 별도 검사하므로, 배포 도메인은 프로토콜까지 정확히 포함해 반드시 추가한다. +# 예) ALLOWED_ORIGINS=https://youthpick.samchon.cloud +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174 + # ===== 온통청년 정책 API (배치 수집용 — 비우면 배치만 실패하고 앱은 뜬다) ===== # 발급: https://www.youthcenter.go.kr 회원가입 후 open api 인증키 신청 YOUTH_API_KEY= @@ -49,6 +57,7 @@ YOUTH_API_KEY= # ===== OAuth (인증 기능 구현 시 채운다. 지금은 비워둬도 앱이 뜬다) ===== # Kakao 는 "REST API 키" 를 client-id 로 사용한다(JavaScript 키 아님). # 각 provider 콘솔에 redirect_uri 로 "OAUTH_FRONTEND_CALLBACK_URI" 값을 그대로 등록해야 한다(프론트 라우트). +# 배포 시에는 프론트와 같은 도메인의 콜백 경로로 바꾼다. 예) https://youthpick.samchon.cloud/oauth/callback GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= NAVER_OAUTH_CLIENT_ID= diff --git a/AGENTS.md b/AGENTS.md index 2afa3c3..be5af24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ .\gradlew.bat test ``` -로컬 인프라(Redis, 필요 시 MySQL)는 `docker compose up -d`로 띄운다. 앱 자체는 컨테이너화하지 않고 IDE/Gradle로 실행한다. +로컬 인프라(Redis, 필요 시 MySQL, MinIO)는 `docker compose up -d`로 띄운다. 앱(백엔드/프론트)까지 한 번에 띄우려면 `docker compose up -d --build`를 사용한다(`Dockerfile` 기반, [`infra.md`](./.claude/rules/infra.md) 참고). 평소 개발은 인프라만 compose로 띄우고 앱은 IDE/Gradle로 실행하는 쪽을 권장한다. ## Non-negotiable rules (요약) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf06542 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# ---- Build stage ---- +FROM eclipse-temurin:21-jdk AS build +WORKDIR /app + +COPY gradlew settings.gradle build.gradle ./ +COPY gradle gradle +RUN ./gradlew --no-daemon --version + +COPY src src +RUN ./gradlew --no-daemon bootJar -x test + +# ---- Runtime stage ---- +FROM eclipse-temurin:21-jre AS runtime +WORKDIR /app + +COPY --from=build /app/build/libs/*.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index 5b5816b..07e23c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,14 @@ -# 로컬 개발용 인프라만 띄운다 (MySQL + Redis + MinIO). -# backend/front 는 컨테이너화하지 않고 각자 로컬(IDE)에서 실행한다. +# 인프라(MySQL + Redis + MinIO) + 백엔드(Spring Boot) + 프론트(React, nginx) 를 한 번에 띄운다. # -# 사용법: docker-compose.yml 파일과 같은 레벨의 패키지로 가서 아래 명령어 실행. -# docker compose up -d # .env 를 자동으로 읽어 MySQL/Redis/MinIO 기동 -# docker compose ps # 상태 확인 -# docker compose down # 중지 (데이터는 볼륨에 보존) -# docker compose down -v # 중지 + 데이터 볼륨 삭제(초기화) +# 사용법: back-end/.env 를 준비한 뒤(.env.example 참고, JWT_SECRET 필수), +# docker compose up -d --build # 전체 기동 (최초 실행/코드 변경 후에는 --build 필요) +# docker compose ps # 상태 확인 +# docker compose logs -f backend # 백엔드 로그 확인 +# docker compose down # 중지 (데이터는 볼륨에 보존) +# docker compose down -v # 중지 + 데이터 볼륨 삭제(초기화) +# +# 인프라만 띄우고 앱은 IDE/로컬에서 실행하고 싶다면: +# docker compose up -d mysql redis minio services: mysql: @@ -63,6 +66,39 @@ services: retries: 10 start_period: 10s + backend: + build: + context: . + dockerfile: Dockerfile + container_name: youthpick-backend + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + env_file: + - .env + environment: + # 컨테이너 네트워크 안에서는 localhost 가 아니라 서비스명으로 붙는다 (.env 의 localhost 값을 덮어씀). + DB_URL: jdbc:mysql://mysql:3306/${DB_NAME:-youthpick}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul + REDIS_HOST: redis + REDIS_PORT: 6379 + MINIO_ENDPOINT: http://minio:9000 + ports: + - "8080:8080" + + frontend: + build: + context: ../front-end + dockerfile: Dockerfile + container_name: youthpick-frontend + depends_on: + - backend + ports: + - "5173:80" + volumes: youthpick-mysql-data: youthpick-redis-data: From 6fa6b089d0bc8f8aba24f943e26c9fc902816503 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 15:49:39 +0900 Subject: [PATCH 87/93] =?UTF-8?q?docs:=20Swagger(OpenAPI)=20API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=8F=84=EC=9E=85=20(#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit springdoc-openapi를 추가하고 전체 REST 컨트롤러에 @Tag/@Operation을 붙여 /swagger-ui에서 실제 코드 기준 API 문서를 바로 확인할 수 있게 한다. Closes #213 --- build.gradle | 1 + .../AdminCommunityCommentController.java | 4 ++ .../AdminCommunityPostController.java | 9 +++++ .../log/controller/AdminAppLogController.java | 6 +++ .../controller/AdminSearchLogController.java | 4 ++ .../AdminPolicyApplicationController.java | 8 ++++ .../controller/AdminPolicyController.java | 7 ++++ .../controller/AdminRegionController.java | 4 ++ .../AdminBatchJobLogController.java | 4 ++ .../controller/AdminPolicySyncController.java | 8 ++++ .../AdminPolicySyncJobController.java | 4 ++ .../AdminLoginHistoryController.java | 4 ++ .../user/controller/AdminUserController.java | 7 ++++ .../auth/controller/AuthController.java | 19 +++++++++ .../comment/controller/CommentController.java | 9 +++++ .../file/controller/FileController.java | 5 +++ .../global/config/OpenApiConfig.java | 39 +++++++++++++++++++ .../PolicyApplicationChecklistController.java | 36 +++++++++++++++++ .../PolicyApplicationController.java | 36 +++++++++++++++++ .../controller/PolicyChatController.java | 6 +++ .../PolicyComparisonController.java | 8 ++++ .../policy/controller/PolicyController.java | 11 ++++++ .../PolicyRecentViewController.java | 4 ++ .../RecommendedPolicyController.java | 6 +++ .../policy/controller/RegionController.java | 6 +++ .../post/controller/PostController.java | 8 ++++ .../controller/UserProfileController.java | 11 ++++++ 27 files changed, 274 insertions(+) create mode 100644 src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java diff --git a/build.gradle b/build.gradle index e1a22e7..399ab4b 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'io.minio:minio:9.0.1' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' implementation 'org.flywaydb:flyway-core' diff --git a/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java index a0dbf7d..cabb96e 100644 --- a/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java +++ b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java @@ -3,12 +3,15 @@ import com.bop.youthpick.admin.board.dto.AdminCommunityCommentResponse; import com.bop.youthpick.admin.board.service.AdminCommunityService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 커뮤니티 댓글") @RestController @RequestMapping("/api/v1/admin/community-comments") @RequiredArgsConstructor @@ -16,6 +19,7 @@ public class AdminCommunityCommentController { private final AdminCommunityService adminCommunityService; + @Operation(summary = "커뮤니티 댓글 삭제", description = "관리자가 커뮤니티 댓글을 삭제합니다.") @DeleteMapping("/{commentId}") public ApiResponse delete(@PathVariable Long commentId) { return ApiResponse.ok(adminCommunityService.deleteComment(commentId)); diff --git a/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java index 9db29e6..6947241 100644 --- a/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java +++ b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java @@ -5,6 +5,8 @@ import com.bop.youthpick.admin.board.dto.AdminCommunityPostResponse; import com.bop.youthpick.admin.board.service.AdminCommunityService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; import java.util.List; @@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 커뮤니티 게시글") @RestController @RequestMapping("/api/v1/admin/community-posts") @RequiredArgsConstructor @@ -29,6 +32,9 @@ public class AdminCommunityPostController { private final AdminCommunityService adminCommunityService; + @Operation( + summary = "커뮤니티 게시글 목록 조회", + description = "카테고리, 작성자, 작성일 범위로 커뮤니티 게시글을 검색해 페이지 단위로 조회합니다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) @@ -47,16 +53,19 @@ public ApiResponse> list( return ApiResponse.ok(page.getContent(), page); } + @Operation(summary = "게시글 댓글 목록 조회", description = "지정한 게시글에 달린 댓글 목록을 조회합니다.") @GetMapping("/{postId}/comments") public ApiResponse> getComments(@PathVariable Long postId) { return ApiResponse.ok(adminCommunityService.getComments(postId)); } + @Operation(summary = "게시글 첨부파일 목록 조회", description = "지정한 게시글에 첨부된 파일 목록을 조회합니다.") @GetMapping("/{postId}/attachments") public ApiResponse> getAttachments(@PathVariable Long postId) { return ApiResponse.ok(adminCommunityService.getAttachments(postId)); } + @Operation(summary = "커뮤니티 게시글 삭제", description = "관리자가 커뮤니티 게시글을 삭제합니다.") @DeleteMapping("/{postId}") public ApiResponse delete(@PathVariable Long postId) { return ApiResponse.ok(adminCommunityService.deletePost(postId)); diff --git a/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java b/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java index f7cc19c..e4c2558 100644 --- a/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java +++ b/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java @@ -3,6 +3,8 @@ import com.bop.youthpick.admin.log.dto.ApplicationLogResponse; import com.bop.youthpick.admin.log.service.AdminAppLogService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; import java.util.List; @@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 애플리케이션 로그") @RestController @RequestMapping("/api/v1/admin/application-logs") @RequiredArgsConstructor @@ -25,6 +28,9 @@ public class AdminAppLogController { private final AdminAppLogService adminAppLogService; + @Operation( + summary = "애플리케이션 로그 목록 조회", + description = "로그 레벨, 키워드, 기간으로 애플리케이션 로그를 검색해 페이지 단위로 조회합니다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) diff --git a/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java b/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java index 5500df7..6888255 100644 --- a/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java +++ b/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java @@ -3,6 +3,8 @@ import com.bop.youthpick.admin.log.dto.SearchLogResponse; import com.bop.youthpick.admin.log.service.AdminSearchLogService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; @@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 검색 로그") @RestController @RequestMapping("/api/v1/admin/search-logs") @RequiredArgsConstructor @@ -22,6 +25,7 @@ public class AdminSearchLogController { private final AdminSearchLogService adminSearchLogService; + @Operation(summary = "검색 로그 목록 조회", description = "키워드와 기간으로 검색 로그를 조회해 페이지 단위로 조회합니다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) String keyword, diff --git a/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java index 94c9659..c96f561 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java @@ -5,6 +5,8 @@ import com.bop.youthpick.admin.policy.dto.ApplicationChecklistItemResponse; import com.bop.youthpick.admin.policy.service.AdminPolicyApplicationService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; @@ -23,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 정책 신청관리") @RestController @RequestMapping("/api/v1/admin/policy-applications") @RequiredArgsConstructor @@ -31,6 +34,9 @@ public class AdminPolicyApplicationController { private final AdminPolicyApplicationService adminPolicyApplicationService; + @Operation( + summary = "정책 신청 목록 조회", + description = "사용자, 정책명, 상태, 마감일 범위로 정책 신청 목록을 검색하여 페이지 단위로 조회한다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) Long userId, @@ -51,12 +57,14 @@ public ApiResponse> list( return ApiResponse.ok(page.getContent(), page); } + @Operation(summary = "신청 체크리스트 조회", description = "특정 정책 신청 건의 체크리스트 항목 목록을 조회한다.") @GetMapping("/{applicationId}/checklist") public ApiResponse> getChecklist( @PathVariable Long applicationId) { return ApiResponse.ok(adminPolicyApplicationService.getChecklist(applicationId)); } + @Operation(summary = "신청 상태 변경", description = "특정 정책 신청 건의 상태를 변경한다.") @PatchMapping("/{applicationId}/status") public ApiResponse updateStatus( @PathVariable Long applicationId, diff --git a/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java index 12e5205..d22d7a4 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java @@ -5,6 +5,8 @@ import com.bop.youthpick.admin.policy.dto.AdminPolicyVisibilityUpdateRequest; import com.bop.youthpick.admin.policy.service.AdminPolicyService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; @@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 정책") @RestController @RequestMapping("/api/v1/admin/policies") @RequiredArgsConstructor @@ -33,6 +36,7 @@ public class AdminPolicyController { private final AdminPolicyService adminPolicyService; + @Operation(summary = "정책 목록 조회", description = "카테고리, 노출 상태, 기간으로 정책 목록을 검색하여 페이지 단위로 조회한다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) String category, @@ -51,12 +55,14 @@ public ApiResponse> list( return ApiResponse.ok(page.getContent(), page); } + @Operation(summary = "정책 정보 수정", description = "특정 정책의 정보를 수정한다.") @PutMapping("/{policyId}") public ApiResponse update( @PathVariable Long policyId, @Valid @RequestBody AdminPolicyUpdateRequest request) { return ApiResponse.ok(adminPolicyService.update(policyId, request)); } + @Operation(summary = "정책 노출 상태 변경", description = "특정 정책의 노출 상태(VISIBLE/HIDDEN)를 변경한다.") @PatchMapping("/{policyId}/visibility") public ApiResponse updateVisibility( @PathVariable Long policyId, @@ -65,6 +71,7 @@ public ApiResponse updateVisibility( adminPolicyService.updateVisibility(policyId, request.visibilityStatus())); } + @Operation(summary = "정책 삭제", description = "특정 정책을 소프트 삭제 처리한다.") @DeleteMapping("/{policyId}") public ApiResponse delete(@PathVariable Long policyId) { return ApiResponse.ok(adminPolicyService.softDelete(policyId)); diff --git a/src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java index 9a5229f..a9896a6 100644 --- a/src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java @@ -3,12 +3,15 @@ import com.bop.youthpick.admin.policy.service.AdminRegionService; import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.RegionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 지역") @RestController @RequestMapping("/api/v1/admin/regions") @RequiredArgsConstructor @@ -16,6 +19,7 @@ public class AdminRegionController { private final AdminRegionService adminRegionService; + @Operation(summary = "지역 목록 조회", description = "전체 지역 목록을 조회한다.") @GetMapping public ApiResponse> list() { return ApiResponse.ok(adminRegionService.findAll()); diff --git a/src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java index 11d4693..3b24e71 100644 --- a/src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java @@ -3,6 +3,8 @@ import com.bop.youthpick.admin.sync.dto.BatchJobLogResponse; import com.bop.youthpick.admin.sync.service.AdminBatchJobLogService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Pattern; import java.time.LocalDate; import java.util.List; @@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 배치 작업 이력") @RestController @RequestMapping("/api/v1/admin/batch-job-logs") @RequiredArgsConstructor @@ -25,6 +28,7 @@ public class AdminBatchJobLogController { private final AdminBatchJobLogService adminBatchJobLogService; + @Operation(summary = "배치 작업 이력 조회", description = "상태, 기간으로 배치 작업 이력 목록을 검색하여 페이지 단위로 조회한다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) diff --git a/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java index 742e753..35278e0 100644 --- a/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java @@ -2,12 +2,15 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.sync.service.PolicySyncService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 정책 동기화") @RestController @RequestMapping("/api/v1/admin/batch/policy-sync") @RequiredArgsConstructor @@ -19,6 +22,11 @@ public class AdminPolicySyncController { * 정책 수집을 비동기로 시작시키고 즉시 202를 반환한다. 이미 실행 중이면 {@code SY001} 409 (GlobalExceptionHandler 변환). 결과 * 확인은 {@code GET /api/v1/admin/batch-job-logs}(#57)로 한다. */ + @Operation( + summary = "정책 동기화 트리거", + description = + "정책 수집을 비동기로 시작시키고 즉시 202를 반환한다. 이미 실행 중이면 SY001 409(GlobalExceptionHandler 변환). 결과 확인은 GET" + + " /api/v1/admin/batch-job-logs로 한다.") @PostMapping public ResponseEntity> trigger() { policySyncService.startFullSyncAsync(); diff --git a/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java index 4d65aaa..13d40c3 100644 --- a/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java @@ -3,11 +3,14 @@ import com.bop.youthpick.admin.sync.dto.PolicySyncJobSummaryResponse; import com.bop.youthpick.admin.sync.service.AdminPolicySyncJobService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 정책 동기화 작업") @RestController @RequestMapping("/api/v1/admin/policy-sync-jobs") @RequiredArgsConstructor @@ -15,6 +18,7 @@ public class AdminPolicySyncJobController { private final AdminPolicySyncJobService adminPolicySyncJobService; + @Operation(summary = "정책 동기화 작업 요약 조회", description = "정책 동기화 작업의 현황 요약 정보를 조회한다.") @GetMapping("/summary") public ApiResponse summary() { return ApiResponse.ok(adminPolicySyncJobService.getSummary()); diff --git a/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java b/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java index b022d48..7afbda4 100644 --- a/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java +++ b/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java @@ -3,6 +3,8 @@ import com.bop.youthpick.admin.user.dto.LoginHistoryResponse; import com.bop.youthpick.admin.user.service.AdminLoginHistoryService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; @@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 로그인 이력") @RestController @RequestMapping("/api/v1/admin/login-histories") @RequiredArgsConstructor @@ -22,6 +25,7 @@ public class AdminLoginHistoryController { private final AdminLoginHistoryService adminLoginHistoryService; + @Operation(summary = "로그인 이력 목록 조회", description = "회원과 기간으로 로그인 이력을 검색해 페이지 단위로 조회합니다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) Long userId, diff --git a/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java b/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java index ad30ce7..fa7c430 100644 --- a/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java +++ b/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java @@ -5,6 +5,8 @@ import com.bop.youthpick.admin.user.dto.AdminUserRoleUpdateRequest; import com.bop.youthpick.admin.user.service.AdminUserService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import java.util.List; @@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "관리자 - 회원") @RestController @RequestMapping("/api/v1/admin/users") @RequiredArgsConstructor @@ -30,6 +33,7 @@ public class AdminUserController { private final AdminUserService adminUserService; + @Operation(summary = "회원 목록 조회", description = "권한, 계정 상태, 가입 경로로 회원을 검색해 페이지 단위로 조회합니다.") @GetMapping public ApiResponse> list( @RequestParam(required = false) @@ -47,17 +51,20 @@ public ApiResponse> list( return ApiResponse.ok(page.getContent(), page); } + @Operation(summary = "회원 프로필 조회", description = "지정한 회원의 상세 프로필을 조회합니다.") @GetMapping("/{userId}/profile") public ApiResponse getProfile(@PathVariable Long userId) { return ApiResponse.ok(adminUserService.getProfile(userId)); } + @Operation(summary = "회원 권한 변경", description = "관리자가 지정한 회원의 권한(role)을 변경합니다.") @PatchMapping("/{userId}/role") public ApiResponse updateRole( @PathVariable Long userId, @Valid @RequestBody AdminUserRoleUpdateRequest request) { return ApiResponse.ok(adminUserService.updateRole(userId, request.role())); } + @Operation(summary = "회원 강제 탈퇴", description = "관리자가 지정한 회원을 소프트 삭제(강제 탈퇴) 처리합니다.") @DeleteMapping("/{userId}") public ApiResponse delete(@PathVariable Long userId) { return ApiResponse.ok(adminUserService.softDelete(userId)); diff --git a/src/main/java/com/bop/youthpick/auth/controller/AuthController.java b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java index 2a241b0..52dcfef 100644 --- a/src/main/java/com/bop/youthpick/auth/controller/AuthController.java +++ b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java @@ -13,6 +13,8 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.global.config.CorsProperties; import com.bop.youthpick.user.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "인증") @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor @@ -38,6 +41,9 @@ public class AuthController { private final RefreshTokenCookieSupport refreshTokenCookieSupport; private final CorsProperties corsProperties; + @Operation( + summary = "소셜 로그인 URL 발급", + description = "지정한 OAuth provider(google/naver/kakao)의 인가 URL을 생성해 반환한다.") @GetMapping("/oauth/{provider}/authorization-url") public ApiResponse authorizationUrl( @PathVariable String provider) { @@ -45,6 +51,10 @@ public ApiResponse authorizationUrl( return ApiResponse.ok(new OAuthAuthorizationUrlResponse(url)); } + @Operation( + summary = "소셜 로그인 콜백", + description = + "OAuth provider의 인가 코드로 로그인을 처리하고, access token은 응답 body로, refresh token은 HttpOnly 쿠키로 내려준다.") @PostMapping("/oauth/{provider}/callback") public ResponseEntity> callback( @PathVariable String provider, @Valid @RequestBody OAuthCallbackRequest request) { @@ -58,6 +68,10 @@ public ResponseEntity> callback( * refresh token 자체는 재발급(rotate)하지 않고 발급 시점의 만료 기간까지 그대로 재사용하므로, access token만 새로 내려주고 쿠키는 다시 * 설정하지 않는다. */ + @Operation( + summary = "액세스 토큰 재발급", + description = + "refresh token은 body가 아니라 HttpOnly 쿠키로 전달받는다. Origin 헤더가 있으면 허용 목록에 포함되는지 검증하고, refresh token 자체는 재발급(rotate)하지 않은 채 access token만 새로 내려준다.") @PostMapping("/token/refresh") public ApiResponse refresh( @CookieValue(name = RefreshTokenCookieSupport.COOKIE_NAME, required = false) @@ -71,6 +85,10 @@ public ApiResponse refresh( return ApiResponse.ok(AccessTokenResponse.from(tokens)); } + @Operation( + summary = "로그아웃", + description = + "인증된 사용자의 refresh token을 Redis에서 삭제하고, HttpOnly 쿠키로 전달되던 refresh token 쿠키도 함께 만료시킨다.") @PostMapping("/logout") public ResponseEntity logout(@CurrentUser Long userId, HttpServletResponse response) { authService.logout(userId); @@ -78,6 +96,7 @@ public ResponseEntity logout(@CurrentUser Long userId, HttpServletResponse return ResponseEntity.noContent().build(); } + @Operation(summary = "내 정보 조회", description = "인증된 사용자의 기본 정보를 조회해 반환한다.") @GetMapping("/me") public ApiResponse me(@CurrentUser Long userId) { User user = authService.getCurrentUser(userId); diff --git a/src/main/java/com/bop/youthpick/comment/controller/CommentController.java b/src/main/java/com/bop/youthpick/comment/controller/CommentController.java index cce7fb2..3e8ba1f 100644 --- a/src/main/java/com/bop/youthpick/comment/controller/CommentController.java +++ b/src/main/java/com/bop/youthpick/comment/controller/CommentController.java @@ -6,6 +6,8 @@ import com.bop.youthpick.comment.dto.CommentUpdateRequest; import com.bop.youthpick.comment.service.CommentService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "댓글") @RestController @RequestMapping("/api/v1/posts/{postId}/comments") @RequiredArgsConstructor @@ -27,6 +30,7 @@ public class CommentController { private final CommentService commentService; + @Operation(summary = "댓글 등록", description = "특정 게시글에 새로운 댓글을 작성하여 등록한다.") @PostMapping public ResponseEntity> create( @CurrentUser Long userId, @@ -37,12 +41,14 @@ public ResponseEntity> create( return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(res)); } + @Operation(summary = "댓글 목록 조회", description = "특정 게시글에 달린 댓글 전체 목록을 조회한다.") @GetMapping public ApiResponse> findAll( @PathVariable Long postId, @CurrentUser(required = false) Long userId) { return commentService.getAllByPostId(postId); } + @Operation(summary = "댓글 수정", description = "댓글 ID로 대상 댓글의 내용을 수정한다.") @PatchMapping("/{commentId}") public ApiResponse update( @CurrentUser Long userId, @@ -52,6 +58,9 @@ public ApiResponse update( return ApiResponse.ok(commentService.update(userId, commentId, request)); } + @Operation( + summary = "댓글 삭제", + description = "댓글 ID로 대상 댓글을 삭제한다. 작성자 본인만 삭제할 수 있는지는 Service에서 검사한다.") @DeleteMapping("/{commentId}") public ApiResponse delete( // [추가] @CurrentUser: JWT 토큰에서 로그인한 사용자의 id를 꺼내 주입해 준다. diff --git a/src/main/java/com/bop/youthpick/file/controller/FileController.java b/src/main/java/com/bop/youthpick/file/controller/FileController.java index ddb0457..f256744 100644 --- a/src/main/java/com/bop/youthpick/file/controller/FileController.java +++ b/src/main/java/com/bop/youthpick/file/controller/FileController.java @@ -5,6 +5,8 @@ import com.bop.youthpick.file.dto.FileUploadResponse; import com.bop.youthpick.file.service.FileService; import com.bop.youthpick.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.UUID; @@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +@Tag(name = "파일") @RestController @RequestMapping("/api/v1/files") @RequiredArgsConstructor @@ -33,6 +36,7 @@ public class FileController { private final FileService fileService; + @Operation(summary = "파일 업로드", description = "인증된 사용자가 파일을 업로드하고 저장된 파일 정보를 반환한다.") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> upload( @CurrentUser Long userId, @RequestPart("file") MultipartFile file) { @@ -40,6 +44,7 @@ public ResponseEntity> upload( return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(response)); } + @Operation(summary = "파일 다운로드", description = "파일 ID로 저장된 파일을 조회해 스트림으로 반환한다.") @GetMapping("/{fileId}") public ResponseEntity download(@PathVariable UUID fileId) { FileDownload file = fileService.download(fileId); diff --git a/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java b/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java new file mode 100644 index 0000000..e9d78cc --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java @@ -0,0 +1,39 @@ +package com.bop.youthpick.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + private static final String BEARER_AUTH_SCHEME = "bearerAuth"; + + @Bean + OpenAPI openAPI() { + return new OpenAPI() + .info(apiInfo()) + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH_SCHEME)) + .components( + new Components() + .addSecuritySchemes(BEARER_AUTH_SCHEME, bearerAuthScheme())); + } + + private Info apiInfo() { + return new Info() + .title("YouthPick API") + .description("청년 정책 추천 서비스 YouthPick 백엔드 API 문서") + .version("v0.0.1"); + } + + private SecurityScheme bearerAuthScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + } +} diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java index 0c3871a..eb4dd00 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java @@ -8,6 +8,8 @@ import com.bop.youthpick.policy.dto.PolicyApplicationUpdateChecklistRequest; import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; import com.bop.youthpick.policy.service.PolicyApplicationChecklistService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -35,6 +37,7 @@ * {@code @Valid @RequestBody} DTO(Create/UpdateChecklistRequest)로만 이루어지고, {@code @RequestParam}에 직접 * 붙는 제약(예: {@code @Pattern})이 없기 때문이다({@code @Validated}는 그 경우에만 필요하다). */ +@Tag(name = "정책 신청 체크리스트") @RestController @RequestMapping("/api/v1/policy-application-checklists") @RequiredArgsConstructor @@ -48,6 +51,13 @@ public class PolicyApplicationChecklistController { * 넘어가고, 서비스 내부에서 {@code PolicyApplicationChecklist.create()}가 이걸 content 필드에 담는다 — DTO는 * "message", 엔티티/리포지토리는 "content"로 이름이 다르니 헷갈리지 않도록 주의. */ + @Operation( + summary = "체크리스트 항목 추가", + description = + "POST /api/v1/policy-application-checklists — 특정 신청관리(applicationId)에 체크리스트 항목 추가." + + " request.message()가 PolicyApplicationChecklistService.add의 message 인자로 그대로 넘어가고, 서비스" + + " 내부에서 PolicyApplicationChecklist.create()가 이걸 content 필드에 담는다 — DTO는 \"message\"," + + " 엔티티/리포지토리는 \"content\"로 이름이 다르니 헷갈리지 않도록 주의.") @PostMapping public ResponseEntity> add( @CurrentUser Long userId, @@ -63,6 +73,12 @@ public ResponseEntity> add( * 체크리스트 목록. 서비스가 먼저 부모 신청관리의 존재/소유권을 확인한 뒤(쿼리 1회), 체크리스트 목록을 id 오름차순(등록 순서)으로 조회한다(쿼리 1회) — 총 * 2쿼리. */ + @Operation( + summary = "신청별 체크리스트 목록 조회", + description = + "GET /api/v1/policy-application-checklists/application/{applicationId} — 특정 신청관리에 달린 체크리스트" + + " 목록. 서비스가 먼저 부모 신청관리의 존재/소유권을 확인한 뒤(쿼리 1회), 체크리스트 목록을 id 오름차순(등록 순서)으로 조회한다(쿼리 1회) — 총" + + " 2쿼리.") @GetMapping("/application/{applicationId}") public ApiResponse> getByApplication( @CurrentUser Long userId, @@ -74,6 +90,10 @@ public ApiResponse> getByApplication( } /** {@code PATCH /api/v1/policy-application-checklists/{id}} — 체크리스트 내용(content) 수정. */ + @Operation( + summary = "체크리스트 내용 수정", + description = + "PATCH /api/v1/policy-application-checklists/{id} — 체크리스트 내용(content) 수정.") @PatchMapping("/{id}") public ApiResponse update( @CurrentUser Long userId, @@ -90,6 +110,11 @@ public ApiResponse update( * PolicyApplicationController#delete}가 {@code ApiResponse.ok(null)}로 데이터 없음을 표현하는 것과 다른 스타일 — * 여긴 메시지 body를 쓴다). */ + @Operation( + summary = "체크리스트 항목 체크", + description = + "PATCH /api/v1/policy-application-checklists/{id}/check — 체크 표시. body 없이 상태만 토글하므로 반환 데이터가" + + " 없고, PolicyApplicationChecklistMessageResponse로 성공 메시지만 내려준다.") @PatchMapping("/{id}/check") public ApiResponse check( @CurrentUser Long userId, @PathVariable Long id) { @@ -98,6 +123,10 @@ public ApiResponse check( } /** {@code PATCH /api/v1/policy-application-checklists/{id}/uncheck} — {@link #check}의 반대. */ + @Operation( + summary = "체크리스트 항목 체크 해제", + description = + "PATCH /api/v1/policy-application-checklists/{id}/uncheck — check의 반대. 체크 표시를 해제한다.") @PatchMapping("/{id}/uncheck") public ApiResponse uncheck( @CurrentUser Long userId, @PathVariable Long id) { @@ -111,6 +140,13 @@ public ApiResponse uncheck( * PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()}이고, 그건 이 컨트롤러가 아니라 {@code * PolicyApplicationService.create()}의 reactivate 분기에서 호출된다(다른 도메인 흐름과의 연결점). */ + @Operation( + summary = "체크리스트 항목 삭제", + description = + "DELETE /api/v1/policy-application-checklists/{id} — 체크리스트 항목 하나만 소프트 삭제. 부모 신청관리 전체가" + + " 삭제/재등록될 때 체크리스트를 한 번에 정리하는 경로는 여기가 아니라" + + " PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()이고, 그건 이 컨트롤러가 아니라" + + " PolicyApplicationService.create()의 reactivate 분기에서 호출된다.") @DeleteMapping("/{id}") public ApiResponse delete( @CurrentUser Long userId, @PathVariable Long id) { diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java index d23bee8..15c02da 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java @@ -11,6 +11,8 @@ import com.bop.youthpick.policy.entity.PolicyApplication; import com.bop.youthpick.policy.exception.PolicyErrorCode; import com.bop.youthpick.policy.service.PolicyApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -38,6 +40,7 @@ * "Controller는 얇게" 규칙). */ @Validated +@Tag(name = "정책 신청관리") @RestController @RequestMapping("/api/v1/policy-applications") @RequiredArgsConstructor @@ -52,6 +55,13 @@ public class PolicyApplicationController { * 대신 깔끔한 400(P007)으로 막아준다. 신규 등록 vs soft-delete 재활성화 vs 중복 예외 판단은 {@link * PolicyApplicationService#create}가 전담한다. */ + @Operation( + summary = "정책 신청 관심 등록", + description = + "POST /api/v1/policy-applications — \"관심 등록\" 액션의 진입점. status 문자열 → ApplicationStatus 변환은" + + " parseStatus를 거친다 — 평소엔 DTO의 @Pattern(=ApplicationStatus.VALUES_PATTERN)이 걸러준 값만 들어오지만, 만에 하나" + + " 어긋날 경우를 대비해 parseStatus가 500 대신 깔끔한 400(P007)으로 막아준다. 신규 등록 vs soft-delete 재활성화 vs 중복 예외" + + " 판단은 PolicyApplicationService.create가 전담한다.") @PostMapping public ResponseEntity> create( @CurrentUser Long userId, @Valid @RequestBody PolicyApplicationCreateRequest request) { @@ -72,6 +82,12 @@ public ResponseEntity> create( * PolicyApplicationResponse}로 변환된 {@code Page}를 돌려주므로, 여기선 그 Page를 (content, meta) 형태로 {@link * ApiResponse}에 담기만 한다(api-design.md의 Pageable 규칙). */ + @Operation( + summary = "정책 신청 목록 조회", + description = + "GET /api/v1/policy-applications — 내 정책 신청 목록 페이지 조회. page/size/totalPages는 컨트롤러가 직접" + + " 계산하지 않는다 — PolicyApplicationService.getApplications가 이미 PolicyApplicationResponse로 변환된" + + " Page를 돌려주므로, 여기선 그 Page를 (content, meta) 형태로 ApiResponse에 담기만 한다.") @GetMapping public ApiResponse> getApplications( @CurrentUser Long userId, @PageableDefault(size = 20) Pageable pageable) { @@ -86,6 +102,13 @@ public ApiResponse> getApplications( * 동일하게 유지). 소유권 검증은 여기가 아니라 {@link PolicyApplicationService#changeStatus} 안의 {@code * verifyOwner()}에서 한다 — id로 조회한 신청이 진짜 이 userId 소유인지는 서비스 계층 책임이다. */ + @Operation( + summary = "정책 신청 상태 변경", + description = + "PATCH /api/v1/policy-applications/{id}/status — 상태만 단독으로 바꾸는 엔드포인트. create의 DTO와 같은" + + " ApplicationStatus.VALUES_PATTERN 화이트리스트를 공유한다(parseStatus가 방어선 역할은 동일하게 유지). 소유권 검증은 여기가" + + " 아니라 PolicyApplicationService.changeStatus 안의 verifyOwner()에서 한다 — id로 조회한 신청이 진짜 이 userId" + + " 소유인지는 서비스 계층 책임이다.") @PatchMapping("/{id}/status") public ApiResponse changeStatus( @CurrentUser Long userId, @@ -103,6 +126,11 @@ public ApiResponse changeStatus( * {@code PATCH /api/v1/policy-applications/{id}/memo} — 메모만 단독 수정. 컨트롤러는 {@code @Size}로 DTO 필드 * 길이를 검증하고, 빈 문자열/공백을 null로 통일하는 정규화는 서비스 계층이 담당한다. */ + @Operation( + summary = "정책 신청 메모 수정", + description = + "PATCH /api/v1/policy-applications/{id}/memo — 메모만 단독 수정. 컨트롤러는 @Size로 DTO 필드 길이를" + + " 검증하고, 빈 문자열/공백을 null로 통일하는 정규화는 서비스 계층이 담당한다.") @PatchMapping("/{id}/memo") public ApiResponse updateMemo( @CurrentUser Long userId, @@ -114,6 +142,9 @@ public ApiResponse updateMemo( } /** {@code PATCH /api/v1/policy-applications/{id}/end-at} — 마감일 단독 수정. */ + @Operation( + summary = "정책 신청 마감일 수정", + description = "PATCH /api/v1/policy-applications/{id}/end-at — 마감일 단독 수정.") @PatchMapping("/{id}/end-at") public ApiResponse updateEndAt( @CurrentUser Long userId, @@ -129,6 +160,11 @@ public ApiResponse updateEndAt( * deletedAt}만 세팅한다(재등록 시 reactivate로 되살아남). 반환할 데이터가 없어 {@code ApiResponse.ok(null)} — data는 * null, meta 없음. */ + @Operation( + summary = "정책 신청 관심 해제", + description = + "DELETE /api/v1/policy-applications/{id} — 소프트 삭제(관심 해제). 물리 삭제가 아니라 deletedAt만 세팅한다(재등록" + + " 시 reactivate로 되살아남). 반환할 데이터가 없어 ApiResponse.ok(null) — data는 null, meta 없음.") @DeleteMapping("/{id}") public ApiResponse delete(@CurrentUser Long userId, @PathVariable Long id) { policyApplicationService.delete(id, userId); diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java index b2d19e0..2ff9706 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java @@ -4,6 +4,8 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.PolicyChatMessagesResponse; import com.bop.youthpick.policy.service.PolicyChatService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; @@ -13,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "정책 채팅") @RestController @RequestMapping("/api/v1/policies/{policyId}/chat/messages") @RequiredArgsConstructor @@ -21,6 +24,9 @@ public class PolicyChatController { private final PolicyChatService policyChatService; + @Operation( + summary = "정책 채팅 메시지 조회", + description = "특정 정책 채팅방의 메시지 목록을 afterId 기준으로 이후 메시지만 조회한다(회원 전용).") @GetMapping public ApiResponse getMessages( @CurrentUser Long userId, diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java index 4eb1e6a..a3c14a5 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java @@ -3,6 +3,8 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.PolicyComparisonItemResponse; import com.bop.youthpick.policy.service.PolicyComparisonService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; import java.util.List; @@ -17,6 +19,7 @@ * 정책 비교 (비회원 허용). 비교 결과는 저장하지 않고 요청받은 정책들을 그때그때 조회해 내려주는 순수 조회다 — 그래서 생성(POST) + 식별자 재조회가 아니라 쿼리 * 파라미터를 받는 단일 GET이다. 공유가 필요하면 이 요청 URL 자체가 공유 링크가 된다. */ +@Tag(name = "정책 비교") @Validated @RestController @RequestMapping("/api/v1/policy-comparisons") @@ -25,6 +28,11 @@ public class PolicyComparisonController { private final PolicyComparisonService policyComparisonService; + @Operation( + summary = "정책 비교 조회", + description = + "정책 비교(비회원 허용). 비교 결과는 저장하지 않고 요청받은 정책들을 그때그때 조회해 내려주는 순수 조회다 — 그래서 생성(POST) + 식별자" + + " 재조회가 아니라 쿼리 파라미터를 받는 단일 GET이다. 공유가 필요하면 이 요청 URL 자체가 공유 링크가 된다.") @GetMapping public ApiResponse> compare( @RequestParam diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java index c566f11..435a8f0 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java @@ -5,6 +5,8 @@ import com.bop.youthpick.policy.dto.PolicyCardResponse; import com.bop.youthpick.policy.dto.PolicyDetailResponse; import com.bop.youthpick.policy.service.PolicyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "정책") @RestController @RequestMapping("/api/v1/policies") @RequiredArgsConstructor @@ -29,6 +32,13 @@ public class PolicyController { * ageMin/ageMax는 자격 구간과의 겹침, jobCode는 온통청년 취업상태 코드(예: 0013001 재직자) — 모두 선택. meta에 * page/totalCount/totalPages. */ + @Operation( + summary = "정책 목록 조회", + description = + "정책 목록(카드) 조회(비회원 허용). 기본 최신순이되 region·jobCode 필터가 걸리면 조건 없는 정책(전국/취업상태 제한없음)을 뒤로 민다." + + " category(표준 5분류) exact match, keyword는 부분일치 검색, region은 시도명('전국'은 지역 무관이라 필터" + + " 미적용), ageMin/ageMax는 자격 구간과의 겹침, jobCode는 온통청년 취업상태 코드(예: 0013001 재직자) — 모두 선택." + + " meta에 page/totalCount/totalPages.") @GetMapping public ApiResponse> getCards( @RequestParam(required = false) String category, @@ -45,6 +55,7 @@ public ApiResponse> getCards( } /** 정책 상세 조회 (비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다. */ + @Operation(summary = "정책 상세 조회", description = "정책 상세 조회(비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다.") @GetMapping("/{policyId}") public ApiResponse getDetail( @PathVariable Long policyId, @CurrentUser(required = false) Long userId) { diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java index ad6b1c9..2c7ba4b 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java @@ -4,6 +4,8 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.PolicyRecentViewResponse; import com.bop.youthpick.policy.service.PolicyRecentViewService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -13,6 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "최근 본 정책") @RestController @RequestMapping("/api/v1/policy-recent-views") @RequiredArgsConstructor @@ -21,6 +24,7 @@ public class PolicyRecentViewController { private final PolicyRecentViewService policyRecentViewService; /** 최근 본 정책 목록 (회원 전용). 마지막 조회 시각 내림차순. */ + @Operation(summary = "최근 본 정책 목록 조회", description = "최근 본 정책 목록(회원 전용). 마지막 조회 시각 내림차순.") @GetMapping public ApiResponse> list( @CurrentUser Long userId, @PageableDefault(size = 20) Pageable pageable) { diff --git a/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java index 1553fc3..2b8af6c 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java @@ -4,6 +4,8 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.RecommendedPolicyResponse; import com.bop.youthpick.policy.service.RecommendedPolicyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -11,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "추천 정책") @RestController @RequestMapping("/api/v1/recommended-policies") @RequiredArgsConstructor @@ -19,6 +22,9 @@ public class RecommendedPolicyController { private final RecommendedPolicyService recommendedPolicyService; /** 맞춤정책 조회 (회원 전용). 로그인 사용자의 온보딩 프로필과 정책 자격조건을 매칭해 점수순으로 내린다. */ + @Operation( + summary = "맞춤 정책 추천 조회", + description = "맞춤정책 조회(회원 전용). 로그인 사용자의 온보딩 프로필과 정책 자격조건을 매칭해 점수순으로 내린다.") @GetMapping public ApiResponse> getRecommendations( @CurrentUser Long userId, diff --git a/src/main/java/com/bop/youthpick/policy/controller/RegionController.java b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java index aa265a0..3c2c3a3 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/RegionController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java @@ -3,6 +3,8 @@ import com.bop.youthpick.global.common.ApiResponse; import com.bop.youthpick.policy.dto.RegionResponse; import com.bop.youthpick.policy.service.RegionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -10,6 +12,7 @@ import org.springframework.web.bind.annotation.RestController; /** 지역 마스터 전체 목록 조회 (비회원 허용). 온보딩 프로필의 거주지역 선택지로 쓰인다. */ +@Tag(name = "지역") @RestController @RequestMapping("/api/v1/regions") @RequiredArgsConstructor @@ -17,6 +20,9 @@ public class RegionController { private final RegionService regionService; + @Operation( + summary = "지역 목록 조회", + description = "지역 마스터 전체 목록 조회(비회원 허용). 온보딩 프로필의 거주지역 선택지로 쓰인다.") @GetMapping public ApiResponse> list() { return ApiResponse.ok(regionService.getAllRegions()); diff --git a/src/main/java/com/bop/youthpick/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java index 8c7a912..01abc2f 100644 --- a/src/main/java/com/bop/youthpick/post/controller/PostController.java +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -8,6 +8,8 @@ import com.bop.youthpick.post.dto.PostUpdateRequest; import com.bop.youthpick.post.entity.PostCategory; import com.bop.youthpick.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.List; @@ -21,6 +23,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +@Tag(name = "게시글") @RestController @RequestMapping("/api/v1/posts") @RequiredArgsConstructor @@ -31,6 +34,7 @@ public class PostController { private final PostService postService; + @Operation(summary = "게시글 등록", description = "새로운 게시글을 작성하여 등록한다.") @PostMapping public ResponseEntity> create( @CurrentUser Long userId, @Valid @RequestBody PostCreateRequest request) { @@ -39,6 +43,7 @@ public ResponseEntity> create( .body(ApiResponse.ok(response)); // 성공했을때 http status 201 } + @Operation(summary = "게시글 목록 조회", description = "카테고리/검색어로 게시글 목록을 페이지 단위로 조회한다.") @GetMapping // 리스폰스엔티티로 감싸야함 수정필요 public ApiResponse> findAll( @RequestParam(required = false) PostCategory category, @@ -51,6 +56,7 @@ public ApiResponse> findAll( return ApiResponse.ok(page.getContent(), page); } + @Operation(summary = "게시글 상세 조회", description = "게시글 ID로 상세 내용을 조회한다.") @GetMapping("/{postId}") public ApiResponse findById( @PathVariable Long postId, @@ -59,6 +65,7 @@ public ApiResponse findById( return ApiResponse.ok(postService.findById(postId, userId, request.getRemoteAddr())); } + @Operation(summary = "게시글 수정", description = "게시글 ID로 대상 게시글의 내용을 수정한다.") @PatchMapping("/{postId}") public ApiResponse update( @CurrentUser Long userId, @@ -67,6 +74,7 @@ public ApiResponse update( return ApiResponse.ok(postService.update(userId, postId, request)); } + @Operation(summary = "게시글 삭제", description = "게시글 ID로 대상 게시글을 삭제한다.") @DeleteMapping("/{postId}") public ApiResponse delete(@CurrentUser Long userId, @PathVariable Long postId) { postService.delete(userId, postId); diff --git a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java index 620eb82..3717b0c 100644 --- a/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java +++ b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java @@ -8,6 +8,8 @@ import com.bop.youthpick.user.dto.UserProfileResponse; import com.bop.youthpick.user.entity.UserProfile; import com.bop.youthpick.user.service.UserProfileService; +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.http.HttpStatus; @@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "회원 프로필") @RestController @RequiredArgsConstructor public class UserProfileController { @@ -27,6 +30,10 @@ public class UserProfileController { // 경로의 userId는 프론트 호환을 위해 유지하되, 실제 대상은 인증 principal이다. // path와 principal이 다르면 타인 프로필 생성(IDOR)이므로 403으로 거부한다. + @Operation( + summary = "회원 프로필 등록", + description = + "경로의 userId는 프론트 호환을 위해 유지하되 실제 대상은 인증 principal이며, path와 principal이 다르면 타인 프로필 생성(IDOR)이므로 403으로 거부한다.") @PostMapping("/api/v1/users/{userId}/profile") public ResponseEntity> submit( @CurrentUser Long currentUserId, @@ -40,12 +47,16 @@ public ResponseEntity> submit( .body(ApiResponse.ok(UserProfileResponse.from(profile))); } + @Operation( + summary = "내 프로필 조회", + description = "인증된 사용자의 프로필을 조회해 반환한다. 등록된 프로필이 없으면 null을 반환한다.") @GetMapping("/api/v1/me/profile") public ApiResponse getMyProfile(@CurrentUser Long userId) { UserProfile profile = userProfileService.getMyProfile(userId); return ApiResponse.ok(profile == null ? null : UserProfileResponse.from(profile)); } + @Operation(summary = "내 프로필 수정", description = "인증된 사용자의 프로필 정보를 수정한다.") @PatchMapping("/api/v1/me/profile") public ApiResponse updateMyProfile( @CurrentUser Long userId, @Valid @RequestBody UserProfileRequest request) { From 85837a62e11d73746c82324f79eabbe3a4bc508d Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 16:07:26 +0900 Subject: [PATCH 88/93] =?UTF-8?q?docs:=20=EA=B3=B5=ED=86=B5=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=91=EB=8B=B5(ErrorResponse)=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=EC=99=80=204XX/5XX=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 성공 응답의 실제 타입(ApiResponse)과 요청 DTO는 springdoc이 이미 정확히 추론하지만, 예외 핸들러가 던지는 ErrorResponse는 컨트롤러 시그니처에 드러나지 않아 문서에서 빠져 있었다. OpenApiConfig에서 ErrorResponse 스키마를 명시적으로 등록하고 OperationCustomizer로 모든 오퍼레이션에 4XX/5XX 공통 에러 응답을 전역으로 붙인다. Closes #215 --- .../global/config/OpenApiConfig.java | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java b/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java index e9d78cc..c84ee14 100644 --- a/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java @@ -1,26 +1,85 @@ package com.bop.youthpick.global.config; +import com.bop.youthpick.global.error.ErrorResponse; +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.customizers.OperationCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * 성공 응답의 실제 타입은 컨트롤러 반환 타입에서 springdoc이 자동으로 뽑아내지만(예: {@code ApiResponseListPolicyCardResponse}), + * 에러 응답({@code global.error.ErrorResponse})은 예외 핸들러가 던지는 것이라 컨트롤러 시그니처에 드러나지 않는다. 그래서 스키마 등록과 + * 4XX/5XX 기본 응답 노출을 여기서 전역으로 채워준다. + */ @Configuration public class OpenApiConfig { private static final String BEARER_AUTH_SCHEME = "bearerAuth"; + private static final String ERROR_SCHEMA_NAME = "ErrorResponse"; @Bean OpenAPI openAPI() { + Components components = + new Components().addSecuritySchemes(BEARER_AUTH_SCHEME, bearerAuthScheme()); + registerErrorSchema(components); + return new OpenAPI() .info(apiInfo()) .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH_SCHEME)) - .components( - new Components() - .addSecuritySchemes(BEARER_AUTH_SCHEME, bearerAuthScheme())); + .components(components); + } + + @Bean + OperationCustomizer errorResponseCustomizer() { + Content errorContent = + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + new Schema<>() + .$ref( + "#/components/schemas/" + + ERROR_SCHEMA_NAME))); + + return (operation, handlerMethod) -> { + ApiResponses responses = operation.getResponses(); + responses.addApiResponse( + "4XX", + errorApiResponse( + errorContent, + "클라이언트 오류 — 공통 에러 응답 형식(ErrorResponse)으로 내려간다. code 필드로 원인을 구분한다(예: C001" + + " 입력값 오류, A001 인증 실패).")); + responses.addApiResponse( + "5XX", + errorApiResponse( + errorContent, "서버 내부 오류 — 공통 에러 응답 형식(ErrorResponse)으로 내려간다(S001).")); + return operation; + }; + } + + private ApiResponse errorApiResponse(Content content, String description) { + return new ApiResponse().description(description).content(content); + } + + private void registerErrorSchema(Components components) { + ResolvedSchema resolvedSchema = + ModelConverters.getInstance() + .resolveAsResolvedSchema(new AnnotatedType(ErrorResponse.class)); + components.addSchemas(ERROR_SCHEMA_NAME, resolvedSchema.schema); + resolvedSchema.referencedSchemas.forEach(components::addSchemas); } private Info apiInfo() { From 53471dec7b83a91482da14cb035f43aea20d159b Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 17:07:45 +0900 Subject: [PATCH 89/93] =?UTF-8?q?docs:=20PageMeta=EC=97=90=20example=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=ED=95=B4=20Swagger=20=EC=9D=98=EB=AF=B8?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=83=98=ED=94=8C=EA=B0=92=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#218)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit int/long 필드에 example이 없으면 Swagger UI 샘플러가 2^30, 2^53-1(Number.MAX_SAFE_INTEGER) 같은 의미 없는 기본값을 채워 넣는다. @Schema(example=...)로 실제 값처럼 보이는 예시를 명시한다. Closes #217 --- .../java/com/bop/youthpick/global/common/PageMeta.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bop/youthpick/global/common/PageMeta.java b/src/main/java/com/bop/youthpick/global/common/PageMeta.java index 53b9a4a..da7138d 100644 --- a/src/main/java/com/bop/youthpick/global/common/PageMeta.java +++ b/src/main/java/com/bop/youthpick/global/common/PageMeta.java @@ -1,5 +1,6 @@ package com.bop.youthpick.global.common; +import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.data.domain.Page; /** @@ -7,8 +8,14 @@ * *

{@code page}는 1부터 시작한다(1-based). {@link Page#getNumber()}는 Spring Data 내부 규약대로 0-based라 여기서 +1 * 해서 응답 규약을 요청 파라미터(`spring.data.web.pageable.one-indexed-parameters=true`)와 맞춘다. + * + *

필드에 {@code example}을 주지 않으면 Swagger UI가 int/long 기본 샘플 값(2^30, 2^53-1 등 의미 없는 큰 수)을 채워 넣으므로 실제 + * 값처럼 보이는 예시를 명시한다. */ -public record PageMeta(int page, long totalCount, int totalPages) { +public record PageMeta( + @Schema(description = "현재 페이지 번호(1부터 시작)", example = "1") int page, + @Schema(description = "전체 데이터 개수", example = "42") long totalCount, + @Schema(description = "전체 페이지 수", example = "5") int totalPages) { public static PageMeta from(Page page) { return new PageMeta(page.getNumber() + 1, page.getTotalElements(), page.getTotalPages()); } From d1d5d57221816a040b3f4bca5287b341c2c3b2d4 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 17:29:14 +0900 Subject: [PATCH 90/93] =?UTF-8?q?fix:=20PolicyMapper=EC=97=90=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EA=B8=B8=EC=9D=B4=20=EC=83=81=ED=95=9C=20=EC=A0=88?= =?UTF-8?q?=EB=8B=A8=20=EA=B0=80=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부 API 원본이 VARCHAR 컬럼 상한을 넘으면 그대로 INSERT를 시도하다 Data truncation으로 정책 1건 전체가 저장 실패하던 문제를 고쳤다(#207 사례). PolicyMapper 클래스 javadoc의 실패 격리 원칙(필드 하나 파싱 실패는 그 필드만 null 처리)을 길이 초과에도 동일하게 적용한다 — 상한 초과 시 경고 로그 (plcyNo·필드명·실제 길이·상한)를 남기고 잘라서 저장하며 예외를 던지지 않는다. - policy_no, title, description, keywords, category, middle_category, organization_name, job_codes, school_codes, 각종 code, URL 3종, application_period_raw, operating_institution_name 등 Policy 엔티티의 @Column(length = ...) VARCHAR 필드에 적용. TEXT/LONGTEXT 컬럼은 제외. - PolicyMapperLengthLimitsTest: 매퍼의 길이 상한 상수가 Policy @Column(length)와 어긋나면 리플렉션으로 잡아내 드리프트를 방지. - PolicyMapperTest: 길이 초과 시 실제로 잘려서 저장되는지, 경계값(정확히 상한)은 잘리지 않는지, 경고 로그에 plcyNo·필드·길이·상한이 담기는지 검증. - PolicySyncServiceTest: 기존 실패율 테스트가 title 길이 초과를 DB 저장 실패 트리거로 썼는데 이제는 절단되어 성공하므로, plcyNm 누락(title NOT NULL 위반)으로 트리거를 바꿔 동일한 실패율 계산·기록 시나리오를 유지했다. Closes #208 --- .../youthpick/sync/service/PolicyMapper.java | 150 +++++++++++++++--- .../service/PolicyMapperLengthLimitsTest.java | 72 +++++++++ .../sync/service/PolicyMapperTest.java | 130 +++++++++++++++ .../sync/service/PolicySyncServiceTest.java | 8 +- 4 files changed, 336 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/bop/youthpick/sync/service/PolicyMapperLengthLimitsTest.java diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java index e35bc39..660bfe1 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java @@ -27,6 +27,10 @@ * *

파싱 실패는 예외로 안 터뜨린다 — 해당 필드만 null + 경고 로그(plcyNo, 필드, 원문). 필드 하나 깨졌다고 정책 1건 전체(나머지 40여 필드)를 버리는 * 게 더 큰 손실이기 때문(설계 §5 실패 격리: 전처리 실패 단위 = 필드). + * + *

컬럼 길이 초과도 같은 원칙이다 — 예외 대신 절단 + 경고 로그(plcyNo, 필드, 실제 길이, 상한)로 처리한다(#208). 길이 상한 상수는 {@link + * com.bop.youthpick.policy.entity.Policy}의 {@code @Column(length = ...)}와 반드시 일치해야 하며, + * PolicyMapperLengthLimitsTest가 리플렉션으로 드리프트를 검증한다. */ @Component @RequiredArgsConstructor @@ -37,6 +41,32 @@ public class PolicyMapper { /** aplyPrdSeCd 상시 코드 — 신청기간 파싱 대상 아님 */ private static final String ALWAYS_OPEN_CODE = "0057002"; + /** + * 컬럼 길이 상한 — {@link com.bop.youthpick.policy.entity.Policy}의 {@code @Column(length = ...)}와 반드시 + * 같은 값이어야 한다(PolicyMapperLengthLimitsTest가 드리프트를 검증). TEXT/LONGTEXT 컬럼(길이 제한 없음)은 대상 아님. + */ + static final int POLICY_NO_MAX_LENGTH = 32; + + static final int TITLE_MAX_LENGTH = 300; + static final int DESCRIPTION_MAX_LENGTH = 2000; + static final int KEYWORDS_MAX_LENGTH = 500; + static final int CATEGORY_MAX_LENGTH = 64; + static final int MIDDLE_CATEGORY_MAX_LENGTH = 64; + static final int ORGANIZATION_NAME_MAX_LENGTH = 255; + static final int JOB_CODES_MAX_LENGTH = 255; + static final int SCHOOL_CODES_MAX_LENGTH = 255; + static final int INCOME_CONDITION_CODE_MAX_LENGTH = 16; + static final int MARITAL_STATUS_CODE_MAX_LENGTH = 16; + static final int MAJOR_CODES_MAX_LENGTH = 255; + static final int SPECIALIZATION_CODES_MAX_LENGTH = 255; + static final int APPLICATION_PERIOD_TYPE_MAX_LENGTH = 16; + static final int APPLICATION_PERIOD_RAW_MAX_LENGTH = 255; + static final int APPLICATION_URL_MAX_LENGTH = 1000; + static final int REFERENCE_URL_MAX_LENGTH = 1000; + static final int AGE_LIMIT_FLAG_MAX_LENGTH = 4; + static final int OPERATING_INSTITUTION_NAME_MAX_LENGTH = 255; + static final int APPROVAL_STATUS_CODE_MAX_LENGTH = 16; + /** * 대분류(lclsfNm) 표준 5분류 매핑 — 키는 가운뎃점(·・・) 제거 기준 (#80). 원본에는 반각점(U+FF65) 구분자, 콤마 다중값('일자리,일자리'), * 구명칭('참여권리')이 섞여 있다. 기존 데이터는 V8 마이그레이션이 같은 규칙으로 정리한다. @@ -60,7 +90,8 @@ public class PolicyMapper { /** 신규/변경 정책 1건 전처리. Policy.create 파라미터 순서 = Policy 필드 선언 순서. */ public Policy toEntity(YouthPolicyItem item) { - String plcyNo = trimToNull(item.plcyNo()); + String rawPlcyNo = trimToNull(item.plcyNo()); + String plcyNo = truncate(rawPlcyNo, "plcyNo", rawPlcyNo, POLICY_NO_MAX_LENGTH); LocalDate businessBegin = toDate(plcyNo, "bizPrdBgngYmd", item.bizPrdBgngYmd()); LocalDate businessEnd = toDate(plcyNo, "bizPrdEndYmd", item.bizPrdEndYmd()); ApplyPeriod applyPeriod = @@ -68,27 +99,63 @@ public Policy toEntity(YouthPolicyItem item) { plcyNo, item.aplyPrdSeCd(), item.aplyYmd(), businessBegin, businessEnd); return Policy.create( plcyNo, - trimToNull(item.plcyNm()), - trimToNull(item.plcyExplnCn()), + truncate(plcyNo, "plcyNm", trimToNull(item.plcyNm()), TITLE_MAX_LENGTH), + truncate( + plcyNo, + "plcyExplnCn", + trimToNull(item.plcyExplnCn()), + DESCRIPTION_MAX_LENGTH), trimToNull(item.plcySprtCn()), - trimToNull(item.plcyKywdNm()), - normalizeCategory(plcyNo, item.lclsfNm()), - trimToNull(item.mclsfNm()), - organizationName(item), + truncate(plcyNo, "plcyKywdNm", trimToNull(item.plcyKywdNm()), KEYWORDS_MAX_LENGTH), + truncate( + plcyNo, + "lclsfNm", + normalizeCategory(plcyNo, item.lclsfNm()), + CATEGORY_MAX_LENGTH), + truncate(plcyNo, "mclsfNm", trimToNull(item.mclsfNm()), MIDDLE_CATEGORY_MAX_LENGTH), + truncate( + plcyNo, + "sprvsnInstCdNm/operInstCdNm", + organizationName(item), + ORGANIZATION_NAME_MAX_LENGTH), toInt(plcyNo, "sprtTrgtMinAge", item.sprtTrgtMinAge()), toInt(plcyNo, "sprtTrgtMaxAge", item.sprtTrgtMaxAge()), - trimToNull(item.jobCd()), - trimToNull(item.schoolCd()), - trimToNull(item.earnCndSeCd()), + truncate(plcyNo, "jobCd", trimToNull(item.jobCd()), JOB_CODES_MAX_LENGTH), + truncate(plcyNo, "schoolCd", trimToNull(item.schoolCd()), SCHOOL_CODES_MAX_LENGTH), + truncate( + plcyNo, + "earnCndSeCd", + trimToNull(item.earnCndSeCd()), + INCOME_CONDITION_CODE_MAX_LENGTH), toInt(plcyNo, "earnMaxAmt", item.earnMaxAmt()), trimToNull(item.earnEtcCn()), - trimToNull(item.mrgSttsCd()), - trimToNull(item.plcyMajorCd()), - trimToNull(item.sbizCd()), + truncate( + plcyNo, + "mrgSttsCd", + trimToNull(item.mrgSttsCd()), + MARITAL_STATUS_CODE_MAX_LENGTH), + truncate( + plcyNo, + "plcyMajorCd", + trimToNull(item.plcyMajorCd()), + MAJOR_CODES_MAX_LENGTH), + truncate( + plcyNo, + "sbizCd", + trimToNull(item.sbizCd()), + SPECIALIZATION_CODES_MAX_LENGTH), trimToNull(item.addAplyQlfcCndCn()), trimToNull(item.ptcpPrpTrgtCn()), - trimToNull(item.aplyPrdSeCd()), - trimToNull(item.aplyYmd()), + truncate( + plcyNo, + "aplyPrdSeCd", + trimToNull(item.aplyPrdSeCd()), + APPLICATION_PERIOD_TYPE_MAX_LENGTH), + truncate( + plcyNo, + "aplyYmd", + trimToNull(item.aplyYmd()), + APPLICATION_PERIOD_RAW_MAX_LENGTH), applyPeriod.start(), applyPeriod.end(), businessBegin, @@ -96,17 +163,41 @@ public Policy toEntity(YouthPolicyItem item) { trimToNull(item.bizPrdEtcCn()), toInt(plcyNo, "sprtSclCnt", item.sprtSclCnt()), toBoolean(item.sprtArvlSeqYn()), - normalizeUrl(plcyNo, "aplyUrlAddr", item.aplyUrlAddr()), - normalizeUrl(plcyNo, "refUrlAddr1", item.refUrlAddr1()), - normalizeUrl(plcyNo, "refUrlAddr2", item.refUrlAddr2()), + truncate( + plcyNo, + "aplyUrlAddr", + normalizeUrl(plcyNo, "aplyUrlAddr", item.aplyUrlAddr()), + APPLICATION_URL_MAX_LENGTH), + truncate( + plcyNo, + "refUrlAddr1", + normalizeUrl(plcyNo, "refUrlAddr1", item.refUrlAddr1()), + REFERENCE_URL_MAX_LENGTH), + truncate( + plcyNo, + "refUrlAddr2", + normalizeUrl(plcyNo, "refUrlAddr2", item.refUrlAddr2()), + REFERENCE_URL_MAX_LENGTH), trimToNull(item.plcyAplyMthdCn()), trimToNull(item.sbmsnDcmntCn()), trimToNull(item.srngMthdCn()), - trimToNull(item.sprtTrgtAgeLmtYn()), + truncate( + plcyNo, + "sprtTrgtAgeLmtYn", + trimToNull(item.sprtTrgtAgeLmtYn()), + AGE_LIMIT_FLAG_MAX_LENGTH), toInt(plcyNo, "earnMinAmt", item.earnMinAmt()), toBoolean(item.sprtSclLmtYn()), - trimToNull(item.operInstCdNm()), - trimToNull(item.plcyAprvSttsCd()), + truncate( + plcyNo, + "operInstCdNm", + trimToNull(item.operInstCdNm()), + OPERATING_INSTITUTION_NAME_MAX_LENGTH), + truncate( + plcyNo, + "plcyAprvSttsCd", + trimToNull(item.plcyAprvSttsCd()), + APPROVAL_STATUS_CODE_MAX_LENGTH), trimToNull(item.etcMttrCn()), toViewCount(plcyNo, item.inqCnt()), toDateTime(plcyNo, "frstRegDt", item.frstRegDt()), @@ -232,6 +323,23 @@ private static String trimToNull(String value) { return trimmed.isEmpty() ? null : trimmed; } + /** + * 컬럼 길이 상한 절단(#208). 초과분은 예외 대신 경고 로그(plcyNo, 필드, 실제 길이, 상한) 후 잘라서 저장한다 — 필드 하나의 길이 초과 때문에 정책 + * 1건(나머지 필드)을 통째로 버리지 않기 위함(클래스 javadoc 실패 격리 원칙과 동일). + */ + private static String truncate(String plcyNo, String field, String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + log.warn( + "정책 {} 필드 {} 길이 초과 — 실제 {}자 > 상한 {}자, 잘라서 저장", + plcyNo, + field, + value.length(), + maxLength); + return value.substring(0, maxLength); + } + private static Integer toInt(String plcyNo, String field, String value) { String v = trimToNull(value); if (v == null) { diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperLengthLimitsTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperLengthLimitsTest.java new file mode 100644 index 0000000..2a1c9c4 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperLengthLimitsTest.java @@ -0,0 +1,72 @@ +package com.bop.youthpick.sync.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bop.youthpick.policy.entity.Policy; +import jakarta.persistence.Column; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * PolicyMapper의 길이 상한 상수와 Policy {@code @Column(length = ...)}가 어긋나면 실패한다(#208). 둘 중 하나만 고치고 다른 쪽을 + * 깜빡하면 여기서 드리프트를 잡아낸다 — 매퍼 상수가 실제 컬럼보다 크면 여전히 Data truncation으로 정책 1건을 통째로 잃고, 작으면 불필요하게 잘려 나간다. + */ +class PolicyMapperLengthLimitsTest { + + private record LengthLimit(String mapperConstant, String entityField) {} + + /** PolicyMapper의 길이 상한 상수 ↔ Policy 엔티티 필드 매핑. 새 길이 가드를 추가하면 이 목록에도 반드시 추가한다. */ + private static final List LIMITS = + List.of( + new LengthLimit("POLICY_NO_MAX_LENGTH", "policyNo"), + new LengthLimit("TITLE_MAX_LENGTH", "title"), + new LengthLimit("DESCRIPTION_MAX_LENGTH", "description"), + new LengthLimit("KEYWORDS_MAX_LENGTH", "keywords"), + new LengthLimit("CATEGORY_MAX_LENGTH", "category"), + new LengthLimit("MIDDLE_CATEGORY_MAX_LENGTH", "middleCategory"), + new LengthLimit("ORGANIZATION_NAME_MAX_LENGTH", "organizationName"), + new LengthLimit("JOB_CODES_MAX_LENGTH", "jobCodes"), + new LengthLimit("SCHOOL_CODES_MAX_LENGTH", "schoolCodes"), + new LengthLimit("INCOME_CONDITION_CODE_MAX_LENGTH", "incomeConditionCode"), + new LengthLimit("MARITAL_STATUS_CODE_MAX_LENGTH", "maritalStatusCode"), + new LengthLimit("MAJOR_CODES_MAX_LENGTH", "majorCodes"), + new LengthLimit("SPECIALIZATION_CODES_MAX_LENGTH", "specializationCodes"), + new LengthLimit("APPLICATION_PERIOD_TYPE_MAX_LENGTH", "applicationPeriodType"), + new LengthLimit("APPLICATION_PERIOD_RAW_MAX_LENGTH", "applicationPeriodRaw"), + new LengthLimit("APPLICATION_URL_MAX_LENGTH", "applicationUrl"), + new LengthLimit("REFERENCE_URL_MAX_LENGTH", "referenceUrl1"), + new LengthLimit("REFERENCE_URL_MAX_LENGTH", "referenceUrl2"), + new LengthLimit("AGE_LIMIT_FLAG_MAX_LENGTH", "ageLimitFlag"), + new LengthLimit( + "OPERATING_INSTITUTION_NAME_MAX_LENGTH", "operatingInstitutionName"), + new LengthLimit("APPROVAL_STATUS_CODE_MAX_LENGTH", "approvalStatusCode")); + + @Test + @DisplayName("X: 매퍼 길이 상한 상수는 Policy @Column(length)와 정확히 일치해야 한다 — 드리프트 방지 (#208)") + void mapperLengthConstantsMatchEntityColumnLength() throws Exception { + for (LengthLimit limit : LIMITS) { + int mapperValue = readMapperConstant(limit.mapperConstant()); + int entityValue = readEntityColumnLength(limit.entityField()); + assertThat(mapperValue) + .as( + "PolicyMapper.%s(%d)는 Policy.%s의 @Column(length=%d)와 일치해야 한다", + limit.mapperConstant(), mapperValue, limit.entityField(), entityValue) + .isEqualTo(entityValue); + } + } + + private static int readMapperConstant(String name) throws Exception { + Field field = PolicyMapper.class.getDeclaredField(name); + field.setAccessible(true); + return field.getInt(null); + } + + private static int readEntityColumnLength(String fieldName) throws Exception { + Field field = Policy.class.getDeclaredField(fieldName); + Column column = field.getAnnotation(Column.class); + assertThat(column).as("Policy.%s 에 @Column 애노테이션이 없다", fieldName).isNotNull(); + return column.length(); + } +} diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java index 049c3ec..db6ae94 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java @@ -15,8 +15,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -300,4 +306,128 @@ void emptyZipCdMeansNoRegions() { assertThat(mapper.resolveRegions("R5", " ", regions)).isEmpty(); assertThat(mapper.resolveRegions("R5", null, regions)).isEmpty(); } + + // ---- 컬럼 길이 상한 절단 (#208) — Data truncation으로 정책 1건이 통째로 버려지는 문제 ---- + + @Test + @DisplayName("E: 컬럼 길이를 넘는 필드는 예외 없이 상한까지 잘려서 저장된다 (#208)") + void truncatesFieldsThatExceedColumnLength() throws IOException { + String longPolicyNo = "N".repeat(PolicyMapper.POLICY_NO_MAX_LENGTH + 5); + String longTitle = "가".repeat(PolicyMapper.TITLE_MAX_LENGTH + 50); + String longDescription = "나".repeat(PolicyMapper.DESCRIPTION_MAX_LENGTH + 10); + String longUrl = + "https://example.com/" + "a".repeat(PolicyMapper.APPLICATION_URL_MAX_LENGTH); + + Policy policy = + mapper.toEntity( + item( + objectMapper.writeValueAsString( + Map.of( + "plcyNo", longPolicyNo, + "plcyNm", longTitle, + "plcyExplnCn", longDescription, + "aplyUrlAddr", longUrl)))); + + assertThat(policy.getPolicyNo()) + .hasSize(PolicyMapper.POLICY_NO_MAX_LENGTH) + .isEqualTo(longPolicyNo.substring(0, PolicyMapper.POLICY_NO_MAX_LENGTH)); + assertThat(policy.getTitle()) + .hasSize(PolicyMapper.TITLE_MAX_LENGTH) + .isEqualTo(longTitle.substring(0, PolicyMapper.TITLE_MAX_LENGTH)); + assertThat(policy.getDescription()) + .hasSize(PolicyMapper.DESCRIPTION_MAX_LENGTH) + .isEqualTo(longDescription.substring(0, PolicyMapper.DESCRIPTION_MAX_LENGTH)); + assertThat(policy.getApplicationUrl()) + .hasSize(PolicyMapper.APPLICATION_URL_MAX_LENGTH) + .isEqualTo(longUrl.substring(0, PolicyMapper.APPLICATION_URL_MAX_LENGTH)); + } + + @Test + @DisplayName("X: 상한과 정확히 같은 길이는 잘리지 않는다 (경계)") + void doesNotTruncateWhenExactlyAtLimit() throws IOException { + String exactTitle = "다".repeat(PolicyMapper.TITLE_MAX_LENGTH); + + Policy policy = + mapper.toEntity( + item( + objectMapper.writeValueAsString( + Map.of("plcyNo", "EXACT1", "plcyNm", exactTitle)))); + + assertThat(policy.getTitle()).hasSize(PolicyMapper.TITLE_MAX_LENGTH).isEqualTo(exactTitle); + } + + @Test + @DisplayName("E: 길이 초과 절단은 경고 로그에 정책번호·필드명·실제 길이·상한을 남긴다 (#208)") + void logsWarningWithPolicyNoFieldLengthAndLimitOnTruncation() throws IOException { + String longTitle = "라".repeat(PolicyMapper.TITLE_MAX_LENGTH + 7); + CapturingAppender appender = CapturingAppender.attachTo(PolicyMapper.class); + try { + mapper.toEntity( + item( + objectMapper.writeValueAsString( + Map.of("plcyNo", "WARN1", "plcyNm", longTitle)))); + + assertThat(appender.warnMessages()) + .anySatisfy( + message -> { + assertThat(message).contains("WARN1"); + assertThat(message).contains("plcyNm"); + assertThat(message).contains(String.valueOf(longTitle.length())); + assertThat(message) + .contains(String.valueOf(PolicyMapper.TITLE_MAX_LENGTH)); + }); + } finally { + appender.detach(); + } + } + + @Test + @DisplayName("X: 길이 상한 내 정상 값은 절단 경고 로그를 남기지 않는다") + void doesNotLogWarningWhenWithinLimit() throws IOException { + CapturingAppender appender = CapturingAppender.attachTo(PolicyMapper.class); + try { + mapper.toEntity(fixtureItem(0)); + + assertThat(appender.warnMessages()).noneMatch(message -> message.contains("길이 초과")); + } finally { + appender.detach(); + } + } + + /** PolicyMapper가 SLF4J로 남기는 WARN 로그를 잡아내는 테스트 전용 Log4j2 Appender. */ + private static final class CapturingAppender extends AbstractAppender { + + private final List messages = new CopyOnWriteArrayList<>(); + private final org.apache.logging.log4j.core.Logger targetLogger; + + private CapturingAppender(org.apache.logging.log4j.core.Logger targetLogger) { + super("PolicyMapperTest-capturing-appender", null, null, false, Property.EMPTY_ARRAY); + this.targetLogger = targetLogger; + } + + static CapturingAppender attachTo(Class loggerClass) { + org.apache.logging.log4j.core.Logger logger = + (org.apache.logging.log4j.core.Logger) LogManager.getLogger(loggerClass); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + return appender; + } + + void detach() { + targetLogger.removeAppender(this); + stop(); + } + + List warnMessages() { + return messages; + } + + @Override + public void append(LogEvent event) { + if (event.getLevel().equals(Level.WARN)) { + messages.add(event.getMessage().getFormattedMessage()); + } + } + } } diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java index b1a16c8..64e77b0 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java @@ -213,15 +213,17 @@ void cleanUp() { @Test void 실패율이_10퍼센트를_넘으면_크래시_없이_실패_사유를_기록한다() throws IOException { - // title(길이 300) 초과로 DB 저장이 실패하는 건을 섞어 실패율 10%를 넘긴다(#203) — + // title(NOT NULL) 없이 저장을 시도해 DB 저장이 실패하는 건을 섞어 실패율 10%를 넘긴다 — + // (#208 이전엔 title 길이(300) 초과로 이 실패를 유발했으나, PolicyMapper가 길이 초과를 절단해 저장하도록 + // 바뀌면서 더는 DB 저장 실패로 이어지지 않는다. plcyNm 누락 → title NOT NULL 위반으로 트리거를 바꿔 같은 + // 실패율 계산·기록 시나리오를 계속 검증한다.) // 실패 메시지 포맷 문자열의 "10%"가 이스케이프 안 돼 있으면 여기서 UnknownFormatConversionException이 터진다. - String tooLongTitle = "가".repeat(301); List items = new java.util.ArrayList<>(); for (int i = 0; i < 8; i++) { items.add(item("{\"plcyNo\":\"P-OK-" + i + "\",\"plcyNm\":\"정상 정책 " + i + "\"}")); } for (int i = 0; i < 2; i++) { - items.add(item("{\"plcyNo\":\"P-BAD-" + i + "\",\"plcyNm\":\"" + tooLongTitle + "\"}")); + items.add(item("{\"plcyNo\":\"P-BAD-" + i + "\"}")); // plcyNm 없음 → title NOT NULL 위반 } when(policyApiClient.fetchAll()).thenReturn(items); From 1b4c43dcd853cb331d1d3090155dd2a33521d2e2 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 17:29:24 +0900 Subject: [PATCH 91/93] =?UTF-8?q?feat:=20=EC=A0=95=EC=B1=85=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D/=EC=83=81=EC=84=B8=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=9E=90=EA=B2=A9=EC=A1=B0=EA=B1=B4=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트 추천 하드필터(buildRecommendations)가 결혼·전공·취업·학력·특화조건으로 부적격 정책을 제외하려면 정책 응답에 해당 코드가 있어야 하는데, PolicyCardResponse/ PolicyDetailResponse에는 이 필드가 없어 프론트 매퍼가 전부 '제한없음'으로 고정되고 있었다. Policy 엔티티는 이미 해당 필드를 갖고 있어 엔티티 변경 없이 두 응답 DTO에 jobCodes/schoolCodes/maritalStatusCode/majorCodes/specializationCodes를 추가하고 from(Policy) 매핑을 갱신했다. PolicyComparisonItemResponse는 여전히 비교표용 원문 필드만 노출하므로 관련 javadoc/문서만 최신화했다. Closes #92 Co-authored-by: Claude Sonnet 5 --- docs/api-spec.md | 2 +- .../policy/dto/PolicyCardResponse.java | 13 +++++++++++++ .../dto/PolicyComparisonItemResponse.java | 5 +++-- .../policy/dto/PolicyDetailResponse.java | 15 ++++++++++++++- .../policy/controller/PolicyControllerTest.java | 10 ++++++++++ .../policy/service/PolicyServiceTest.java | 17 +++++++++++++++++ 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/api-spec.md b/docs/api-spec.md index cc0ae27..4b69256 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -424,7 +424,7 @@ OAuth 인가 코드로 로그인을 완료하고 사용자 정보와 토큰을 ### 응답 필드 -`data`는 비교 대상 정책 배열이다. 각 원소는 `Policy` 엔티티 전체가 아니라 비교에 필요한 필드만 담은 값이며, **정책 상세 조회 응답의 부분집합으로 유지한다** — 같은 정책을 상세로 볼 때 감춰지는 값이 비교로 볼 때만 드러나면 안 되기 때문이다. 따라서 자격 판정용 내부 코드(`jobCodes`, `schoolCodes`, `majorCodes`, `specializationCodes`, `maritalStatusCode`)와 보류 필드, raw payload는 상세와 동일하게 노출하지 않는다. +`data`는 비교 대상 정책 배열이다. 각 원소는 `Policy` 엔티티 전체가 아니라 비교에 필요한 필드만 담은 값이며, **정책 상세 조회 응답의 부분집합으로 유지한다** — 같은 정책을 상세로 볼 때 감춰지는 값이 비교로 볼 때만 드러나면 안 되기 때문이다. 비교표는 자격 원문(추가 자격조건·참여 제한사항 등)만 나란히 보여주면 되므로, 상세 응답이 노출하는 자격 판정용 내부 코드(`jobCodes`, `schoolCodes`, `majorCodes`, `specializationCodes`, `maritalStatusCode` — #92부터 정책 목록/상세 응답에 노출)와 보류 필드, raw payload는 비교 응답에는 포함하지 않는다. | 필드 | 타입 | 내용 | |---|---|---| diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java index ff733a2..6c3dac0 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java @@ -9,6 +9,9 @@ * *

{@code provinces}는 정책이 적용되는 시도명 목록(중복 제거·정렬)이다. 지역 매핑이 없으면 빈 리스트 — "전국/OO 외 N" 같은 표시 문구 조립은 * 프론트 책임이며, 상세({@link PolicyDetailResponse#regions()})와 같은 규칙을 쓴다. + * + *

자격 판정용 내부 코드(jobCodes/schoolCodes/majorCodes/specializationCodes/maritalStatusCode)는 목록 화면에서도 + * 프론트가 추천 하드필터(결혼·전공·신분 등 자격조건으로 부적격 정책 제외)를 적용할 수 있도록 상세와 동일하게 노출한다(#92). */ public record PolicyCardResponse( Long id, @@ -17,6 +20,11 @@ public record PolicyCardResponse( String description, Integer minAge, Integer maxAge, + String jobCodes, + String schoolCodes, + String maritalStatusCode, + String majorCodes, + String specializationCodes, LocalDate applicationEndDate, List provinces) { @@ -28,6 +36,11 @@ public static PolicyCardResponse from(Policy policy, List provinces) { policy.getDescription(), policy.getMinAge(), policy.getMaxAge(), + policy.getJobCodes(), + policy.getSchoolCodes(), + policy.getMaritalStatusCode(), + policy.getMajorCodes(), + policy.getSpecializationCodes(), policy.getApplicationEndDate(), provinces); } diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java index 2f0bf69..1488c6d 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java @@ -8,8 +8,9 @@ * 비교표 한 열(정책 한 건)의 응답. 자격요건 원문 노출용 필드를 나열해 프론트가 항목별로 나란히 비교할 수 있게 한다. * *

필드 구성은 {@link PolicyDetailResponse}의 부분집합으로 유지한다 — 같은 정책을 상세로 볼 때는 감춰지는 값이 비교로 볼 때만 드러나면 안 되기 - * 때문이다. 따라서 자격 판정용 내부 코드(jobCodes/schoolCodes/majorCodes/specializationCodes/maritalStatusCode)와 보류 - * 필드, raw payload는 상세와 동일하게 노출하지 않는다. + * 때문이다. 비교표는 자격 원문(추가 자격조건·참여 제한사항 등)만 나란히 보여주면 되므로, 상세가 노출하는 자격 판정용 내부 코드 + * (jobCodes/schoolCodes/majorCodes/specializationCodes/maritalStatusCode, #92부터 상세에 노출)와 보류 필드, raw + * payload는 비교 응답에는 포함하지 않는다. */ public record PolicyComparisonItemResponse( Long policyId, diff --git a/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java index 310b788..10f0437 100644 --- a/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java @@ -4,7 +4,10 @@ import java.time.LocalDate; import java.util.List; -/** 정책 상세 조회 응답 (유저 대상). 자격 판정용 내부 코드·보류 필드·raw payload는 노출하지 않는다. */ +/** + * 정책 상세 조회 응답 (유저 대상). 자격 판정용 내부 코드(jobCodes/schoolCodes/incomeConditionCode 등)는 프론트가 추천 + * 하드필터(결혼·전공·신분 등 자격조건으로 부적격 정책 제외)를 복구할 수 있도록 노출한다(#92). 보류 필드·raw payload는 계속 노출하지 않는다. + */ public record PolicyDetailResponse( Long id, String policyNo, @@ -17,9 +20,14 @@ public record PolicyDetailResponse( String organizationName, Integer minAge, Integer maxAge, + String jobCodes, + String schoolCodes, String incomeConditionCode, Integer incomeMaxAmount, String incomeEtcContent, + String maritalStatusCode, + String majorCodes, + String specializationCodes, String additionalQualification, String participationRestriction, String applicationPeriodType, @@ -52,9 +60,14 @@ public static PolicyDetailResponse from(Policy policy, List regi policy.getOrganizationName(), policy.getMinAge(), policy.getMaxAge(), + policy.getJobCodes(), + policy.getSchoolCodes(), policy.getIncomeConditionCode(), policy.getIncomeMaxAmount(), policy.getIncomeEtcContent(), + policy.getMaritalStatusCode(), + policy.getMajorCodes(), + policy.getSpecializationCodes(), policy.getAdditionalQualification(), policy.getParticipationRestriction(), policy.getApplicationPeriodType(), diff --git a/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java index 042ff5c..f0ba22b 100644 --- a/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java @@ -48,9 +48,14 @@ class PolicyControllerTest { "국토교통부", 19, 34, + "0013010", + "0043010", null, null, null, + "0055010", + "0066010", + "0077010", null, null, null, @@ -83,6 +88,11 @@ void clearContext() { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.id").value(1)) .andExpect(jsonPath("$.data.title").value("청년 월세 지원")) + .andExpect(jsonPath("$.data.jobCodes").value("0013010")) + .andExpect(jsonPath("$.data.schoolCodes").value("0043010")) + .andExpect(jsonPath("$.data.maritalStatusCode").value("0055010")) + .andExpect(jsonPath("$.data.majorCodes").value("0066010")) + .andExpect(jsonPath("$.data.specializationCodes").value("0077010")) .andExpect(jsonPath("$.data.regions[0].regionCode").value("11680")) .andExpect(jsonPath("$.data.regions[0].provinceName").value("서울특별시")) .andExpect(jsonPath("$.data.regions[0].districtName").value("강남구")); diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index 4dfda4e..4f7cba4 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -75,6 +75,12 @@ void setUp() { assertThat(response.id()).isEqualTo(1L); assertThat(response.title()).isEqualTo("청년 월세 지원"); assertThat(response.regions()).containsExactly(new RegionResponse("11680", "서울특별시", "강남구")); + // #92: 자격 판정용 내부 코드도 상세 응답에 노출해 프론트 추천 하드필터를 복구할 수 있게 한다. + assertThat(response.jobCodes()).isEqualTo("0013010"); + assertThat(response.schoolCodes()).isEqualTo("0043010"); + assertThat(response.maritalStatusCode()).isEqualTo("0055010"); + assertThat(response.majorCodes()).isEqualTo("0066010"); + assertThat(response.specializationCodes()).isEqualTo("0077010"); } @Test @@ -175,6 +181,12 @@ void setUp() { assertThat(card.id()).isEqualTo(1L); assertThat(card.title()).isEqualTo("한 시도 정책"); assertThat(card.category()).isEqualTo("주거"); + // #92: 목록(카드) 응답도 상세와 동일하게 자격 판정용 내부 코드를 노출해 프론트 추천 하드필터를 복구할 수 있게 한다. + assertThat(card.jobCodes()).isEqualTo("0013010"); + assertThat(card.schoolCodes()).isEqualTo("0043010"); + assertThat(card.maritalStatusCode()).isEqualTo("0055010"); + assertThat(card.majorCodes()).isEqualTo("0066010"); + assertThat(card.specializationCodes()).isEqualTo("0077010"); assertThat(page.getContent()) .extracting(PolicyCardResponse::provinces) .containsExactly( @@ -312,6 +324,11 @@ private Policy newPolicy(Long id, String title) { ReflectionTestUtils.setField(policy, "id", id); ReflectionTestUtils.setField(policy, "policyNo", "R2026" + id); ReflectionTestUtils.setField(policy, "title", title); + ReflectionTestUtils.setField(policy, "jobCodes", "0013010"); + ReflectionTestUtils.setField(policy, "schoolCodes", "0043010"); + ReflectionTestUtils.setField(policy, "maritalStatusCode", "0055010"); + ReflectionTestUtils.setField(policy, "majorCodes", "0066010"); + ReflectionTestUtils.setField(policy, "specializationCodes", "0077010"); return policy; } From 7ed93e21ca81c6054ddb0e66582cfc6ce57f3557 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 17:29:35 +0900 Subject: [PATCH 92/93] =?UTF-8?q?fix:=20=EC=8B=A4=ED=8C=A8=EC=9C=A8=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20FAILED=20=EC=9D=B4=EB=A0=A5=EC=97=90=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C/=EB=B3=80=EA=B2=BD/=EB=88=84=EB=9D=BD/?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EA=B1=B4=EC=88=98=EB=A5=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=ED=95=9C=EB=8B=A4=20(#219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolicyBatchHistory.fail(String)이 카운트를 건드리지 않아 실패율 초과로 FAILED 처리된 회차가 관리자 배치 로그 화면에 항상 0/0/0/0으로 보였다. 카운트를 함께 받는 fail(...) 오버로드를 추가하고, PolicySyncService의 실패율 초과 분기에서 실제 집계값을 넘기도록 수정한다. Closes #205 --- .../sync/entity/PolicyBatchHistory.java | 30 ++++++++++++++++--- .../sync/service/PolicySyncService.java | 8 ++++- .../sync/entity/PolicyBatchHistoryTest.java | 27 +++++++++++++++++ .../sync/service/PolicySyncServiceTest.java | 27 +++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java index eaf2dee..fea0945 100644 --- a/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java +++ b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java @@ -93,12 +93,34 @@ public void succeed( this.errorCount = errorCount; } + /** 원인 불명 조기 실패용(예: API fetch 자체가 실패해 아무것도 처리 못 한 경우) — 카운트는 전부 0으로 남는다. */ public void fail(String message) { this.status = BatchStatus.FAILED; this.finishedAt = LocalDateTime.now(); - this.failureMessage = - message != null && message.length() > FAILURE_MESSAGE_MAX - ? message.substring(0, FAILURE_MESSAGE_MAX) - : message; + this.failureMessage = truncate(message); + } + + /** 실패율 초과 등 일부라도 처리된 뒤 실패 처리하는 경우 — succeed와 동일한 카운트를 함께 남긴다. */ + public void fail( + String message, + int newCount, + int updatedCount, + int unchangedCount, + int missingCount, + int errorCount) { + this.status = BatchStatus.FAILED; + this.finishedAt = LocalDateTime.now(); + this.failureMessage = truncate(message); + this.newCount = newCount; + this.updatedCount = updatedCount; + this.unchangedCount = unchangedCount; + this.missingCount = missingCount; + this.errorCount = errorCount; + } + + private static String truncate(String message) { + return message != null && message.length() > FAILURE_MESSAGE_MAX + ? message.substring(0, FAILURE_MESSAGE_MAX) + : message; } } diff --git a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java index 9566cfa..444d40f 100644 --- a/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java @@ -106,7 +106,13 @@ private PolicyBatchHistory doRunFullSync() { "정책 수집 실패율 10%% 초과 — 에러 건수: %d/%d (%.2f%%)" .formatted( writeResult.errorCount(), totalProcessed, errorRate * 100); - history.fail(errorMsg); + history.fail( + errorMsg, + writeResult.newCount(), + writeResult.updatedCount(), + plan.unchangedCount(), + missingMarked, + writeResult.errorCount()); log.error("정책 수집 완료되었으나 실패율 기준 초과로 작업 실패 처리함: {}", errorMsg); } else { history.succeed( diff --git a/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java b/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java index 7d08b45..a80c9ce 100644 --- a/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java +++ b/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java @@ -63,4 +63,31 @@ class PolicyBatchHistoryTest { assertThat(history.getFailureMessage()).hasSize(1000); } + + @Test + void 카운트를_포함한_fail은_FAILED_상태와_함께_신규_변경_유지_누락_실패_건수를_기록한다() { + PolicyBatchHistory history = PolicyBatchHistory.request(BatchMode.FULL); + history.start(); + + history.fail("정책 수집 실패율 10% 초과 — 에러 건수: 12/12 (100.00%)", 0, 0, 0, 0, 12); + + assertThat(history.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(history.getFinishedAt()).isNotNull(); + assertThat(history.getFailureMessage()).contains("12/12"); + assertThat(history.getNewCount()).isZero(); + assertThat(history.getUpdatedCount()).isZero(); + assertThat(history.getUnchangedCount()).isZero(); + assertThat(history.getMissingCount()).isZero(); + assertThat(history.getErrorCount()).isEqualTo(12); + } + + @Test + void 카운트를_포함한_fail도_실패_메시지가_컬럼_한도를_넘으면_잘라서_기록한다() { + PolicyBatchHistory history = PolicyBatchHistory.request(BatchMode.FULL); + + history.fail("가".repeat(2000), 1, 2, 3, 4, 5); + + assertThat(history.getFailureMessage()).hasSize(1000); + assertThat(history.getErrorCount()).isEqualTo(5); + } } diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java index 64e77b0..6e40c0d 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java @@ -231,6 +231,33 @@ void cleanUp() { assertThat(result.getStatus()).isEqualTo(BatchStatus.FAILED); assertThat(result.getFailureMessage()).contains("정책 수집 실패율 10% 초과").contains("2/10"); + // #205: FAILED 처리돼도 실제 처리된 신규/변경/유지/누락/실패 건수는 버려지지 않고 그대로 남아야 한다. + assertThat(result.getNewCount()).isEqualTo(8); + assertThat(result.getUpdatedCount()).isZero(); + assertThat(result.getUnchangedCount()).isZero(); + assertThat(result.getMissingCount()).isZero(); + assertThat(result.getErrorCount()).isEqualTo(2); + } + + @Test + void 실패율_초과로_전량_실패해도_실패_건수가_이력에_그대로_기록된다() throws IOException { + // #205 재현: 모든 건이 저장 실패(예: 컬럼 길이 초과)해도 카운트가 0으로 버려지면 안 된다. + String tooLongTitle = "가".repeat(301); + List items = new java.util.ArrayList<>(); + for (int i = 0; i < 12; i++) { + items.add(item("{\"plcyNo\":\"P-BAD-" + i + "\",\"plcyNm\":\"" + tooLongTitle + "\"}")); + } + when(policyApiClient.fetchAll()).thenReturn(items); + + PolicyBatchHistory result = policySyncService.runFullSync(); + + assertThat(result.getStatus()).isEqualTo(BatchStatus.FAILED); + assertThat(result.getFailureMessage()).contains("12/12"); + assertThat(result.getNewCount()).isZero(); + assertThat(result.getUpdatedCount()).isZero(); + assertThat(result.getUnchangedCount()).isZero(); + assertThat(result.getMissingCount()).isZero(); + assertThat(result.getErrorCount()).isEqualTo(12); } @Test From 6ab54ccc340f32e64fe47bc3a5ded6701f4ec450 Mon Sep 17 00:00:00 2001 From: Jungwon Sohn Date: Fri, 24 Jul 2026 17:43:22 +0900 Subject: [PATCH 93/93] =?UTF-8?q?fix:=20PolicySyncServiceTest=20=EC=A0=84?= =?UTF-8?q?=EB=9F=89=EC=8B=A4=ED=8C=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B8=EC=9D=B4=EC=A0=88=EB=8B=A8=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=EC=99=80=20=EC=B6=A9=EB=8F=8C=ED=95=B4=20CI=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #219와 #221이 독립적으로 작업되며 PolicySyncServiceTest에서 같은 "길이 초과로 DB 저장 실패" 트리거를 썼는데, #221이 그 실패 자체를 없애버려(초과분을 잘라 저장) #219가 추가한 전량실패 테스트가 dev에서 깨졌다. plcyNm 누락(title NOT NULL 위반) 방식으로 트리거를 통일한다. Closes #223 --- .../bop/youthpick/sync/service/PolicySyncServiceTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java index 6e40c0d..3d3f6c6 100644 --- a/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java @@ -241,11 +241,13 @@ void cleanUp() { @Test void 실패율_초과로_전량_실패해도_실패_건수가_이력에_그대로_기록된다() throws IOException { - // #205 재현: 모든 건이 저장 실패(예: 컬럼 길이 초과)해도 카운트가 0으로 버려지면 안 된다. - String tooLongTitle = "가".repeat(301); + // #205 재현: 모든 건이 저장 실패해도 카운트가 0으로 버려지면 안 된다. + // (#208 이전엔 title 길이(300) 초과로 이 실패를 유발했으나, PolicyMapper가 길이 초과를 절단해 저장하도록 + // 바뀌면서 더는 DB 저장 실패로 이어지지 않는다. plcyNm 누락 → title NOT NULL 위반으로 트리거를 바꿔 같은 + // 전량 실패 시나리오를 계속 검증한다.) List items = new java.util.ArrayList<>(); for (int i = 0; i < 12; i++) { - items.add(item("{\"plcyNo\":\"P-BAD-" + i + "\",\"plcyNm\":\"" + tooLongTitle + "\"}")); + items.add(item("{\"plcyNo\":\"P-BAD-" + i + "\"}")); // plcyNm 없음 → title NOT NULL 위반 } when(policyApiClient.fetchAll()).thenReturn(items);