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..0c94d13 --- /dev/null +++ b/.claude/rules/api-design.md @@ -0,0 +1,65 @@ +--- +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`, `totalPages`)는 **`data`가 아니라 `meta`(`PageMeta`)에** 담는다. +- `meta`는 Swagger(OpenAPI) 문서에 필드 스키마가 드러나도록 `Map`이 아니라 타입 있는 `PageMeta` record를 사용한다. 새 부가정보가 필요하면 `Map`으로 돌아가지 말고 `PageMeta`에 필드를 추가하거나 전용 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(), page); +} +``` + +- 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..798236f --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,41 @@ +--- +paths: + - "src/main/java/**" +--- + +# 패키지 구조 + +도메인 우선 + 단순 구조를 유지한다. 패키지 루트는 `com.bop.youthpick`. + +```text +com.bop.youthpick +├── global +│ ├── 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 + ├── 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`에는 여러 도메인이 공유하는 공통 응답·설정·예외 처리만 둔다. +- 관리자(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에 둔다. + +**금지 구조** — 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..e60df58 --- /dev/null +++ b/.claude/rules/auth-security.md @@ -0,0 +1,22 @@ +--- +paths: + - "src/main/java/**" + - "src/main/resources/**" + - ".env.example" +--- + +# 인증 / JWT / Redis / Secret + +Spring Security **STATELESS** + **JWT(access/refresh) 인증** + OAuth 소셜 로그인(Google / Naver / Kakao) 구조다. 서버 세션(`HttpSession`)은 쓰지 않는다. refresh token만 예외적으로 HttpOnly 쿠키를 쓴다(아래 참고) — 그 외 새 세션/쿠키 기반 인증을 추가하지 않는다. + +- 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에 저장된 값과 일치하는지(`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`로 내려준다. +- 인가 규칙(경로별 권한)은 `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/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/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..5607270 --- /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) | +| `B` | 커뮤니티(board) | | `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..6885d39 --- /dev/null +++ b/.claude/rules/infra.md @@ -0,0 +1,44 @@ +--- +paths: + - "docker-compose.yml" + - "src/main/resources/**" + - ".env.example" + - "build.gradle" +--- + +# 실행 / Docker / API 수동 검증 + +- 평소 개발은 로컬 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 minio # 인프라만 기동 (앱은 IDE/Gradle로 실행) +docker compose up -d --build # 인프라 + 백엔드 + 프론트 전체 기동 +docker compose ps +docker compose logs -f backend +docker compose down # 중지 (볼륨 보존) +docker compose down -v # 중지 + 볼륨 삭제(초기화) +``` + +- 로컬 앱 실행은 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 검증 결과에 명시한다. + +## 단일 서버 도메인 배포 (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`를 사용한다: + +```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..d34299c --- /dev/null +++ b/.claude/rules/workflow.md @@ -0,0 +1,37 @@ +# 작업 흐름 / 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`)를 통과시킨 뒤에만 완료를 주장한다. +- Java 코드를 작성/수정했다면 `./gradlew spotlessApply`로 포맷을 맞춘 뒤, `./gradlew spotlessCheck`를 별도로 실행해 통과를 확인한다(`test`/`build`는 이 검사를 포함하지 않는다. 코드 포맷 기준은 [`code-style.md`](./code-style.md)). +- 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..bd6e163 --- /dev/null +++ b/.claude/skills/security-review/SKILL.md @@ -0,0 +1,24 @@ +--- +name: security-review +description: YouthPick 백엔드 변경분의 보안 점검. 인증/JWT/OAuth, secret 노출, 입력 검증, 인가, 민감정보 응답 노출을 검토한다. PR 전 또는 auth/user/secret 관련 코드를 만졌을 때 사용. +--- + +# Security Review + +YouthPick 백엔드(STATELESS + JWT access/refresh 인증 + OAuth 소셜 로그인, refresh token은 Redis TTL 저장) 변경분을 보안 관점에서 점검한다. + +## 절차 + +1. 리뷰 범위를 정한다. 기본은 현재 브랜치의 diff: + ```bash + git diff --merge-base main + ``` +2. [`checklist.md`](./checklist.md)의 각 항목을 diff에 대입해 점검한다. +3. 발견 항목을 **심각도(critical / high / medium / low)** 순으로 정리한다. 각 항목에 `파일:라인`, 무엇이 문제인지, 어떻게 고칠지를 적는다. +4. 확실하지 않은 지적은 "가능성"으로 표시하고 근거를 남긴다. 추측을 확정처럼 말하지 않는다. + +## 원칙 + +- 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 new file mode 100644 index 0000000..40dd46a --- /dev/null +++ b/.claude/skills/security-review/checklist.md @@ -0,0 +1,35 @@ +# 보안 점검 체크리스트 (YouthPick 백엔드) + +## 인증 / JWT / OAuth + +- [ ] 미인증 접근이 리다이렉트가 아니라 `RestAuthenticationEntryPoint`에서 JSON + `A001 UNAUTHORIZED`로 내려가는가. +- [ ] `SecurityConfig`가 `SessionCreationPolicy.STATELESS`를 유지하는가. 세션/쿠키 기반 인증 코드가 새로 생기지 않았는가. +- [ ] 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()`로 누구나 접근 가능). +- [ ] 로그인/회원가입에서 비밀번호가 평문 저장·로그되지 않는가. 인증 실패 메시지가 아이디 존재 여부를 흘리지 않는가(`A002`로 통일). +- [ ] OAuth 콜백에서 state 검증이 있는가(`OAuthStateStore` 1회 소비). 외부에서 받은 값을 검증 없이 신뢰하지 않는가. + +## Secret / 민감정보 + +- [ ] OAuth client id/secret, `JWT_SECRET` 등이 하드코딩되지 않고 환경변수로 주입되는가. 새 secret은 `.env.example`에 placeholder만 추가됐는가. +- [ ] secret / access·refresh 토큰 값이 로그(`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/.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 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/.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/.env.example b/.env.example index 6e71343..34a9703 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 @@ -19,19 +19,56 @@ 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=update +JPA_DDL_AUTO=validate # ===== Redis ===== 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 + +# ===== 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= + # ===== 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= 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/.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 # 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/.gitignore b/.gitignore index cf673a5..b074398 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,13 @@ out/ .env .env.* !.env.example + +### 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/.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..be5af24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# 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 / Redis / Validation / Actuator +- DB: 로컬·배포 MySQL + Flyway(`db/migration`, JPA는 validate만) / 테스트만 H2 in-memory(MySQL 모드) — 프로파일/환경변수로 전환(기본 프로파일 `local`) +- 인증: Spring Security STATELESS + JWT(access/refresh) + OAuth 소셜 로그인(Google/Naver/Kakao). refresh token은 Redis에 TTL로 저장 +- 패키지 루트: `com.bop.youthpick` + +## 검증 명령 + +완료를 주장하기 전에 테스트를 실행해 통과를 확인한다. Java toolchain이 JDK 21을 자동 프로비저닝하므로 부트스트랩 JVM만 있으면 된다. + +```bash +# macOS / Linux / CI +./gradlew test +``` + +```powershell +# Windows (PowerShell) +.\gradlew.bat test +``` + +로컬 인프라(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 (요약) + +- `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/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` | +| [`.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) | 인증(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 | + +문서 컨벤션과 전체 문서 목록은 [`docs/README.md`](./docs/README.md)를 본다. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3561dca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +@AGENTS.md + +## Claude Code 안내 + +- 상세 규칙은 `.claude/rules/`에서 자동 로드된다. 항상 로드되는 규칙(`workflow.md`)과 경로 매칭 시 로드되는 규칙(`paths` frontmatter)으로 나뉜다. 규칙을 바꾸면 `AGENTS.md`의 규칙 문서 맵도 함께 갱신한다. +- 완료를 주장하기 전에 `./gradlew test`를 실행해 통과를 확인한다. +- 머신별 환경 메모(JDK 경로 등)는 커밋되지 않는 `CLAUDE.local.md`에 둔다. 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/README.md b/README.md index 4029aba..fbaf7db 100644 Binary files a/README.md and b/README.md differ diff --git a/build.gradle b/build.gradle index f2729e9..399ab4b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,8 @@ 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' + id 'com.diffplug.spotless' version '7.0.2' } group = 'com.bop' @@ -18,22 +19,36 @@ 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' 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.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-webmvc' + 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' + 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' + runtimeOnly 'org.flywaydb:flyway-mysql' 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' + // 커스텀 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' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' @@ -42,3 +57,13 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +spotless { + java { + target 'src/**/*.java' + googleJavaFormat().aosp() + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 3479de3..07e23c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,38 @@ -# 로컬 개발용 인프라만 띄운다 (MySQL + Redis). -# backend/front 는 컨테이너화하지 않고 각자 로컬(IDE)에서 실행한다. +# 인프라(MySQL + Redis + MinIO) + 백엔드(Spring Boot) + 프론트(React, nginx) 를 한 번에 띄운다. # -# 사용법: docker-compose.yml 파일과 같은 레벨의 패키지로 가서 아래 명령어 실행. -# docker compose up -d # .env 를 자동으로 읽어 MySQL/Redis 기동 -# 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: -# 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 @@ -44,6 +47,59 @@ 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 + + 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-mysql-data: youthpick-redis-data: + youthpick-minio-data: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9bda977 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,61 @@ +# YouthPick 문서 (docs) + +`youth-pick` 백엔드의 문서 안내다. 코드/문서 작업 전에 관련 문서를 먼저 읽는다. + +## 규칙 문서 위치 + +개발 규칙·컨벤션의 단일 출처(SSOT)는 [`.claude/rules/`](../.claude/rules)다. 주제별 파일로 나뉘어 있고, Claude Code 등 코딩 에이전트가 자동 로드한다. 전체 맵은 [`AGENTS.md`](../AGENTS.md#규칙-문서-맵)를 본다. + +| 문서 | 내용 | +|------|------| +| [`../.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 수동 검증 | + +## 설계/명세 문서 + +| 문서 | 내용 | +|------|------| +| [`api-spec.md`](./api-spec.md) | 프론트 연동 기준 API 명세(엔드포인트, 메서드, 권한, 파라미터) | + +## 진입점 문서 (레포 루트) + +| 문서 | 내용 | +|------|------| +| [`../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/`) + +| 파일 | 내용 | +|------|------| +| [`../.github/ISSUE_TEMPLATE/task.md`](../.github/ISSUE_TEMPLATE/task.md) | 작업 이슈 템플릿 (배경 / 작업 범위 / 검증) | +| [`../.github/PULL_REQUEST_TEMPLATE.md`](../.github/PULL_REQUEST_TEMPLATE.md) | PR 템플릿 (변경 내용 / 검증 / 연결 이슈) | + +## 문서 컨벤션 + +- **위치**: 규칙·컨벤션은 `.claude/rules/`(주제별 1파일). 에이전트 진입점(`CLAUDE.md`, `AGENTS.md`)은 레포 루트. 설계·명세 등 그 외 문서는 `docs/` 아래. +- **파일명**: 소문자 + 하이픈(`kebab-case`), 확장자 `.md`. +- **언어**: 본문은 한국어. 코드 식별자/명령/예약어는 원문 그대로. +- **단일 출처(SSOT)**: 같은 규칙을 여러 문서에 복붙하지 않는다. 상세는 한 문서에 두고 나머지는 링크한다. +- **갱신**: 규칙을 바꾸면 `AGENTS.md`의 요약·규칙 문서 맵과 이 목록도 함께 갱신한다. +- **경로 스코프**: `.claude/rules/` 파일은 필요하면 YAML frontmatter의 `paths`(glob)로 적용 범위를 제한한다. `paths`가 없으면 항상 로드된다. + +## 앞으로 추가하면 좋은 문서 (제안) + +- `error-codes.md` — `ErrorCode` 카탈로그와 프론트 메시지 매핑 +- `oauth-setup.md` — Google/Naver/Kakao OAuth 앱 등록 및 환경변수 세팅 가이드 +- `local-setup.md` — 로컬 실행(H2/Redis/compose) 온보딩 가이드 diff --git a/docs/api-spec-db.md b/docs/api-spec-db.md new file mode 100644 index 0000000..c7de44b --- /dev/null +++ b/docs/api-spec-db.md @@ -0,0 +1,41 @@ +# API 명세 DB + +> 기획안의 기능을 기준으로 작성한 REST API 명세입니다. + +| 이름 | 메서드 | 경로 | 권한 | 기능 | 상태 | 요청 | 응답 | +|---|---|---|---|---|---|---|---| +| 정책 수집 실행 | POST | `/api/v1/admin/policy-sync-jobs` | 관리자 | SYNC | 확정 | | | +| 로그아웃 | POST | `/api/v1/auth/logout` | 회원 | AUTH | 확정 | | | +| 관심정책 등록 | 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}` (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 | +| 맞춤정책 조회 | GET | `/api/v1/me/recommended-policies` | 회원 | REC | 확정 | | | +| 회원 탈퇴 | DELETE | `/api/v1/auth/me` | 회원 | AUTH | 확정 | | | +| 정책 상세 조회 | GET | `/api/v1/policies/{policyId}` | 비회원 | SEARCH | 확정 | | | +| 읽음 상태 목록 조회 | 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 | 확정 | | | + +## 정책 채팅 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 new file mode 100644 index 0000000..4b69256 --- /dev/null +++ b/docs/api-spec.md @@ -0,0 +1,512 @@ +# 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-application-checklists` | +| 정책 배치 이력 엔티티 | `/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/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 | +| 신청관리 | 상태 변경 | `PATCH` | `/api/v1/policy-applications/{id}/status` | 회원 | path: id. query: status(INTERESTED\|PREPARING\|APPLIED\|COMPLETED) | +| 신청관리 | 메모 수정 | `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 | +| 신청관리 | 체크리스트 체크 | `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-read-states` | 회원 | query: policyIds 반복 | +| 읽음 상태 | 정책 읽음 처리 | `PUT` | `/api/v1/policy-read-states/{policyId}` | 회원 | path: policyId | +| 정책 비교 | 정책 비교 조회 | `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` | 관리자 | 없음 | +| 관리자/운영 | 운영 지표 조회 | `GET` | `/api/v1/admin/metrics` | 관리자 | 없음 | + +## 인증 + +### 로그아웃 + +현재 로그인 세션을 종료하고 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 선택 | + +### 정책 상세 조회 + +단일 정책의 상세 정보를 조회한다. 삭제·숨김 처리된 정책은 404(`P001`)로 응답한다. 로그인 사용자(access token 포함 요청)가 조회하면 해당 정책이 최근 본 정책으로 기록된다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policies/{policyId}` | +| 권한 | 비회원 | +| 파라미터 | 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는 거부한다. + +## 맞춤 추천 + +### 맞춤정책 조회 + +로그인 사용자의 프로필과 조건을 바탕으로 맞춤 정책을 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/recommended-policies` | +| 권한 | 회원 | +| 파라미터 | 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-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. body: memo (필수, 최대 2000자) | + +### 마감일 수정 + +신청관리 항목의 마감일(endAt)을 수정한다. endAt을 생략하면 필수값 오류가 아니라 마감일 초기화(clear)로 처리한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `PATCH` | +| 경로 | `/api/v1/policy-applications/{id}/end-at` | +| 권한 | 회원 | +| 파라미터 | path: id. body: 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-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 | + +### 체크리스트 삭제 + +체크리스트 항목을 삭제(soft-delete)한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `DELETE` | +| 경로 | `/api/v1/policy-application-checklists/{id}` | +| 권한 | 회원 | +| 파라미터 | path: id | + +## 최근 본 정책 + +### 최근 본 정책 목록 + +로그인 사용자가 최근에 상세 조회한 정책 목록을 마지막 조회 시각 내림차순으로 조회한다. 같은 정책을 다시 보면 기록이 늘어나지 않고 조회 시각만 갱신된다. 삭제·숨김 처리된 정책은 목록에서 제외된다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `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`) + 식별자(`comparisonId`) 재조회 구조가 아니라 쿼리 파라미터를 받는 **단일 `GET`**이다. 저장하는 것이 없으므로 `POST`는 만들 리소스가 없고, 식별자 역시 요청에 담긴 `policyIds`를 그대로 다시 표현한 값이라 정보를 더하지 않는다. 공유가 필요하면 이 요청 URL 자체가 공유 링크가 된다. + +응답은 항상 조회 시점의 최신 정책 정보이며, 요청에 담긴 `policyIds` 순서를 그대로 보존한다(비교표 열 순서 = 사용자가 고른 순서). + +### 정책 비교 조회 + +여러 정책을 나란히 비교할 수 있도록 항목별 값을 함께 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `GET` | +| 경로 | `/api/v1/policy-comparisons` | +| 권한 | 비회원 | +| 파라미터 | 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` — #92부터 정책 목록/상세 응답에 노출)와 보류 필드, 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 | 시군구명 | + +## 정책 동기화 + +### 정책 수집 이력 목록 + +관리자용 정책 동기화 작업 이력 목록을 조회한다. + +| 항목 | 내용 | +|---|---| +| 메서드 | `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` | +| 권한 | 관리자 | +| 파라미터 | 없음 | + +## 기존/보류 명세 + +아래 항목은 Notion DB에 유지된 기존 데이터다. 현재 프론트 필요 API 경로 변경 작업의 직접 대상이 아니므로 별도 확인 후 사용한다. + +| 기능 | 이름 | 메서드 | 경로 | 권한 | 파라미터 | +|---|---|---|---|---|---| +| 온보딩/프로필 | 프로필 삭제 | `DELETE` | `/api/v1/me/profile` | 회원 | 없음 | +| 정책 동기화 | 정책 수집 상세 | `GET` | `/api/v1/admin/policy-sync-jobs/{jobId}` | 관리자 | path: jobId | +| 관리자/운영 | 헬스 체크 | `GET` | `/api/v1/health` | 공통 | 없음 | + +## 백엔드 구현 메모 + +사용자별 데이터는 JWT에서 얻은 사용자 식별자를 서비스 계층에서 사용한다. URL 경로는 `user-profiles`, `policy-applications`, `policy-read-states`처럼 엔티티 리소스를 직접 드러내고, 컨트롤러 내부에서 인증 principal과 매핑한다. + +`policy_applications`는 기존 즐겨찾기 개념을 흡수한 신청관리 엔티티다. 관심정책 등록·해제는 별도 `favorites` 리소스가 아니라 `policy-applications`의 생성/삭제와 상태 변경(`status`)으로 구현되어 있다. 신청관리 하위 체크리스트는 `policy_application_checklists` 엔티티로 별도 관리하며, 신청관리 삭제·재등록(soft-delete → reactivate) 시 체크리스트도 함께 초기화된다. + +`policy-batch-histories`는 정책 수집 작업 이력을 나타낸다. 수집 실행은 같은 컬렉션에 작업 요청을 생성하는 `POST /api/v1/policy-batch-histories`로 해석한다. 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 @@ + + + + + + diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 0000000..181c4ba --- /dev/null +++ b/docs/schema.sql @@ -0,0 +1,328 @@ +-- ============================================================ +-- 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) 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 '특화조건 콤마목록 (선택)', + 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', + 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. policy_application_checklists — 신청관리 체크리스트 +-- p_id → application_id, status TINYINT → is_checked +-- ------------------------------------------------------------ +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 — 게시판 (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. application_logs — 앱 에러/요청 로그 (ERDCloud `로그`) +-- VARCHAR 길이 누락 정정, user_id NULL 허용(비로그인/배치), +-- FK 없음 — 로그는 유저 삭제와 무관하게 보존 +-- V4 마이그레이션에서 app_logs → application_logs로 개명(ERD 물리명 기준) +-- ------------------------------------------------------------ +CREATE TABLE application_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_application_logs_created (created_at) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT '앱 로그 (기한 지나면 삭제)'; + +-- ------------------------------------------------------------ +-- 13. search_histories — 검색 로그 (ERDCloud `검색용 로그`) +-- 개수 VARCHAR → INT, user_id NULL 허용, 물리명 영어로 +-- ------------------------------------------------------------ +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 '검색 로그 (집계 후 원본 정리)'; + +-- ============================================================ +-- [결정필요] 원본에 있었으나 이 정리본에 미포함: +-- policy_read_states — 읽음 상태 (원본에서도 "팀 논의 중"). +-- 포함하려면 docs/2026-07-03-youthpick-erd.sql §6 참조. +-- ============================================================ 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..9b5cd6f --- /dev/null +++ b/docs/spring-request-processing-and-jwt-auth.md @@ -0,0 +1,252 @@ +# 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); +... +if (!refreshTokenStore.matches(userId, refreshToken)) { // ② Redis 대조 + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); +} +String newAccessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getRole().name()); +``` + +JWT는 서명이 유효하고 만료 전이면 그 자체로는 "위조되지 않았다"만 증명할 뿐, "아직 살아있는(로그아웃되지 않은) 토큰인가"는 증명하지 못한다. 그래서 [`RefreshTokenStore.matches(...)`](../src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java)가 Redis에 저장된 해시와 제출된 토큰의 해시를 대조한다. + +- 일치하면 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`)으로 거부한다(헤더 자체가 없으면 통과 — 브라우저가 아닌 클라이언트를 막지 않기 위해서다). + +## 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/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/bop/youthpick/YouthpickApplication.java b/src/main/java/com/bop/youthpick/YouthpickApplication.java index d74d7de..292bc72 100644 --- a/src/main/java/com/bop/youthpick/YouthpickApplication.java +++ b/src/main/java/com/bop/youthpick/YouthpickApplication.java @@ -1,13 +1,21 @@ 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; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling 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/admin/board/controller/AdminCommunityCommentController.java b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java new file mode 100644 index 0000000..cabb96e --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentController.java @@ -0,0 +1,27 @@ +package com.bop.youthpick.admin.board.controller; + +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 +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 new file mode 100644 index 0000000..6947241 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostController.java @@ -0,0 +1,73 @@ +package com.bop.youthpick.admin.board.controller; + +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 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; +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; + +@Tag(name = "관리자 - 커뮤니티 게시글") +@RestController +@RequestMapping("/api/v1/admin/community-posts") +@RequiredArgsConstructor +@Validated +public class AdminCommunityPostController { + + private final AdminCommunityService adminCommunityService; + + @Operation( + summary = "커뮤니티 게시글 목록 조회", + description = "카테고리, 작성자, 작성일 범위로 커뮤니티 게시글을 검색해 페이지 단위로 조회합니다.") + @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); + } + + @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/board/dto/AdminAttachmentResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java new file mode 100644 index 0000000..c78a27b --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminAttachmentResponse.java @@ -0,0 +1,15 @@ +package com.bop.youthpick.admin.board.dto; + +import com.bop.youthpick.post.entity.Attachment; +import java.time.LocalDateTime; + +public record AdminAttachmentResponse( + 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.getCreatedAt()); + } +} 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 new file mode 100644 index 0000000..ac83ba4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityCommentResponse.java @@ -0,0 +1,24 @@ +package com.bop.youthpick.admin.board.dto; + +import com.bop.youthpick.comment.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/admin/board/dto/AdminCommunityPostResponse.java b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityPostResponse.java new file mode 100644 index 0000000..241c138 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/board/dto/AdminCommunityPostResponse.java @@ -0,0 +1,29 @@ +package com.bop.youthpick.admin.board.dto; + +import com.bop.youthpick.post.entity.Post; +import com.bop.youthpick.post.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/admin/board/repository/AdminCommunityPostSpecifications.java b/src/main/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecifications.java new file mode 100644 index 0000000..3176e67 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecifications.java @@ -0,0 +1,57 @@ +package com.bop.youthpick.admin.board.repository; + +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; +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/admin/board/service/AdminCommunityService.java b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java new file mode 100644 index 0000000..51ac881 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/board/service/AdminCommunityService.java @@ -0,0 +1,97 @@ +package com.bop.youthpick.admin.board.service; + +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.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.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/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java b/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java new file mode 100644 index 0000000..e4c2558 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/controller/AdminAppLogController.java @@ -0,0 +1,51 @@ +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 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; +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; + +@Tag(name = "관리자 - 애플리케이션 로그") +@RestController +@RequestMapping("/api/v1/admin/application-logs") +@RequiredArgsConstructor +@Validated +public class AdminAppLogController { + + private final AdminAppLogService adminAppLogService; + + @Operation( + summary = "애플리케이션 로그 목록 조회", + description = "로그 레벨, 키워드, 기간으로 애플리케이션 로그를 검색해 페이지 단위로 조회합니다.") + @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/admin/log/controller/AdminSearchLogController.java b/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java new file mode 100644 index 0000000..6888255 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/controller/AdminSearchLogController.java @@ -0,0 +1,41 @@ +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 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; +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; + +@Tag(name = "관리자 - 검색 로그") +@RestController +@RequestMapping("/api/v1/admin/search-logs") +@RequiredArgsConstructor +public class AdminSearchLogController { + + private final AdminSearchLogService adminSearchLogService; + + @Operation(summary = "검색 로그 목록 조회", description = "키워드와 기간으로 검색 로그를 조회해 페이지 단위로 조회합니다.") + @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/admin/log/dto/ApplicationLogResponse.java b/src/main/java/com/bop/youthpick/admin/log/dto/ApplicationLogResponse.java new file mode 100644 index 0000000..1355bef --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/dto/ApplicationLogResponse.java @@ -0,0 +1,34 @@ +package com.bop.youthpick.admin.log.dto; + +import com.bop.youthpick.log.entity.ApplicationLog; +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(ApplicationLog 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/admin/log/dto/SearchLogResponse.java b/src/main/java/com/bop/youthpick/admin/log/dto/SearchLogResponse.java new file mode 100644 index 0000000..9cdeb35 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/dto/SearchLogResponse.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.admin.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/admin/log/repository/AdminAppLogSpecifications.java b/src/main/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecifications.java new file mode 100644 index 0000000..cd95052 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecifications.java @@ -0,0 +1,62 @@ +package com.bop.youthpick.admin.log.repository; + +import com.bop.youthpick.log.entity.ApplicationLog; +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/admin/log/repository/AdminSearchLogSpecifications.java b/src/main/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecifications.java new file mode 100644 index 0000000..85f520f --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecifications.java @@ -0,0 +1,53 @@ +package com.bop.youthpick.admin.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/admin/log/service/AdminAppLogService.java b/src/main/java/com/bop/youthpick/admin/log/service/AdminAppLogService.java new file mode 100644 index 0000000..f1ef0c2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/service/AdminAppLogService.java @@ -0,0 +1,32 @@ +package com.bop.youthpick.admin.log.service; + +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; +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/main/java/com/bop/youthpick/admin/log/service/AdminSearchLogService.java b/src/main/java/com/bop/youthpick/admin/log/service/AdminSearchLogService.java new file mode 100644 index 0000000..068f0fc --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/log/service/AdminSearchLogService.java @@ -0,0 +1,26 @@ +package com.bop.youthpick.admin.log.service; + +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; +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/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java new file mode 100644 index 0000000..c96f561 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationController.java @@ -0,0 +1,75 @@ +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 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; +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; + +@Tag(name = "관리자 - 정책 신청관리") +@RestController +@RequestMapping("/api/v1/admin/policy-applications") +@RequiredArgsConstructor +@Validated +public class AdminPolicyApplicationController { + + private final AdminPolicyApplicationService adminPolicyApplicationService; + + @Operation( + summary = "정책 신청 목록 조회", + description = "사용자, 정책명, 상태, 마감일 범위로 정책 신청 목록을 검색하여 페이지 단위로 조회한다.") + @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); + } + + @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, + @Valid @RequestBody AdminPolicyApplicationStatusUpdateRequest request) { + return ApiResponse.ok( + adminPolicyApplicationService.updateStatus(applicationId, request.status())); + } +} 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 new file mode 100644 index 0000000..d22d7a4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminPolicyController.java @@ -0,0 +1,79 @@ +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 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; +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; + +@Tag(name = "관리자 - 정책") +@RestController +@RequestMapping("/api/v1/admin/policies") +@RequiredArgsConstructor +@Validated +public class AdminPolicyController { + + private final AdminPolicyService adminPolicyService; + + @Operation(summary = "정책 목록 조회", description = "카테고리, 노출 상태, 기간으로 정책 목록을 검색하여 페이지 단위로 조회한다.") + @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); + } + + @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, + @Valid @RequestBody AdminPolicyVisibilityUpdateRequest request) { + return ApiResponse.ok( + 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 new file mode 100644 index 0000000..a9896a6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/controller/AdminRegionController.java @@ -0,0 +1,27 @@ +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 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 +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/policy/dto/AdminPolicyApplicationResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationResponse.java new file mode 100644 index 0000000..ac4f729 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationResponse.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.admin.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/admin/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java new file mode 100644 index 0000000..22be04d --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyApplicationStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.admin.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/admin/policy/dto/AdminPolicyResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java new file mode 100644 index 0000000..422ab45 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyResponse.java @@ -0,0 +1,47 @@ +package com.bop.youthpick.admin.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, + boolean adminHidden, + 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.isAdminHidden() ? PolicyVisibility.HIDDEN : policy.getVisibility(), + policy.isAdminHidden(), + regionCodes, + policy.getCreatedAt(), + policy.getUpdatedAt(), + policy.getDeletedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyUpdateRequest.java new file mode 100644 index 0000000..eeb211b --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyUpdateRequest.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.admin.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/admin/policy/dto/AdminPolicyVisibilityUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyVisibilityUpdateRequest.java new file mode 100644 index 0000000..d330ef3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/AdminPolicyVisibilityUpdateRequest.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.admin.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/admin/policy/dto/ApplicationChecklistItemResponse.java b/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java new file mode 100644 index 0000000..687c0db --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/dto/ApplicationChecklistItemResponse.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.admin.policy.dto; + +import com.bop.youthpick.policy.entity.PolicyApplicationChecklist; +import java.time.LocalDateTime; + +public record ApplicationChecklistItemResponse( + Long id, + Long policyApplicationId, + boolean checked, + String description, + LocalDateTime createdAt) { + public static ApplicationChecklistItemResponse from(PolicyApplicationChecklist item) { + return new ApplicationChecklistItemResponse( + item.getId(), + item.getApplication().getId(), + item.isChecked(), + item.getContent(), + item.getCreatedAt()); + } +} diff --git a/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecifications.java b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecifications.java new file mode 100644 index 0000000..d3847ce --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecifications.java @@ -0,0 +1,133 @@ +package com.bop.youthpick.admin.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/admin/policy/repository/AdminPolicySpecifications.java b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java new file mode 100644 index 0000000..b7c69da --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecifications.java @@ -0,0 +1,66 @@ +package com.bop.youthpick.admin.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; + } + 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]와 겹치는 정책만 남긴다. */ + 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/admin/policy/service/AdminPolicyApplicationService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java new file mode 100644 index 0000000..f6119dc --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationService.java @@ -0,0 +1,75 @@ +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.entity.ApplicationStatus; +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 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 PolicyApplicationChecklistRepository 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 + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .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/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java new file mode 100644 index 0000000..23b021b --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminPolicyService.java @@ -0,0 +1,124 @@ +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.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.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); + List policyRegions = + regions.stream().map(region -> PolicyRegion.create(policy, region)).toList(); + policyRegionRepository.saveAll(policyRegions); + + return AdminPolicyResponse.from(policy, request.regionCodes()); + } + + @Transactional + public AdminPolicyResponse updateVisibility(Long policyId, String visibilityStatus) { + Policy policy = findPolicy(policyId); + if (PolicyVisibility.HIDDEN.name().equals(visibilityStatus)) { + policy.hideByAdmin(); + } else { + policy.unhideByAdmin(); + } + 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) { + 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) { + 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/admin/policy/service/AdminRegionService.java b/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java new file mode 100644 index 0000000..63ef082 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/policy/service/AdminRegionService.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.admin.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.findAllByOrderBySidoNameAscNameAsc().stream() + .map(RegionResponse::from) + .toList(); + } +} 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 new file mode 100644 index 0000000..3b24e71 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogController.java @@ -0,0 +1,48 @@ +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 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; +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; + +@Tag(name = "관리자 - 배치 작업 이력") +@RestController +@RequestMapping("/api/v1/admin/batch-job-logs") +@RequiredArgsConstructor +@Validated +public class AdminBatchJobLogController { + + private final AdminBatchJobLogService adminBatchJobLogService; + + @Operation(summary = "배치 작업 이력 조회", description = "상태, 기간으로 배치 작업 이력 목록을 검색하여 페이지 단위로 조회한다.") + @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/admin/sync/controller/AdminPolicySyncController.java b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java new file mode 100644 index 0000000..35278e0 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncController.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.admin.sync.controller; + +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 +public class AdminPolicySyncController { + + private final PolicySyncService policySyncService; + + /** + * 정책 수집을 비동기로 시작시키고 즉시 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(); + return ResponseEntity.accepted().body(ApiResponse.ok(null)); + } +} 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 new file mode 100644 index 0000000..13d40c3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobController.java @@ -0,0 +1,26 @@ +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 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 +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/sync/dto/BatchJobLogResponse.java b/src/main/java/com/bop/youthpick/admin/sync/dto/BatchJobLogResponse.java new file mode 100644 index 0000000..c3d23d1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/dto/BatchJobLogResponse.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.admin.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/admin/sync/dto/PolicySyncJobSummaryResponse.java b/src/main/java/com/bop/youthpick/admin/sync/dto/PolicySyncJobSummaryResponse.java new file mode 100644 index 0000000..af4e7b4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/dto/PolicySyncJobSummaryResponse.java @@ -0,0 +1,10 @@ +package com.bop.youthpick.admin.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/admin/sync/repository/AdminBatchJobLogSpecifications.java b/src/main/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecifications.java new file mode 100644 index 0000000..b32a192 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecifications.java @@ -0,0 +1,48 @@ +package com.bop.youthpick.admin.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/admin/sync/service/AdminBatchJobLogService.java b/src/main/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogService.java new file mode 100644 index 0000000..c1576d7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogService.java @@ -0,0 +1,27 @@ +package com.bop.youthpick.admin.sync.service; + +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; +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/main/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobService.java b/src/main/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobService.java new file mode 100644 index 0000000..d902876 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobService.java @@ -0,0 +1,24 @@ +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 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/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java b/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java new file mode 100644 index 0000000..7afbda4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryController.java @@ -0,0 +1,41 @@ +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 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; +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; + +@Tag(name = "관리자 - 로그인 이력") +@RestController +@RequestMapping("/api/v1/admin/login-histories") +@RequiredArgsConstructor +public class AdminLoginHistoryController { + + private final AdminLoginHistoryService adminLoginHistoryService; + + @Operation(summary = "로그인 이력 목록 조회", description = "회원과 기간으로 로그인 이력을 검색해 페이지 단위로 조회합니다.") + @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/admin/user/controller/AdminUserController.java b/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java new file mode 100644 index 0000000..fa7c430 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/controller/AdminUserController.java @@ -0,0 +1,72 @@ +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 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; +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; + +@Tag(name = "관리자 - 회원") +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@Validated +public class AdminUserController { + + private final AdminUserService adminUserService; + + @Operation(summary = "회원 목록 조회", description = "권한, 계정 상태, 가입 경로로 회원을 검색해 페이지 단위로 조회합니다.") + @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); + } + + @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/admin/user/dto/AdminUserProfileResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java new file mode 100644 index 0000000..5f1a6fb --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserProfileResponse.java @@ -0,0 +1,42 @@ +package com.bop.youthpick.admin.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, + String maritalStatus, + List major, + List specialCondition, + Integer income, + 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(), + profile.getMaritalStatus(), + splitToList(profile.getMajor()), + splitToList(profile.getSpecialCondition()), + profile.getIncome(), + 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/admin/user/dto/AdminUserResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserResponse.java new file mode 100644 index 0000000..02d159c --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserResponse.java @@ -0,0 +1,23 @@ +package com.bop.youthpick.admin.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/admin/user/dto/AdminUserRoleUpdateRequest.java b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserRoleUpdateRequest.java new file mode 100644 index 0000000..677ef42 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/dto/AdminUserRoleUpdateRequest.java @@ -0,0 +1,9 @@ +package com.bop.youthpick.admin.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/admin/user/dto/LoginHistoryResponse.java b/src/main/java/com/bop/youthpick/admin/user/dto/LoginHistoryResponse.java new file mode 100644 index 0000000..1a9b259 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/dto/LoginHistoryResponse.java @@ -0,0 +1,15 @@ +package com.bop.youthpick.admin.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/admin/user/repository/AdminLoginHistorySpecifications.java b/src/main/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecifications.java new file mode 100644 index 0000000..2eb1886 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecifications.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.admin.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/admin/user/repository/AdminUserSpecifications.java b/src/main/java/com/bop/youthpick/admin/user/repository/AdminUserSpecifications.java new file mode 100644 index 0000000..f155f24 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/repository/AdminUserSpecifications.java @@ -0,0 +1,40 @@ +package com.bop.youthpick.admin.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/admin/user/service/AdminLoginHistoryService.java b/src/main/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryService.java new file mode 100644 index 0000000..600c219 --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryService.java @@ -0,0 +1,28 @@ +package com.bop.youthpick.admin.user.service; + +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; +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/java/com/bop/youthpick/admin/user/service/AdminUserService.java b/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java new file mode 100644 index 0000000..95e440a --- /dev/null +++ b/src/main/java/com/bop/youthpick/admin/user/service/AdminUserService.java @@ -0,0 +1,65 @@ +package com.bop.youthpick.admin.user.service; + +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; +import com.bop.youthpick.user.exception.UserException; +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; + private final RefreshTokenStore refreshTokenStore; + + @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(); + // refresh token을 즉시 회수해 제재 실효성을 access token 만료 이내로 확보한다. + refreshTokenStore.delete(userId); + return AdminUserResponse.from(user); + } + + private User findUser(Long userId) { + return userRepository + .findById(userId) + .orElseThrow(() -> new UserException(UserError.USER_NOT_FOUND)); + } +} 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..52dcfef --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/controller/AuthController.java @@ -0,0 +1,119 @@ +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.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; +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; + +@Tag(name = "인증") +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + 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) { + String url = authService.buildAuthorizationUrl(provider); + 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) { + TokenResponse tokens = authService.login(provider, request.code(), request.state()); + return withRefreshCookie(tokens); + } + + /** + * refresh token은 더 이상 body가 아니라 {@link RefreshTokenCookieSupport#COOKIE_NAME} 쿠키로 받는다. 쿠키만으로 + * 동작해 preflight 없는 단순 요청(cross-site form 등)도 도달할 수 있으므로, Origin 헤더가 있는데 허용 목록에 없으면 거부한다. + * 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) + 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 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); + response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookieSupport.clear().toString()); + return ResponseEntity.noContent().build(); + } + + @Operation(summary = "내 정보 조회", description = "인증된 사용자의 기본 정보를 조회해 반환한다.") + @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) && !corsProperties.allowedOrigins().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 new file mode 100644 index 0000000..35773e9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/exception/AuthErrorCode.java @@ -0,0 +1,27 @@ +package com.bop.youthpick.auth.exception; + +import com.bop.youthpick.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A001", "로그인이 필요합니다."), + 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", "접근 권한이 없습니다."), + 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; + private final String message; +} 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..271ec2c --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/AuthService.java @@ -0,0 +1,166 @@ +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.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; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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)로 관리한다. + */ +@Slf4j +@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; + private final LoginHistoryRepository loginHistoryRepository; + + 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); + try { + loginHistoryRepository.save(LoginHistory.create(user)); + } catch (RuntimeException e) { + log.warn("로그인 이력(LoginHistory) 저장 실패 - 로그인 프로세스는 정상 진행합니다. userId={}", user.getId(), e); + } + return issueTokens(user); + } + + @Transactional(readOnly = true) + public TokenResponse refresh(String refreshToken) { + Claims claims = jwtTokenProvider.validateRefreshToken(refreshToken); + Long userId = jwtTokenProvider.getUserId(claims); + + // soft-delete(탈퇴/제재)된 사용자는 refresh token TTL이 남아 있어도 재발급을 거부한다. + User user = + userRepository + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.UNAUTHORIZED)); + + 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, refreshToken, 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 + .findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new AuthException(AuthErrorCode.UNAUTHORIZED)); + } + + private User findOrCreateUser(OAuthProvider provider, OAuthUserInfo 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) { + 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..16675e6 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/CurrentUser.java @@ -0,0 +1,21 @@ +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}로 거부된다. + * + *

비회원도 허용하는 공개 엔드포인트에서 로그인 사용자만 선택적으로 식별하려면 {@code required = false}로 지정한다. 이 경우 인증되지 않은 요청에는 거부 + * 대신 {@code null}이 주입된다. + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +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 new file mode 100644 index 0000000..c7eb284 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolver.java @@ -0,0 +1,45 @@ +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로 채운다. + * {@code required = false}면 미인증 요청에 거부 대신 {@code null}을 주입한다. + */ +@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)) { + // 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/auth/service/JwtAuthenticationFilter.java b/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java new file mode 100644 index 0000000..5361857 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/JwtAuthenticationFilter.java @@ -0,0 +1,71 @@ +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 boolean shouldNotFilterAsyncDispatch() { + return false; + } + + @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/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/auth/service/JwtTokenProvider.java b/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java new file mode 100644 index 0000000..0f50cd9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/JwtTokenProvider.java @@ -0,0 +1,106 @@ +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 (io.jsonwebtoken.ExpiredJwtException e) { + throw new AuthException(AuthErrorCode.EXPIRED_TOKEN); + } 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..5c9f9e1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/OAuthStateStore.java @@ -0,0 +1,37 @@ +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를 원자적으로 소비(GETDEL)하고, 저장된 provider와 일치하면 true를 반환한다. GET 후 DELETE 2단계로 나누면 동시 요청 2건이 모두 + * 통과할 수 있어 getAndDelete로 1회 소비를 보장한다. + */ + public boolean consume(String state, String provider) { + String savedProvider = redisTemplate.opsForValue().getAndDelete(key(state)); + return savedProvider != null && savedProvider.equals(provider); + } + + 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..0c28069 --- /dev/null +++ b/src/main/java/com/bop/youthpick/auth/service/RefreshTokenStore.java @@ -0,0 +1,51 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * 사용자별 refresh token을 Redis에 TTL과 함께 저장한다. Redis가 유출되더라도 원문 토큰이 그대로 노출되지 않도록 SHA-256 해시만 저장한다. + * refresh token은 재발급(rotate) 없이 발급 시점의 TTL이 다할 때까지 그대로 사용한다. + */ +@Component +@RequiredArgsConstructor +public class RefreshTokenStore { + + private static final String KEY_PREFIX = "auth:refresh-token:"; + + private final StringRedisTemplate redisTemplate; + + public void save(Long userId, String refreshToken, Duration ttl) { + redisTemplate.opsForValue().set(key(userId), hash(refreshToken), ttl); + } + + /** 저장된 값이 {@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) { + 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/comment/controller/CommentController.java b/src/main/java/com/bop/youthpick/comment/controller/CommentController.java new file mode 100644 index 0000000..3e8ba1f --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/controller/CommentController.java @@ -0,0 +1,72 @@ +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 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; +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; + +@Tag(name = "댓글") +@RestController +@RequestMapping("/api/v1/posts/{postId}/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 등록", description = "특정 게시글에 새로운 댓글을 작성하여 등록한다.") + @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)); + } + + @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, + @PathVariable Long postId, + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request) { + return ApiResponse.ok(commentService.update(userId, commentId, request)); + } + + @Operation( + summary = "댓글 삭제", + description = "댓글 ID로 대상 댓글을 삭제한다. 작성자 본인만 삭제할 수 있는지는 Service에서 검사한다.") + @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/comment/entity/Comment.java b/src/main/java/com/bop/youthpick/comment/entity/Comment.java new file mode 100644 index 0000000..6e746a1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/comment/entity/Comment.java @@ -0,0 +1,66 @@ +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; +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; + + 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/file/controller/FileController.java b/src/main/java/com/bop/youthpick/file/controller/FileController.java new file mode 100644 index 0000000..f256744 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/controller/FileController.java @@ -0,0 +1,64 @@ +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 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; +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; + +@Tag(name = "파일") +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +public class FileController { + + private static final Duration FILE_CACHE_MAX_AGE = Duration.ofDays(365); + + private final FileService fileService; + + @Operation(summary = "파일 업로드", description = "인증된 사용자가 파일을 업로드하고 저장된 파일 정보를 반환한다.") + @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)); + } + + @Operation(summary = "파일 다운로드", description = "파일 ID로 저장된 파일을 조회해 스트림으로 반환한다.") + @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/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/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..7bd59f4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/file/service/FileService.java @@ -0,0 +1,190 @@ +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 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 = + 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) { + log.warn("파일 저장소 업로드에 실패했습니다. fileId={}, userId={}", fileId, userId, 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); + } + log.warn("파일 저장소 조회에 실패했습니다. fileId={}", fileId, exception); + 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/common/ApiResponse.java b/src/main/java/com/bop/youthpick/global/common/ApiResponse.java index 9fd2034..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,23 +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} 형식으로 내려간다. + * 성공 응답 공통 봉투. 컨트롤러는 엔티티를 직접 반환하지 않고 항상 이 타입으로 감싼다. 대부분의 경우 (기본 http 상태 코드가 200인 경우) 응답 Response로 + * 사용하면 된다. 상태 코드가 필요한 경우, 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..da7138d --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/common/PageMeta.java @@ -0,0 +1,22 @@ +package com.bop.youthpick.global.common; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +/** + * 페이지네이션 부가정보. Map 대신 타입을 명시해 Swagger(OpenAPI) 문서에 필드 스키마가 그대로 드러나게 한다. + * + *

{@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( + @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()); + } +} 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/JpaAuditingConfig.java b/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..b5c75e2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/JpaAuditingConfig.java @@ -0,0 +1,8 @@ +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/config/OpenApiConfig.java b/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java new file mode 100644 index 0000000..c84ee14 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/OpenApiConfig.java @@ -0,0 +1,98 @@ +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(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() { + 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/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/RestAuthenticationEntryPoint.java b/src/main/java/com/bop/youthpick/global/config/RestAuthenticationEntryPoint.java index d99bfa9..bf0409b 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; @@ -15,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 @@ -33,9 +33,9 @@ public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { public void commence( HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException - ) throws IOException { - ErrorCode errorCode = ErrorCode.UNAUTHORIZED; + AuthenticationException authException) + throws IOException { + 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..2386f4f 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -1,5 +1,8 @@ package com.bop.youthpick.global.config; +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; @@ -9,26 +12,23 @@ 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를 개발·테스트할 수 있도록 - * {@code anyRequest().permitAll()}로 전부 열어 둔다. 보안 자체는 아래 절차로 언제든 켤 수 있다. + *

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

* - *

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

- *
    - *
  1. {@code com.bop.youthpick.auth} 패키지(JwtAuthenticationFilter 등)를 추가한다. - * JWT/Redis 관련 의존성(jjwt 등)과 설정(application.yml의 youthpick.auth.*)도 함께 채운다.
  2. - *
  3. 아래 "AUTH ON" 주석 블록을 활성화하고, 현재 "DEV: 전부 허용" 블록을 제거한다. - * (JwtAuthenticationFilter 주입 필드 + addFilterBefore + 공개경로/authenticated)
  4. - *
- * 이렇게 두면 EntryPoint·CORS·STATELESS 골격은 그대로 재사용되고, 바뀌는 건 인가 규칙과 필터뿐이다. + *

관리자 전용은 {@code hasRole("ADMIN")}, 회원 전용은 {@code authenticated()}, 비회원/공통은 {@code permitAll()}로 + * 매핑한다. 아직 컨트롤러가 없는 경로도 명세에 있으면 미리 규칙을 걸어 둔다(나중에 컨트롤러가 추가돼도 기본값이 열려 있지 않도록). 명세에 없는 나머지 경로는 여전히 개발 + * 편의상 {@code anyRequest().permitAll()}로 열어 둔다. */ @Configuration @EnableWebSecurity @@ -36,41 +36,84 @@ public class SecurityConfig { private final RestAuthenticationEntryPoint restAuthenticationEntryPoint; - - // [AUTH ON] 1단계: 팀원 auth 코드 추가 후 아래 주입을 활성화한다. - // private final com.bop.youthpick.auth.token.JwtAuthenticationFilter jwtAuthenticationFilter; + private final RestAccessDeniedHandler restAccessDeniedHandler; + private final JwtTokenProvider jwtTokenProvider; + private final CorsProperties corsProperties; @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) + .accessDeniedHandler(restAccessDeniedHandler)); - // ===== DEV: 전부 허용 (로그인 기능 생기기 전까지 개발 편의용) ===== - http.authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ); + http.authorizeHttpRequests( + auth -> + auth.requestMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() + // 관리자(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-comparisons/**", + "/api/v1/meta/profile-options", + "/api/v1/regions", + "/api/v1/policies/**", + "/api/v1/health") + .permitAll() + // 게시글 조회는 공개, 생성·수정·삭제는 회원 전용 + .requestMatchers(HttpMethod.GET, "/api/v1/posts/**") + .permitAll() + .requestMatchers("/api/v1/posts/**") + .authenticated() + // 파일 조회 URL은 img src에서 바로 사용하도록 공개하고, 업로드만 회원 전용으로 제한한다. + .requestMatchers(HttpMethod.GET, "/api/v1/files/**") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/files") + .authenticated() + // 회원 전용 — 로그인 상태 조회/로그아웃, 회원 탈퇴(컨트롤러 미구현, 경로만 선점), + // 마이페이지(관심정책/추천/읽음/프로필), 최근 본 정책 + // 온보딩 프로필 제출도 회원 전용 — 컨트롤러에서 본인 여부를 추가 검증한다. + .requestMatchers(HttpMethod.POST, "/api/v1/users/*/profile") + .authenticated() + .requestMatchers( + "/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") + .authenticated() + // 회원 전용 — 정책 신청관리(관심정책 흡수) + 체크리스트. + .requestMatchers( + "/api/v1/policy-applications/**", + "/api/v1/policy-application-checklists/**") + .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); + http.addFilterAfter(new RequestLogContextFilter(), JwtAuthenticationFilter.class); return http.build(); } @@ -78,15 +121,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.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedOrigins(corsProperties.allowedOrigins()); + 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/TaskExecutionConfig.java b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java new file mode 100644 index 0000000..1a0794d --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/TaskExecutionConfig.java @@ -0,0 +1,37 @@ +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.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +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; + } + + @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/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/global/config/WebSocketConfig.java b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java new file mode 100644 index 0000000..30dfdc8 --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/WebSocketConfig.java @@ -0,0 +1,71 @@ +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.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; + +@Configuration +@EnableWebSocketMessageBroker +@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; + private final CorsProperties corsProperties; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/api/ws") + .setAllowedOrigins(corsProperties.allowedOrigins().toArray(String[]::new)); + registry.setPreserveReceiveOrder(true); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); + registry.setUserDestinationPrefix("/user/"); + 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); + } + + @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/entity/BaseEntity.java b/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java new file mode 100644 index 0000000..aed3c1e --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/entity/BaseEntity.java @@ -0,0 +1,25 @@ +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/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/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 2c4b4ec..790c454 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,11 @@ 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 { +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); // 임의의 코드를 정하세요. - 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; - } + 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 8360c42..ab0b7fb 100644 --- a/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/bop/youthpick/global/error/GlobalExceptionHandler.java @@ -3,71 +3,237 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import java.util.List; - +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로 에러 응답을 직접 만들지 않는다(여기로 위임). + * 전역 예외 처리. 모든 컨트롤러 예외를 여기서 {@link ErrorResponse}로 변환한다. 컨트롤러는 try-catch로 에러 응답을 직접 만들지 않는다(여기로 + * 위임). */ +@Slf4j @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(); - - ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; - return ResponseEntity - .status(errorCode.getStatus()) + 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()) .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(); - - ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; - return ResponseEntity - .status(errorCode.getStatus()) + 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()) .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))); + } + + @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( + 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) { ErrorCode errorCode = exception.getErrorCode(); - return ResponseEntity - .status(errorCode.getStatus()) - .body(ErrorResponse.of(errorCode)); + 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)); + } + + // 매핑되지 않은 경로(오타 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)); + } + + // 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) { - ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; - return ResponseEntity - .status(errorCode.getStatus()) - .body(ErrorResponse.of(errorCode)); + log.error("예상치 못한 서버 오류", exception); + ErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(errorCode.getStatus()).body(ErrorResponse.of(errorCode)); } private String fieldName(ConstraintViolation violation) { @@ -78,4 +244,21 @@ 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", "입력 형태가 올바르지 않습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "C002", "지원하지 않는 HTTP 메서드입니다."), + 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; + 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/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..cbebe5a --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/config/ApplicationLogDataSource.java @@ -0,0 +1,50 @@ +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); + config.setConnectionTimeout(2000); + 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/ApplicationLog.java b/src/main/java/com/bop/youthpick/log/entity/ApplicationLog.java new file mode 100644 index 0000000..e62a112 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/entity/ApplicationLog.java @@ -0,0 +1,67 @@ +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 = "application_logs", + indexes = @Index(name = "idx_application_logs_created", columnList = "created_at")) +@EntityListeners(AuditingEntityListener.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ApplicationLog { + + @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..96b8113 --- /dev/null +++ b/src/main/java/com/bop/youthpick/log/entity/SearchLog.java @@ -0,0 +1,58 @@ +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_histories", + indexes = @Index(name = "idx_search_histories_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; + + 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/repository/AppLogRepository.java b/src/main/java/com/bop/youthpick/log/repository/AppLogRepository.java new file mode 100644 index 0000000..23a2844 --- /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.ApplicationLog; +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/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/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/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/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..eb4dd00 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationChecklistController.java @@ -0,0 +1,156 @@ +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 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; +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}는 그 경우에만 필요하다). + */ +@Tag(name = "정책 신청 체크리스트") +@RestController +@RequestMapping("/api/v1/policy-application-checklists") +@RequiredArgsConstructor +public class PolicyApplicationChecklistController { + + private final PolicyApplicationChecklistService checklistService; + + /** + * {@code POST /api/v1/policy-application-checklists} — 특정 신청관리(applicationId)에 체크리스트 항목 추가. + * {@code request.message()}가 {@link PolicyApplicationChecklistService#add}의 message 인자로 그대로 + * 넘어가고, 서비스 내부에서 {@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, + @Valid @RequestBody PolicyApplicationCreateChecklistRequest request) { + PolicyApplicationChecklist checklist = + checklistService.add(request.applicationId(), userId, request.message()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(PolicyApplicationChecklistResponse.from(checklist))); + } + + /** + * {@code GET /api/v1/policy-application-checklists/application/{applicationId}} — 특정 신청관리에 달린 + * 체크리스트 목록. 서비스가 먼저 부모 신청관리의 존재/소유권을 확인한 뒤(쿼리 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, + @PathVariable Long applicationId, + @PageableDefault(size = 20) Pageable pageable) { + Page page = + checklistService.getByApplication(applicationId, userId, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + /** {@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, + @PathVariable Long id, + @Valid @RequestBody PolicyApplicationUpdateChecklistRequest request) { + PolicyApplicationChecklist checklist = + checklistService.update(id, userId, request.message()); + return ApiResponse.ok(PolicyApplicationChecklistResponse.from(checklist)); + } + + /** + * {@code PATCH /api/v1/policy-application-checklists/{id}/check} — 체크 표시. body 없이 상태만 토글하므로 반환 + * 데이터가 없고, {@link PolicyApplicationChecklistMessageResponse}로 성공 메시지만 내려준다({@link + * 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) { + checklistService.check(id, userId); + return ApiResponse.ok(new PolicyApplicationChecklistMessageResponse("체크 완료")); + } + + /** {@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) { + checklistService.uncheck(id, userId); + return ApiResponse.ok(new PolicyApplicationChecklistMessageResponse("체크 해제 완료")); + } + + /** + * {@code DELETE /api/v1/policy-application-checklists/{id}} — 체크리스트 항목 하나만 소프트 삭제. 부모 신청관리 전체가 + * 삭제/재등록될 때 체크리스트를 한 번에 정리하는 경로는 여기가 아니라 {@code + * 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) { + 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..15c02da --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyApplicationController.java @@ -0,0 +1,187 @@ +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.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; +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; +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.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 +@Tag(name = "정책 신청관리") +@RestController +@RequestMapping("/api/v1/policy-applications") +@RequiredArgsConstructor +public class PolicyApplicationController { + + private final PolicyApplicationService policyApplicationService; + + /** + * {@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}가 전담한다. + */ + @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) { + PolicyApplication application = + policyApplicationService.create( + userId, + request.policyId(), + parseStatus(request.status()), + request.memo(), + request.endAt()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(PolicyApplicationResponse.from(application))); + } + + /** + * {@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 규칙). + */ + @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) { + Page page = + policyApplicationService.getApplications(userId, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + /** + * {@code PATCH /api/v1/policy-applications/{id}/status} — 상태만 단독으로 바꾸는 엔드포인트. {@link #create}의 + * DTO와 같은 {@link ApplicationStatus#VALUES_PATTERN} 화이트리스트를 공유한다({@link #parseStatus}가 방어선 역할은 + * 동일하게 유지). 소유권 검증은 여기가 아니라 {@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, + @PathVariable Long id, + @RequestParam + @NotBlank(message = "상태는 필수입니다.") + @Pattern(regexp = ApplicationStatus.VALUES_PATTERN, message = "유효하지 않은 상태값입니다.") + String status) { + PolicyApplication application = + policyApplicationService.changeStatus(id, userId, parseStatus(status)); + return ApiResponse.ok(PolicyApplicationResponse.from(application)); + } + + /** + * {@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, + @PathVariable Long id, + @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} — 마감일 단독 수정. */ + @Operation( + summary = "정책 신청 마감일 수정", + description = "PATCH /api/v1/policy-applications/{id}/end-at — 마감일 단독 수정.") + @PatchMapping("/{id}/end-at") + public ApiResponse updateEndAt( + @CurrentUser Long userId, + @PathVariable Long id, + @Valid @RequestBody PolicyApplicationEndAtUpdateRequest request) { + PolicyApplication application = + policyApplicationService.updateEndAt(id, userId, request.endAt()); + return ApiResponse.ok(PolicyApplicationResponse.from(application)); + } + + /** + * {@code DELETE /api/v1/policy-applications/{id}} — 소프트 삭제(관심 해제). 물리 삭제가 아니라 {@code + * 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); + return ApiResponse.ok(null); + } + + /** + * {@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); + } catch (IllegalArgumentException e) { + throw new CustomException(PolicyErrorCode.INVALID_APPLICATION_STATUS); + } + } +} 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..2ff9706 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyChatController.java @@ -0,0 +1,38 @@ +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 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; +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; + +@Tag(name = "정책 채팅") +@RestController +@RequestMapping("/api/v1/policies/{policyId}/chat/messages") +@RequiredArgsConstructor +@Validated +public class PolicyChatController { + + private final PolicyChatService policyChatService; + + @Operation( + summary = "정책 채팅 메시지 조회", + description = "특정 정책 채팅방의 메시지 목록을 afterId 기준으로 이후 메시지만 조회한다(회원 전용).") + @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/controller/PolicyComparisonController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java new file mode 100644 index 0000000..a3c14a5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyComparisonController.java @@ -0,0 +1,44 @@ +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 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; +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 자체가 공유 링크가 된다. + */ +@Tag(name = "정책 비교") +@Validated +@RestController +@RequestMapping("/api/v1/policy-comparisons") +@RequiredArgsConstructor +public class PolicyComparisonController { + + private final PolicyComparisonService policyComparisonService; + + @Operation( + summary = "정책 비교 조회", + description = + "정책 비교(비회원 허용). 비교 결과는 저장하지 않고 요청받은 정책들을 그때그때 조회해 내려주는 순수 조회다 — 그래서 생성(POST) + 식별자" + + " 재조회가 아니라 쿼리 파라미터를 받는 단일 GET이다. 공유가 필요하면 이 요청 URL 자체가 공유 링크가 된다.") + @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/controller/PolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java new file mode 100644 index 0000000..435a8f0 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java @@ -0,0 +1,64 @@ +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.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; +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; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "정책") +@RestController +@RequestMapping("/api/v1/policies") +@RequiredArgsConstructor +public class PolicyController { + + private final PolicyService policyService; + + /** + * 정책 목록(카드) 조회 (비회원 허용). 기본 최신순이되, region·jobCode 필터가 걸리면 조건 없는 정책(전국/취업상태 제한없음)을 뒤로 민다. + * category(표준 5분류) exact match, keyword는 부분일치 검색, region은 시도명('전국'은 지역 무관이라 필터 미적용), + * 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, + @RequestParam(required = false) String keyword, + @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, jobCode, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + /** 정책 상세 조회 (비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다. */ + @Operation(summary = "정책 상세 조회", description = "정책 상세 조회(비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다.") + @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..2c7ba4b --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyRecentViewController.java @@ -0,0 +1,35 @@ +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 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; +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; + +@Tag(name = "최근 본 정책") +@RestController +@RequestMapping("/api/v1/policy-recent-views") +@RequiredArgsConstructor +public class PolicyRecentViewController { + + private final PolicyRecentViewService policyRecentViewService; + + /** 최근 본 정책 목록 (회원 전용). 마지막 조회 시각 내림차순. */ + @Operation(summary = "최근 본 정책 목록 조회", description = "최근 본 정책 목록(회원 전용). 마지막 조회 시각 내림차순.") + @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/controller/RecommendedPolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java new file mode 100644 index 0000000..2b8af6c --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/RecommendedPolicyController.java @@ -0,0 +1,37 @@ +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 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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "추천 정책") +@RestController +@RequestMapping("/api/v1/recommended-policies") +@RequiredArgsConstructor +public class RecommendedPolicyController { + + private final RecommendedPolicyService recommendedPolicyService; + + /** 맞춤정책 조회 (회원 전용). 로그인 사용자의 온보딩 프로필과 정책 자격조건을 매칭해 점수순으로 내린다. */ + @Operation( + summary = "맞춤 정책 추천 조회", + description = "맞춤정책 조회(회원 전용). 로그인 사용자의 온보딩 프로필과 정책 자격조건을 매칭해 점수순으로 내린다.") + @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/controller/RegionController.java b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java new file mode 100644 index 0000000..3c2c3a3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/controller/RegionController.java @@ -0,0 +1,30 @@ +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.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; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 지역 마스터 전체 목록 조회 (비회원 허용). 온보딩 프로필의 거주지역 선택지로 쓰인다. */ +@Tag(name = "지역") +@RestController +@RequestMapping("/api/v1/regions") +@RequiredArgsConstructor +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/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..b963995 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationChecklistResponse.java @@ -0,0 +1,30 @@ +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..ebb2df5 --- /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 필드에 담긴다 (create 쪽 500자 + * 제한은 PolicyApplicationCreateRequest의 메모 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/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/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/dto/PolicyApplicationResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java new file mode 100644 index 0000000..65a42e1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyApplicationResponse.java @@ -0,0 +1,44 @@ +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의 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( + 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/dto/PolicyCardResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.java new file mode 100644 index 0000000..6c3dac0 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyCardResponse.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}로 분리 — 프론트 카드가 쓰는 필드만 내린다. + * + *

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

자격 판정용 내부 코드(jobCodes/schoolCodes/majorCodes/specializationCodes/maritalStatusCode)는 목록 화면에서도 + * 프론트가 추천 하드필터(결혼·전공·신분 등 자격조건으로 부적격 정책 제외)를 적용할 수 있도록 상세와 동일하게 노출한다(#92). + */ +public record PolicyCardResponse( + Long id, + String title, + String category, + String description, + Integer minAge, + Integer maxAge, + String jobCodes, + String schoolCodes, + String maritalStatusCode, + String majorCodes, + String specializationCodes, + LocalDate applicationEndDate, + List provinces) { + + public static PolicyCardResponse from(Policy policy, List provinces) { + return new PolicyCardResponse( + policy.getId(), + policy.getTitle(), + policy.getCategory(), + 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/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/dto/PolicyComparisonItemResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java new file mode 100644 index 0000000..1488c6d --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyComparisonItemResponse.java @@ -0,0 +1,48 @@ +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, #92부터 상세에 노출)와 보류 필드, 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/dto/PolicyDetailResponse.java b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java new file mode 100644 index 0000000..10f0437 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/dto/PolicyDetailResponse.java @@ -0,0 +1,90 @@ +package com.bop.youthpick.policy.dto; + +import com.bop.youthpick.policy.entity.Policy; +import java.time.LocalDate; +import java.util.List; + +/** + * 정책 상세 조회 응답 (유저 대상). 자격 판정용 내부 코드(jobCodes/schoolCodes/incomeConditionCode 등)는 프론트가 추천 + * 하드필터(결혼·전공·신분 등 자격조건으로 부적격 정책 제외)를 복구할 수 있도록 노출한다(#92). 보류 필드·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 jobCodes, + String schoolCodes, + String incomeConditionCode, + Integer incomeMaxAmount, + String incomeEtcContent, + String maritalStatusCode, + String majorCodes, + String specializationCodes, + 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 regions) { + + public static PolicyDetailResponse from(Policy policy, List regions) { + 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.getJobCodes(), + policy.getSchoolCodes(), + policy.getIncomeConditionCode(), + policy.getIncomeMaxAmount(), + policy.getIncomeEtcContent(), + policy.getMaritalStatusCode(), + policy.getMajorCodes(), + policy.getSpecializationCodes(), + 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(), + regions); + } +} 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/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/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/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/.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/ApplicationStatus.java b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java new file mode 100644 index 0000000..3349fd0 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/ApplicationStatus.java @@ -0,0 +1,25 @@ +package com.bop.youthpick.policy.entity; + +/** + * 정책 신청관리 상태. INTERESTED(관심)가 구 즐겨찾기 역할. PREPARING(준비중)은 관심과 신청 사이 서류 준비 단계. + * + *

{@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으로 새지는 않는다. + */ +public enum ApplicationStatus { + INTERESTED, + PREPARING, + APPLIED, + 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/Policy.java b/src/main/java/com/bop/youthpick/policy/entity/Policy.java new file mode 100644 index 0000000..67ea035 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/Policy.java @@ -0,0 +1,481 @@ +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 '제한없음'. 실측 2,638건 중 1,962건(74%)이 이 값이며, 취업상태를 따지지 않는 정책이라는 뜻이다. 특정 상태로 필터링할 때도 이 정책들은 + * 대상에 포함해야 한다 — 재직자를 골랐다고 K-패스가 빠지면 안 된다. + */ + public static final String JOB_CODE_UNRESTRICTED = "0013010"; + + /** 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 = 255) + 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", columnDefinition = "TEXT") + 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 = 1000) + private String applicationUrl; + + /** refUrlAddr1 */ + @Column(name = "reference_url1", length = 1000) + private String referenceUrl1; + + /** refUrlAddr2 */ + @Column(name = "reference_url2", length = 1000) + 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; + + /** 관리자 수동 숨김 여부. 수집 배치의 누락/재등장 상태({@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; + + /** + * [파생] 전 시도 커버 여부 (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해야 한다. + */ + 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; + + public void hideByAdmin() { + this.adminHidden = true; + } + + public void unhideByAdmin() { + this.adminHidden = false; + } + + 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/PolicyApplication.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java new file mode 100644 index 0000000..55b10fe --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplication.java @@ -0,0 +1,139 @@ +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; +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; +import org.hibernate.annotations.SQLRestriction; + +/** + * 사용자별 정책 신청 진행관리 (관심 → 신청 → 완료). 기존 favorite_policies를 흡수 — status=INTERESTED가 즐겨찾기. UNIQUE(user, + * policy)로 같은 정책 중복 등록 방지. 이 엔티티는 오직 PolicyApplicationService를 통해서만 생성/변경된다 — Controller에서 직접 다루지 + * 않는다(entity-jpa.md 규칙). 조회는 PolicyApplicationRepository, API 노출은 PolicyApplicationResponse.from()이 + * 담당한다. + */ +@Entity +@Table( + name = "policy_applications", + uniqueConstraints = + @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 { + + @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; + + @Column(columnDefinition = "TEXT") + private String memo; + + /** 개인이 설정하는 마감일 */ + @Column(name = "end_at") + private LocalDateTime endAt; + + /** 관리 해제 = soft delete. 재등록 시 행 재활성화(UNIQUE 충돌 방지) */ + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + /** + * 정적 팩토리 — {@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; + application.policy = policy; + application.status = status; + application.memo = memo; + application.endAt = endAt; + return application; + } + + /** + * setter 대신 의미 있는 메서드로 상태를 변경한다(entity-jpa.md 규칙). {@code save()} 호출 없이 필드만 바꿔도 + * {@code @Transactional} 커밋 시 JPA dirty checking으로 자동 UPDATE된다. + */ + public void changeStatus(ApplicationStatus status) { + this.status = status; + } + + /** null 정규화(공백/빈 문자열 → null)는 서비스 계층 책임이라 여기선 그대로 대입만 한다. */ + public void updateMemo(String memo) { + this.memo = memo; + } + + /** 정책 마감일 초과 검증은 서비스 책임이고, 엔티티는 검증된 값을 그대로 받아 대입만 한다. */ + public void updateEndAt(LocalDateTime endAt) { + this.endAt = endAt; + } + + /** + * soft-delete된 행을 UNIQUE(user, policy) 충돌 없이 재등록한다. PolicyApplicationService.create()의 "기존 신청이 + * 있는데 삭제된 상태" 분기에서만 호출되며, status/memo/endAt을 과거 값과 무관하게 이번 요청 값으로 완전히 덮어쓴다. 리뷰 노트: 이 + * 엔티티엔 @Version(낙관적 락)이 없어서, 같은 (user, policy)에 대해 동시에 재등록 요청이 들어오면 나중에 커밋되는 쪽이 조용히 덮어쓸 수 있다 — + * create()의 "새 INSERT" 경로가 DataIntegrityViolationException으로 명시적으로 막는 것과 달리 이 경로는 방어가 없다. + */ + public void reactivate(ApplicationStatus status, String memo, LocalDateTime endAt) { + this.status = status; + this.memo = memo; + this.endAt = endAt; + this.deletedAt = null; + } + + /** + * 물리 DELETE가 아니라 {@code deletedAt} 타임스탬프만 세팅하는 소프트 삭제다. UNIQUE(user, policy) 제약은 유지한 채로 "삭제됨"만 + * 표시해서, 나중에 {@link #reactivate}가 이 행을 재사용할 수 있게 한다. + */ + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + /** {@link #create}가 "새로 만들지 vs 재활성화할지"를 가르는 조건이자, {@code findActive()}류 조회 필터의 기준이 된다. */ + public boolean isDeleted() { + return deletedAt != null; + } + + /** + * 소유권 검증 — {@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 new file mode 100644 index 0000000..5c2635d --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyApplicationChecklist.java @@ -0,0 +1,98 @@ +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}에 종속된다 — 부모가 소프트 + * 삭제되면(PolicyApplicationService.delete()) 딸린 체크리스트도 + * PolicyApplicationChecklistRepository.softDeleteAllByApplicationId()로 함께 소프트 삭제된다. 혹시 정리 전에 남아있던 + * "고아" 체크리스트라도 PolicyApplicationChecklistRepository.findActiveWithApplicationById()가 부모의 deleted_at + * 조건까지 쿼리에서 확인하므로 CHECKLIST_NOT_FOUND로 걸러진다. + */ +@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 {@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) 상태로 + * 시작한다. + */ + 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/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/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/entity/PolicyRegion.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java new file mode 100644 index 0000000..bc293a3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyRegion.java @@ -0,0 +1,48 @@ +package com.bop.youthpick.policy.entity; + +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; + + 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/entity/PolicyVisibility.java b/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java new file mode 100644 index 0000000..554b3cb --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/PolicyVisibility.java @@ -0,0 +1,7 @@ +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..7b44b0e --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/entity/Region.java @@ -0,0 +1,35 @@ +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; + + 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/exception/PolicyErrorCode.java b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java new file mode 100644 index 0000000..30c92f3 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/exception/PolicyErrorCode.java @@ -0,0 +1,55 @@ +package com.bop.youthpick.policy.exception; + +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 { + /** PolicyApplicationService.create()에서 policyRepository.findById(policyId)가 비었을 때 던진다. */ + POLICY_NOT_FOUND("P001", "일치하는 정책이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + + /** + * PolicyApplicationService.create()에서 두 번 쓰인다: ①활성 상태인 기존 신청이 있을 때(중복 등록 시도), ②동시 요청으로 + * UNIQUE(user, policy) 제약을 위반한 DataIntegrityViolationException을 잡았을 때 — 같은 도메인 에러로 통일. + */ + POLICY_ALREADY_EXISTS("P002", "이미 존재하는 정책입니다.", HttpStatus.CONFLICT), + + REGION_NOT_FOUND("P003", "존재하지 않는 지역 코드입니다.", HttpStatus.NOT_FOUND), + + /** + * PolicyApplicationService.findActive(id)가 못 찾을 때(changeStatus/updateMemo/updateEndAt/delete 공통 + * 경로), PolicyApplicationChecklistService에서도 같은 목적으로 재사용된다. + */ + POLICY_APPLICATION_NOT_FOUND("P004", "정책 관리 항목을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + + /** + * PolicyApplicationChecklistService 전용 — 이 파일이 속한 policy-application 핵심 흐름(create/changeStatus + * 등)에선 쓰이지 않는다. + */ + CHECKLIST_NOT_FOUND("P005", "체크리스트 항목을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + + /** + * PolicyApplicationService.validateWithinPolicyDeadline()에서 던진다 — create()의 resolveEndAt()과 + * updateEndAt()이 공통으로 이 검증을 거친다(개인 마감일이 정책 자체 마감일보다 늦으면 안 됨). + */ + END_AT_AFTER_POLICY_DEADLINE("P006", "개인 마감일은 정책 신청 마감일을 넘을 수 없습니다.", HttpStatus.BAD_REQUEST), + + /** + * 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); + + private final String code; + private final String message; + private final HttpStatus status; +} 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/PolicyApplicationChecklistRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java new file mode 100644 index 0000000..aa7fe27 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepository.java @@ -0,0 +1,49 @@ +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 네 메서드가 모두 이 조회를 거친다. {@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)로 떨어지게 한다. + */ + @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()에서 사용 + * — 목록 API의 실제 조회. + */ + Page findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + Long applicationId, Pageable pageable); + + /** + * 이 리포지토리를 쓰는 다른 모든 메서드와 달리, 이건 PolicyApplicationChecklistService가 아니라 옆 도메인 파일인 + * PolicyApplicationService에서 직접 호출된다 — ①create()의 reactivate 분기(관심 삭제했던 정책을 재등록하면 예전 체크리스트 전부 + * 무효화), ②delete()(신청 소프트 삭제 시 딸린 체크리스트도 함께 정리해 고아 체크리스트가 남지 않게 함). {@code flushAutomatically = + * true}라서, 직전에 PolicyApplication.reactivate()로 바뀐 변경분이 먼저 flush된 뒤에 이 벌크 UPDATE가 실행된다(순서 보장). + * {@code clearAutomatically}는 안 켜져 있어서, 같은 트랜잭션 안에서 이미 영속 상태로 로드해둔 PolicyApplicationChecklist가 + * 있다면 이 벌크 UPDATE 이후에도 메모리 값은 갱신되지 않는다 — 지금 호출 지점(create()/delete())은 체크리스트 엔티티를 따로 로드하지 않아서 + * 문제되지 않는다. + */ + @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 new file mode 100644 index 0000000..b563b5a --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyApplicationRepository.java @@ -0,0 +1,42 @@ +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; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PolicyApplicationRepository + extends JpaRepository, + JpaSpecificationExecutor { + + /** + * PolicyApplicationService.findActive(id)가 이걸 감싼다 — changeStatus/updateMemo/updateEndAt/delete가 + * 모두 이 메서드를 거쳐 "존재하지 않거나 이미 삭제된 id"를 POLICY_APPLICATION_NOT_FOUND로 통일해서 처리한다. + */ + Optional findByIdAndDeletedAtIsNull(Long id); + + /** + * PolicyApplicationService.getApplications()(목록 조회)에서 사용. {@code @EntityGraph}로 policy 연관관계를 함께 + * fetch해서, PolicyApplicationResponse.from()이 policy.getTitle() 등을 부를 때 N+1 쿼리가 나지 않게 한다. + */ + @EntityGraph(attributePaths = "policy") + Page findByUser_IdAndDeletedAtIsNull(Long userId, Pageable pageable); + + /** + * 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", + nativeQuery = true) + Optional findIncludingDeletedByUserIdAndPolicyId( + @Param("userId") Long userId, @Param("policyId") Long policyId); +} 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/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/PolicyRegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java new file mode 100644 index 0000000..7e9f926 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRegionRepository.java @@ -0,0 +1,24 @@ +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; +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 { + + void deleteByPolicy(Policy policy); + + List 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 new file mode 100644 index 0000000..9291321 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/PolicyRepository.java @@ -0,0 +1,96 @@ +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.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 { + + /** 배치 비교용 전체 스냅샷 — 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); + + Optional findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + Long id, PolicyVisibility visibility); + + List findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + Collection ids, PolicyVisibility visibility); + + /** + * 목록 카드 조회 — 노출 중이고 신청 마감(applicationEndDate)이 지나지 않은 정책만. 마감일 없음(상시)은 포함하되, + * businessPeriodEnd(사업기간 종료일)가 있고 이미 지났다면 제외한다 — aplyPrdSeCd가 진짜 상시(0057002)가 아닌데도 + * aplyYmd(신청기간)가 비어 applicationEndDate만 null인 정책(예: 0057003 지역 단발성 모집)이 이미 끝났음에도 "상시"로 계속 노출되는 + * 문제를 막는다(#123). category는 표준 5분류(V8에서 정규화) exact match, null이면 전체. keyword는 5개 필드 LIKE + * 부분일치(escape '!')에 더해 정책 지역(시도명) 부분일치도 포함한다(#199) — 검색창에 "서울"처럼 지역명을 입력해도 그 지역 정책이 결과에 잡히게 하기 + * 위함이다. sidoName은 시도명 EXISTS(같은 시도 내 다수 시군구여도 중복 반환 없음) — 전 시도를 커버하는 정책도 개별 시도 조회에 포함된다. age는 + * 요청 구간과 정책 자격 구간의 겹침(overlap) 판정 — min/maxAge가 0 또는 NULL이면 제한없음으로 항상 통과. jobCode는 온통청년 취업상태 코드 + * 하나로, 해당 코드를 가진 정책과 '제한없음'({@link Policy#JOB_CODE_UNRESTRICTED}) 정책을 함께 통과시킨다. + * + *

정렬은 Pageable이 아니라 이 쿼리가 고정한다. "조건 없는 정책을 뒤로"는 필터가 걸렸을 때만 적용해야 하는데, Spring Data {@code + * 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" + + " 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 '!'" + + " 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 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))" + + " 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 (: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 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 + + ",%') then 1 else 0 end," + + " p.id desc") + Page findCards( + @Param("visibility") PolicyVisibility visibility, + @Param("today") LocalDate today, + @Param("category") String category, + @Param("keyword") String keyword, + @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/repository/RegionRepository.java b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java new file mode 100644 index 0000000..6c9a2fd --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/repository/RegionRepository.java @@ -0,0 +1,11 @@ +package com.bop.youthpick.policy.repository; + +import com.bop.youthpick.policy.entity.Region; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RegionRepository extends JpaRepository { + + /** 시도명 → 시군구명 순 한글 가나다 정렬. 온보딩/관리자 지역 선택지가 이 순서를 그대로 쓴다. */ + List findAllByOrderBySidoNameAscNameAsc(); +} 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..2a76fe4 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistService.java @@ -0,0 +1,110 @@ +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 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를 이 서비스가 직접 다시 + * 부르지 않고 {@link PolicyApplicationService#findActive}를 재사용한다(체크리스트 자체 레포만으론 소유권을 못 가려 여전히 부모 조회가 + * 필요하지만, 그 조회 자체는 한 곳에서만 구현한다). + */ +@Service +@RequiredArgsConstructor +public class PolicyApplicationChecklistService { + + private final PolicyApplicationChecklistRepository applicationChecklistRepository; + private final PolicyApplicationService policyApplicationService; + + /** + * 먼저 {@link #findActiveApplication}으로 부모 신청관리가 존재하고 요청자 소유인지 확인한 뒤에만 {@link + * 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); + } + + /** + * {@code save()} 없이 필드만 바꿔도 {@code @Transactional} 커밋 시 dirty checking으로 자동 반영된다 ({@link + * PolicyApplication#changeStatus}와 같은 패턴). + */ + @Transactional + public PolicyApplicationChecklist update(Long id, Long userId, String message) { + PolicyApplicationChecklist checklist = findActive(id, userId); + checklist.updateContent(message); + return checklist; + } + + @Transactional + public void check(Long id, Long userId) { + findActive(id, userId).check(); + } + + @Transactional + public void uncheck(Long id, Long userId) { + findActive(id, userId).uncheck(); + } + + /** + * 항목 하나만 소프트 삭제한다. 부모 신청관리 전체가 삭제/재등록될 때 체크리스트를 일괄 정리하는 건 여기가 아니라 {@link + * PolicyApplicationChecklistRepository#softDeleteAllByApplicationId}이고, 그건 {@link + * PolicyApplicationService#create}의 reactivate 분기와 {@link PolicyApplicationService#delete}에서 (이 + * 서비스를 거치지 않고) 직접 호출된다. + */ + @Transactional + public void delete(Long id, Long userId) { + findActive(id, userId).delete(); + } + + /** 부모 신청관리 소유권 확인(1쿼리) 후 체크리스트 목록을 id 오름차순으로 조회(1쿼리)해 응답 DTO로 변환한다. */ + @Transactional(readOnly = true) + public Page getByApplication( + Long applicationId, Long userId, Pageable pageable) { + findActiveApplication(applicationId, userId); + + return applicationChecklistRepository + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc(applicationId, pageable) + .map(PolicyApplicationChecklistResponse::from); + } + + /** + * {@link #add}/{@link #getByApplication}이 공통으로 쓰는 "부모 신청관리가 존재하고, 삭제되지 않았고, 요청자 소유인지" 확인. 존재/삭제 + * 여부 조회는 {@link PolicyApplicationService#findActive}에 위임하고, 여기선 체크리스트 쪽에서만 필요한 소유권 검증만 이어서 한다. + */ + private PolicyApplication findActiveApplication(Long applicationId, Long userId) { + PolicyApplication application = policyApplicationService.findActive(applicationId); + application.verifyOwner(userId); + return application; + } + + /** + * 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 + .findActiveWithApplicationById(id) + .orElseThrow( + () -> 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..d9a0cd2 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyApplicationService.java @@ -0,0 +1,171 @@ +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.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.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 +public class PolicyApplicationService { + + private final PolicyApplicationRepository policyApplicationRepository; + private final UserRepository userRepository; + private final PolicyRepository policyRepository; + private final PolicyApplicationChecklistRepository policyApplicationChecklistRepository; + + /** + * 정책 신청관리를 새로 등록한다. 같은 (user, policy) 조합의 기존 행이 있으면 두 갈래로 나뉜다: soft-delete되지 않은 상태면 {@link + * PolicyErrorCode#POLICY_ALREADY_EXISTS}, soft-delete된 상태면 {@link + * PolicyApplication#reactivate}로 같은 행을 재활성화하고(상태/메모/마감일을 이번 요청 값으로 덮어씀) 기존에 연결돼 있던 체크리스트를 모두 + * 소프트삭제한다. + */ + @Transactional + public PolicyApplication create( + Long userId, + Long policyId, + 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) + .orElse(null); + String normalizedMemo = blankToNull(memo); + + if (existing != null) { + if (!existing.isDeleted()) { + throw new CustomException(PolicyErrorCode.POLICY_ALREADY_EXISTS); + } + existing.reactivate(status, normalizedMemo, resolveEndAt(endAt, policy)); + policyApplicationChecklistRepository.softDeleteAllByApplicationId(existing.getId()); + return existing; + } + PolicyApplication application = + PolicyApplication.create( + user, policy, status, normalizedMemo, resolveEndAt(endAt, policy)); + + try { + return policyApplicationRepository.save(application); + } catch (DataIntegrityViolationException e) { + // including-deleted 조회 확인 이후 동시 요청이 먼저 저장한 경우 + // (uk_policy_applications_user_policy UNIQUE 위반). 같은 도메인 에러로 통일한다. + throw new CustomException(PolicyErrorCode.POLICY_ALREADY_EXISTS); + } + } + + /** 신청관리 항목의 상태만 단독으로 변경한다. 소유권 검증 후 변경하며, 트랜잭션 커밋 시 dirty checking으로 자동 반영된다. */ + @Transactional + public PolicyApplication changeStatus(Long id, Long userId, ApplicationStatus status) { + PolicyApplication application = findActive(id); + application.verifyOwner(userId); + application.changeStatus(status); + return application; + } + + /** 신청관리 항목의 개인 메모만 단독으로 수정한다. 공백/빈 문자열은 {@link #blankToNull}로 null 정규화한다. */ + @Transactional + public PolicyApplication updateMemo(Long id, Long userId, String memo) { + PolicyApplication application = findActive(id); + application.verifyOwner(userId); + application.updateMemo(blankToNull(memo)); + return application; + } + + /** 신청관리 항목의 개인 마감일만 단독으로 수정한다. null이 아니면 정책 자체의 신청 마감일을 넘지 않는지 검증한다. */ + @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; + } + + /** 로그인 사용자의 삭제되지 않은 신청관리 목록을 페이지로 조회한다. */ + @Transactional(readOnly = true) + public Page getApplications(Long userId, Pageable pageable) { + return policyApplicationRepository + .findByUser_IdAndDeletedAtIsNull(userId, pageable) + .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); + } + + /** + * 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로 통일하고, 값이 있으면 원본 그대로(가공 없이) 반환한다. */ + 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/policy/service/PolicyChatAccessService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java new file mode 100644 index 0000000..11a6060 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatAccessService.java @@ -0,0 +1,25 @@ +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 + .findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 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..1372e53 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptor.java @@ -0,0 +1,202 @@ +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.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; +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; + + /** + * 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 = + 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); + 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)) { + 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/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..4bc1412 --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyComparisonService.java @@ -0,0 +1,70 @@ +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.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 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.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + policyIds, PolicyVisibility.VISIBLE); + 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/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..94bcefc --- /dev/null +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -0,0 +1,154 @@ +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; +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.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.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; + private final SearchLogService searchLogService; + + private static final String NATIONWIDE_REGION = "전국"; + + /** + * 정책 목록(카드) 조회 (비회원 허용). 삭제·숨김·신청 마감 지난 정책은 제외한다. 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( + @Nullable String category, + @Nullable String keyword, + @Nullable String region, + @Nullable Integer ageMin, + @Nullable Integer ageMax, + @Nullable String jobCode, + Pageable pageable) { + String categoryFilter = trimToNull(category); + String keywordPattern = toLikePattern(keyword); + String regionFilter = trimToNull(region); + // '전국'은 지역 드롭다운의 "지역 무관" 선택지 — 시도명 필터를 걸지 않는다. + // 전국 대상 정책은 policy_regions에 전 시도가 들어 있어 개별 시도 조회에도 이미 포함된다. + String sidoName = NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter; + + Pageable unsorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); + + Page page = + policyRepository.findCards( + PolicyVisibility.VISIBLE, + LocalDate.now(), + categoryFilter, + keywordPattern, + sidoName, + ageMin, + 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 = + policyIds.isEmpty() + ? Map.of() + : policyRegionRepository.findWithRegionByPolicyIdIn(policyIds).stream() + .collect( + Collectors.groupingBy( + pr -> pr.getPolicy().getId(), + Collectors.mapping( + pr -> pr.getRegion().getSidoName(), + Collectors.toList()))); + + return page.map( + policy -> + PolicyCardResponse.from( + policy, + sidoNamesByPolicyId.getOrDefault(policy.getId(), List.of()).stream() + .distinct() + .sorted() + .toList())); + } + + @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 + "%"; + } + + /** + * 정책 상세 조회. 삭제(soft delete)·숨김 정책은 존재하지 않는 것으로 취급한다. 로그인 사용자({@code userId != null})의 조회는 최근 본 + * 정책으로 기록하되, 기록은 부가 동작이라 실패해도 조회는 정상 응답한다. + */ + @Transactional(readOnly = true) + public PolicyDetailResponse getDetail(Long policyId, @Nullable Long userId) { + Policy policy = + policyRepository + .findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 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 regions = + 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/RecommendedPolicyService.java b/src/main/java/com/bop/youthpick/policy/service/RecommendedPolicyService.java new file mode 100644 index 0000000..684829d --- /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.getMaritalStatus()), + 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/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/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/post/controller/PostController.java b/src/main/java/com/bop/youthpick/post/controller/PostController.java new file mode 100644 index 0000000..01abc2f --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/controller/PostController.java @@ -0,0 +1,83 @@ +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.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; +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.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "게시글") +@RestController +@RequestMapping("/api/v1/posts") +@RequiredArgsConstructor +// @RequestParam 제약(@Pattern 등)을 활성화한다. 없으면 잘못된 값이 서비스까지 내려가 +// PostCategory.valueOf에서 IllegalArgumentException → 500(S001)으로 터진다. +@Validated +public class PostController { + + private final PostService postService; + + @Operation(summary = "게시글 등록", description = "새로운 게시글을 작성하여 등록한다.") + @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 + } + + @Operation(summary = "게시글 목록 조회", description = "카테고리/검색어로 게시글 목록을 페이지 단위로 조회한다.") + @GetMapping // 리스폰스엔티티로 감싸야함 수정필요 + public ApiResponse> findAll( + @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 == null ? null : category.name(), query, pageable); + return ApiResponse.ok(page.getContent(), page); + } + + @Operation(summary = "게시글 상세 조회", description = "게시글 ID로 상세 내용을 조회한다.") + @GetMapping("/{postId}") + public ApiResponse findById( + @PathVariable Long postId, + @CurrentUser(required = false) Long userId, + HttpServletRequest request) { + return ApiResponse.ok(postService.findById(postId, userId, request.getRemoteAddr())); + } + + @Operation(summary = "게시글 수정", description = "게시글 ID로 대상 게시글의 내용을 수정한다.") + @PatchMapping("/{postId}") + public ApiResponse update( + @CurrentUser Long userId, + @PathVariable Long postId, + @Valid @RequestBody PostUpdateRequest request) { + 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); + 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..4632e11 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/dto/PostCreateRequest.java @@ -0,0 +1,27 @@ +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; +import java.util.List; + +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 = "내용은 필수입니다.") @Size(max = 10_000, message = "내용은 10,000자 이하여야 합니다.") + String content, + @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/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..4d457c5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/dto/PostSummaryResponse.java @@ -0,0 +1,46 @@ +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, + 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(); + return new PostSummaryResponse( + post.getId(), + post.getUser().getId(), + post.getUser().getNickname(), + policyId, + 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/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..46d443d --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/dto/PostUpdateRequest.java @@ -0,0 +1,27 @@ +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; +import java.util.List; + +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 = "내용은 필수입니다.") @Size(max = 10_000, message = "내용은 10,000자 이하여야 합니다.") + String content, + @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 new file mode 100644 index 0000000..bf6849b --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/entity/Attachment.java @@ -0,0 +1,35 @@ +package com.bop.youthpick.post.entity; + +import com.bop.youthpick.global.entity.BaseEntity; +import jakarta.persistence.*; +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; + + 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/entity/Post.java b/src/main/java/com/bop/youthpick/post/entity/Post.java new file mode 100644 index 0000000..56a6604 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/entity/Post.java @@ -0,0 +1,72 @@ +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.*; +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; + + 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(); + } + + public void incrementViewCount() { + this.viewCount += 1; + } +} diff --git a/src/main/java/com/bop/youthpick/post/entity/PostCategory.java b/src/main/java/com/bop/youthpick/post/entity/PostCategory.java new file mode 100644 index 0000000..85ae2f7 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/entity/PostCategory.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.post.entity; + +public enum PostCategory { + QUESTION, + REVIEW, + FREE +} 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..c68e469 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/exception/BoardErrorCode.java @@ -0,0 +1,20 @@ +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", "자유글에는 정책을 연결할 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B005", "댓글을 찾을 수 없습니다."); + + 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/AttachmentRepository.java b/src/main/java/com/bop/youthpick/post/repository/AttachmentRepository.java new file mode 100644 index 0000000..77f4982 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/repository/AttachmentRepository.java @@ -0,0 +1,12 @@ +package com.bop.youthpick.post.repository; + +import com.bop.youthpick.post.entity.Attachment; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttachmentRepository extends JpaRepository { + + List findByPostId(Long postId); + + void deleteByPostId(Long postId); +} 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..9f914ce --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java @@ -0,0 +1,30 @@ +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.domain.Specification; +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 { + + @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 findAll(Specification spec, 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/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 new file mode 100644 index 0000000..841af12 --- /dev/null +++ b/src/main/java/com/bop/youthpick/post/service/PostService.java @@ -0,0 +1,136 @@ +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.Attachment; +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.AttachmentRepository; +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; +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; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + private final AttachmentRepository attachmentRepository; + private final UserRepository userRepository; + private final PolicyRepository policyRepository; + private final PostViewLogStore postViewLogStore; + + @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 = + postRepository.save( + Post.create(user, policy, category, request.title(), request.content())); + saveAttachments(post, request.attachmentUrls()); + return PostDetailResponse.from(post); + } + + @Transactional(readOnly = true) + public Page findAll(String category, String query, Pageable pageable) { + Specification spec = PostSpecifications.search(category, query); + return postRepository.findAll(spec, pageable).map(PostSummaryResponse::from); + } + + @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(post); + } + + @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()); + attachmentRepository.deleteByPostId(postId); + saveAttachments(post, request.attachmentUrls()); + 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 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) + .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) { + // 도메인 계약: policy가 NULL이면 자유글. 정책이 연결된 자유글을 허용하면 + // 목록/상세 응답에 policyTitle이 붙어 프론트 분류가 깨지므로 명시적으로 거부한다. + if (policyId != null) { + throw new BoardException(BoardErrorCode.FREE_POST_POLICY_NOT_ALLOWED); + } + 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/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/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..3aa691f --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/dto/PolicyUpsertItem.java @@ -0,0 +1,14 @@ +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의 몫이다. + * + * @param nationwide {@code regions}가 전 시도를 커버하는지 (V13, #120). Job이 지역 마스터로 판정해 넘긴다 — Writer는 전체 시도 + * 수를 모르기 때문이다. + */ +public record PolicyUpsertItem(Policy policy, List regions, boolean nationwide) {} 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/BatchMode.java b/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java new file mode 100644 index 0000000..244944f --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/entity/BatchMode.java @@ -0,0 +1,7 @@ +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..f850fa0 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/entity/BatchStatus.java @@ -0,0 +1,8 @@ +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..fea0945 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/entity/PolicyBatchHistory.java @@ -0,0 +1,126 @@ +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; + + 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; + } + + /** 원인 불명 조기 실패용(예: API fetch 자체가 실패해 아무것도 처리 못 한 경우) — 카운트는 전부 0으로 남는다. */ + public void fail(String message) { + this.status = BatchStatus.FAILED; + this.finishedAt = LocalDateTime.now(); + 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/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/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/PolicyApiClient.java b/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java new file mode 100644 index 0000000..d37c1c5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyApiClient.java @@ -0,0 +1,130 @@ +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; + long delay = 1000; + 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; + 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( + "정책 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..660bfe1 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyMapper.java @@ -0,0 +1,391 @@ +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 실패 격리: 전처리 실패 단위 = 필드). + * + *

컬럼 길이 초과도 같은 원칙이다 — 예외 대신 절단 + 경고 로그(plcyNo, 필드, 실제 길이, 상한)로 처리한다(#208). 길이 상한 상수는 {@link + * com.bop.youthpick.policy.entity.Policy}의 {@code @Column(length = ...)}와 반드시 일치해야 하며, + * PolicyMapperLengthLimitsTest가 리플렉션으로 드리프트를 검증한다. + */ +@Component +@RequiredArgsConstructor +public class PolicyMapper { + + private static final Logger log = LoggerFactory.getLogger(PolicyMapper.class); + + /** 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 마이그레이션이 같은 규칙으로 정리한다. + */ + 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"); + + private final ObjectMapper objectMapper; + + /** 신규/변경 정책 1건 전처리. Policy.create 파라미터 순서 = Policy 필드 선언 순서. */ + public Policy toEntity(YouthPolicyItem item) { + 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 = + parseApplyPeriod( + plcyNo, item.aplyPrdSeCd(), item.aplyYmd(), businessBegin, businessEnd); + return Policy.create( + plcyNo, + truncate(plcyNo, "plcyNm", trimToNull(item.plcyNm()), TITLE_MAX_LENGTH), + truncate( + plcyNo, + "plcyExplnCn", + trimToNull(item.plcyExplnCn()), + DESCRIPTION_MAX_LENGTH), + trimToNull(item.plcySprtCn()), + 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()), + 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()), + 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()), + 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, + businessEnd, + trimToNull(item.bizPrdEtcCn()), + toInt(plcyNo, "sprtSclCnt", item.sprtSclCnt()), + toBoolean(item.sprtArvlSeqYn()), + 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()), + truncate( + plcyNo, + "sprtTrgtAgeLmtYn", + trimToNull(item.sprtTrgtAgeLmtYn()), + AGE_LIMIT_FLAG_MAX_LENGTH), + toInt(plcyNo, "earnMinAmt", item.earnMinAmt()), + toBoolean(item.sprtSclLmtYn()), + 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()), + 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; + } + + /** 대분류 정규화: 콤마 다중값은 첫 값, 가운뎃점 변형 통일 후 표준 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)는 스킵(경고 없음). 그 외 코드에서 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(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()); + 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; + } + + /** + * 컬럼 길이 상한 절단(#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) { + 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..6ed4a1a --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncScheduler.java @@ -0,0 +1,30 @@ +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.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 정책 전량 수집을 매일 1회 실행하는 스케줄러. 로컬 기본 off — 팀원이 앱을 켤 때마다 실 API를 호출하는 사고 방지 + * (`youthpick.sync.scheduler.enabled`). + */ +@Slf4j +@Component +@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..444d40f --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncService.java @@ -0,0 +1,205 @@ +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 batchTaskExecutor; + 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)); + batchTaskExecutor.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()); + + 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, + writeResult.newCount(), + writeResult.updatedCount(), + plan.unchangedCount(), + missingMarked, + writeResult.errorCount()); + 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()); + 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())); + // 전 시도 커버 판정 기준(#120). 이미 로드한 지역 마스터에서 세므로 추가 쿼리가 없다. + long totalSidoCount = + regionsByCode.values().stream().map(Region::getSidoName).distinct().count(); + + // 수집 도중 페이지 경계가 밀리면 같은 정책이 두 번 올 수 있다 — 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 + && snapshot.lastModifiedAt() != null + && policy.getLastModifiedAt() != null + && Objects.equals(snapshot.lastModifiedAt(), policy.getLastModifiedAt())) { + unchangedCount++; + continue; + } + List regions = + policyMapper.resolveRegions(policyNo, item.zipCd(), regionsByCode); + upserts.add( + new PolicyUpsertItem(policy, regions, isNationwide(regions, totalSidoCount))); + } + + // API 응답에 아예 안 나타난 정책만 누락 — SKIP(unchanged)된 정책은 존재하는 것 + List missingPolicyNos = + snapshots.keySet().stream().filter(no -> !seenPolicyNos.contains(no)).toList(); + 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/PolicySyncStartupRunner.java b/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java new file mode 100644 index 0000000..e40af0e --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicySyncStartupRunner.java @@ -0,0 +1,44 @@ +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 비어 있음 — 초기 수집을 시작합니다"); + try { + policySyncService.runFullSync(); + } catch (RuntimeException e) { + log.warn("초기 수집 실패 — 외부 API 혹은 DB 장애가 의심됩니다. 수동 실행을 수행하거나 확인하세요.", e); + } + } +} 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..872e3b5 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sync/service/PolicyUpsertWriter.java @@ -0,0 +1,145 @@ +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(), item.nationwide()); + 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)); + } + // 지역 행과 같은 트랜잭션에서 갱신한다 — 따로 두면 둘이 어긋나 정렬이 조용히 틀린다(#120). + target.applyRegionCoverage(item.nationwide()); + 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/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/UserProfileController.java b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java new file mode 100644 index 0000000..3717b0c --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/controller/UserProfileController.java @@ -0,0 +1,66 @@ +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; +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; +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; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "회원 프로필") +@RestController +@RequiredArgsConstructor +public class UserProfileController { + + private final UserProfileService userProfileService; + + // 경로의 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, + @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))); + } + + @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) { + UserProfile profile = userProfileService.update(userId, request); + return ApiResponse.ok(UserProfileResponse.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/UserProfileRequest.java b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java new file mode 100644 index 0000000..8ea388e --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/UserProfileRequest.java @@ -0,0 +1,40 @@ +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; + +/** + * categories/keywords/major/specialCondition은 온보딩 화면에서 체크박스로 다중 선택되는 값이라 콤마 문자열이 아니라 배열로 받는다. 콤마 + * 결합(저장 포맷)은 서비스 계층 책임이다. categories는 대분류 5개 중 최대 3개, keywords는 전체 19개 중 선택. + */ +public record UserProfileRequest( + @NotNull(message = "출생연도는 필수입니다.") + @Min(value = 1950, message = "출생연도가 올바르지 않습니다.") + @Max(value = 2015, message = "출생연도가 올바르지 않습니다.") + Integer birthYear, + @NotBlank(message = "거주 지역은 필수입니다.") @Size(max = 10, message = "지역 코드 형식이 올바르지 않습니다.") + String regionCode, + @NotBlank(message = "취업상태는 필수입니다.") + @Pattern(regexp = EmploymentStatus.PATTERN, message = "취업상태 코드 형식이 올바르지 않습니다.") + String employmentStatus, + @NotBlank(message = "학력은 필수입니다.") + @Pattern(regexp = EducationLevel.PATTERN, message = "학력 코드 형식이 올바르지 않습니다.") + String educationLevel, + @Pattern(regexp = MaritalStatus.PATTERN, message = "결혼여부 코드 형식이 올바르지 않습니다.") + String maritalStatus, + 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/dto/UserProfileResponse.java b/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java new file mode 100644 index 0000000..5de4035 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/dto/UserProfileResponse.java @@ -0,0 +1,44 @@ +package com.bop.youthpick.user.dto; + +import com.bop.youthpick.user.entity.UserProfile; +import java.util.Arrays; +import java.util.List; + +public record UserProfileResponse( + Long id, + Long userId, + Integer birthYear, + String regionCode, + String employmentStatus, + String educationLevel, + String maritalStatus, + List major, + List specialCondition, + Integer income, + List categories, + List keywords, + String status) { + public static UserProfileResponse from(UserProfile profile) { + return new UserProfileResponse( + profile.getId(), + profile.getUser().getId(), + profile.getBirthYear(), + profile.getRegion().getCode(), + profile.getEmploymentStatus(), + profile.getEducationLevel(), + profile.getMaritalStatus(), + splitToList(profile.getMajor()), + splitToList(profile.getSpecialCondition()), + profile.getIncome(), + 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/.gitkeep b/src/main/java/com/bop/youthpick/user/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 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/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/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/Role.java b/src/main/java/com/bop/youthpick/user/entity/Role.java new file mode 100644 index 0000000..d7491fc --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/Role.java @@ -0,0 +1,6 @@ +package com.bop.youthpick.user.entity; + +public enum Role { + USER, + ADMIN +} 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/entity/User.java b/src/main/java/com/bop/youthpick/user/entity/User.java new file mode 100644 index 0000000..ac9106c --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/User.java @@ -0,0 +1,70 @@ +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; + + 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; + } + + 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/entity/UserProfile.java b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java new file mode 100644 index 0000000..8c022fb --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/entity/UserProfile.java @@ -0,0 +1,124 @@ +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, nullable = false) + private String employmentStatus; + + @Column(name = "education_level", length = 16, nullable = false) + private String educationLevel; + + @Column(name = "marital_status", length = 16) + private String maritalStatus; + + @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; + + @Column(length = 700) + private String keywords; + + @Column(length = 20, nullable = false) + private String status = "COMPLETED"; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public static UserProfile create( + User user, + Region region, + Integer birthYear, + String employmentStatus, + String educationLevel, + String maritalStatus, + String major, + String specialCondition, + Integer income, + String categories, + String keywords) { + UserProfile profile = new UserProfile(); + profile.user = user; + profile.region = region; + profile.birthYear = birthYear; + profile.employmentStatus = employmentStatus; + profile.educationLevel = educationLevel; + profile.maritalStatus = maritalStatus; + profile.major = major; + profile.specialCondition = specialCondition; + profile.income = income; + profile.categories = categories; + profile.keywords = keywords; + return profile; + } + + public void update( + Region region, + Integer birthYear, + String employmentStatus, + String educationLevel, + String maritalStatus, + String major, + String specialCondition, + Integer income, + String categories, + String keywords) { + this.region = region; + this.birthYear = birthYear; + this.employmentStatus = employmentStatus; + this.educationLevel = educationLevel; + this.maritalStatus = maritalStatus; + 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 new file mode 100644 index 0000000..83caf65 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/exception/UserError.java @@ -0,0 +1,22 @@ +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", "이미 사용 중인 아이디입니다."), + PROFILE_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 온보딩 프로필이 등록되어 있습니다."), + // 프로필 수정(PATCH)과 맞춤정책 조회(RecommendedPolicyService) 양쪽에서 쓴다 — 둘 다 "온보딩 프로필이 아직 없음"이라 + // 같은 코드로 통일한다. + PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "U004", "온보딩 프로필을 찾을 수 없습니다."); + + 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); + } +} 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/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/repository/UserProfileRepository.java b/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java new file mode 100644 index 0000000..8ac14c9 --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/repository/UserProfileRepository.java @@ -0,0 +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 new file mode 100644 index 0000000..f06614e --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.bop.youthpick.user.repository; + +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, JpaSpecificationExecutor { + + Optional findByProviderAndProviderId(String provider, String providerId); + + Optional findByIdAndDeletedAtIsNull(Long id); +} 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/UserProfileService.java b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java new file mode 100644 index 0000000..dc6110f --- /dev/null +++ b/src/main/java/com/bop/youthpick/user/service/UserProfileService.java @@ -0,0 +1,105 @@ +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.UserProfileRequest; +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 UserProfileService { + + private final UserRepository userRepository; + 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, UserProfileRequest 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(), + request.maritalStatus(), + joinToCommaString(request.major()), + joinToCommaString(request.specialCondition()), + request.income(), + 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); + } + } + + @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.maritalStatus(), + 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; + } + return String.join(",", values); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 17c9202..ee634da 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,20 +1,47 @@ +# 로컬 개발: 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} 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을 반드시 설정한다. + 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 + sync: + startup-on-empty: true # policies 비어 있으면 앱 시작 시 초기 수집 1회 (기본 off) oauth: + # provider 콘솔에 등록하는 redirect_uri와 동일해야 하는 프론트엔드 콜백 라우트 + frontend-callback-uri: ${OAUTH_FRONTEND_CALLBACK_URI:http://localhost:3000/oauth/callback} providers: google: client-id: ${GOOGLE_OAUTH_CLIENT_ID:} @@ -24,4 +51,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.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..f80e298 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,64 @@ +spring: + profiles: + active: local + flyway: + # 기본 비활성(안전 기본값) — 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·PolicyApiClient 등 자동구성된 RestClient.Builder에 전역 적용. provider가 응답이 없을 때 + # 스레드/DB 커넥션을 오래 붙잡지 않도록 한다. + # read-timeout은 "연속 무응답" 기준(소켓 SO_TIMEOUT)이라 배치의 대용량 페이지 수신도 + # 스트림이 흐르는 한 걸리지 않는다 — 배치 실측(페이지당 ~2초)과도 여유 있음. + connect-timeout: 3s + read-timeout: 5s + data: + web: + pageable: + # 프론트가 페이지 번호를 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} + # multipart boundary/header 오버헤드를 고려해 실제 파일 제한보다 여유를 둔다. + 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: + 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가 필수. + # 로컬(application-local.yml)은 http라 secure=false, localhost 동일 사이트라 SameSite=Lax로 완화한다. + path: /api/v1/auth + secure: true + 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 * * *" + +server: + forward-headers-strategy: framework + diff --git a/src/main/resources/db/migration/V10__update_policy_applications_status_comment.sql b/src/main/resources/db/migration/V10__update_policy_applications_status_comment.sql new file mode 100644 index 0000000..205670d --- /dev/null +++ b/src/main/resources/db/migration/V10__update_policy_applications_status_comment.sql @@ -0,0 +1,10 @@ +-- ============================================================ +-- V10 — 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/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/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/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/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/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/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/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/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/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/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/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/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/main/resources/db/migration/V6__add_policies_deleted_at.sql b/src/main/resources/db/migration/V6__add_policies_deleted_at.sql new file mode 100644 index 0000000..ef801cb --- /dev/null +++ b/src/main/resources/db/migration/V6__add_policies_deleted_at.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- V6 — 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/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/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/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/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', '전북특별자치도', '부안군'); diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..c6d7d1b --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java b/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java index 1e8682b..72fe78a 100644 --- a/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java +++ b/src/test/java/com/bop/youthpick/YouthpickApplicationTests.java @@ -7,7 +7,5 @@ class YouthpickApplicationTests { @Test - void contextLoads() { - } - + void contextLoads() {} } 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 new file mode 100644 index 0000000..aaf6282 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityCommentControllerTest.java @@ -0,0 +1,49 @@ +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.admin.board.dto.AdminCommunityCommentResponse; +import com.bop.youthpick.admin.board.service.AdminCommunityService; +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; +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와_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("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 new file mode 100644 index 0000000..52d858e --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/board/controller/AdminCommunityPostControllerTest.java @@ -0,0 +1,141 @@ +package com.bop.youthpick.admin.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.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.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; +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", + 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/admin/board/repository/AdminCommunityPostSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecificationsTest.java new file mode 100644 index 0000000..2dfc9fe --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/board/repository/AdminCommunityPostSpecificationsTest.java @@ -0,0 +1,103 @@ +package com.bop.youthpick.admin.board.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.post.repository.PostRepository; +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/admin/board/service/AdminCommunityServiceTest.java b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java new file mode 100644 index 0000000..72d6750 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/board/service/AdminCommunityServiceTest.java @@ -0,0 +1,155 @@ +package com.bop.youthpick.admin.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.*; + +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.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); + } +} diff --git a/src/test/java/com/bop/youthpick/admin/log/controller/AdminAppLogControllerTest.java b/src/test/java/com/bop/youthpick/admin/log/controller/AdminAppLogControllerTest.java new file mode 100644 index 0000000..5511da7 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/log/controller/AdminAppLogControllerTest.java @@ -0,0 +1,76 @@ +package com.bop.youthpick.admin.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.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; +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/admin/log/controller/AdminSearchLogControllerTest.java b/src/test/java/com/bop/youthpick/admin/log/controller/AdminSearchLogControllerTest.java new file mode 100644 index 0000000..21f9c11 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/log/controller/AdminSearchLogControllerTest.java @@ -0,0 +1,48 @@ +package com.bop.youthpick.admin.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.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; +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/admin/log/repository/AdminAppLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecificationsTest.java new file mode 100644 index 0000000..16a1121 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/log/repository/AdminAppLogSpecificationsTest.java @@ -0,0 +1,89 @@ +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; +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) { + 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"); + ApplicationLog saved = appLogRepository.saveAndFlush(appLog); + + entityManager + .getEntityManager() + .createNativeQuery("UPDATE application_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(ApplicationLog::getTraceId).containsExactly("trace-2"); + } + + @Test + void keyword로_message_traceId_uri_부분일치를_검색한다() { + List result = + appLogRepository.findAll( + AdminAppLogSpecifications.filter(null, "timeout", null, null)); + + assertThat(result).extracting(ApplicationLog::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(ApplicationLog::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/admin/log/repository/AdminSearchLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecificationsTest.java new file mode 100644 index 0000000..c5683c1 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/log/repository/AdminSearchLogSpecificationsTest.java @@ -0,0 +1,78 @@ +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; +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/admin/log/service/AdminAppLogServiceTest.java b/src/test/java/com/bop/youthpick/admin/log/service/AdminAppLogServiceTest.java new file mode 100644 index 0000000..b0d6e07 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/log/service/AdminAppLogServiceTest.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.admin.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.ApplicationLog; +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으로_넘겨_페이지를_반환한다() { + ApplicationLog appLog = mock(ApplicationLog.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); + } +} diff --git a/src/test/java/com/bop/youthpick/admin/log/service/AdminSearchLogServiceTest.java b/src/test/java/com/bop/youthpick/admin/log/service/AdminSearchLogServiceTest.java new file mode 100644 index 0000000..7f05b57 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/log/service/AdminSearchLogServiceTest.java @@ -0,0 +1,45 @@ +package com.bop.youthpick.admin.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); + } +} diff --git a/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationControllerTest.java b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationControllerTest.java new file mode 100644 index 0000000..ad5ad56 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyApplicationControllerTest.java @@ -0,0 +1,130 @@ +package com.bop.youthpick.admin.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.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.entity.ApplicationStatus; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +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/admin/policy/controller/AdminPolicyControllerTest.java b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java new file mode 100644 index 0000000..ebaad0e --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminPolicyControllerTest.java @@ -0,0 +1,201 @@ +package com.bop.youthpick.admin.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.admin.policy.dto.AdminPolicyResponse; +import com.bop.youthpick.admin.policy.service.AdminPolicyService; +import com.bop.youthpick.global.error.CustomException; +import com.bop.youthpick.policy.entity.PolicyVisibility; +import com.bop.youthpick.policy.exception.PolicyErrorCode; +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, + true, + 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()) + .andExpect(jsonPath("$.data.adminHidden").value(true)); + } + + @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, + false, + 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/admin/policy/controller/AdminRegionControllerTest.java b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminRegionControllerTest.java new file mode 100644 index 0000000..696dfc1 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/policy/controller/AdminRegionControllerTest.java @@ -0,0 +1,38 @@ +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 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/admin/policy/repository/AdminPolicyApplicationSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecificationsTest.java new file mode 100644 index 0000000..4490b4a --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicyApplicationSpecificationsTest.java @@ -0,0 +1,201 @@ +package com.bop.youthpick.admin.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.policy.repository.PolicyApplicationRepository; +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/admin/policy/repository/AdminPolicySpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java new file mode 100644 index 0000000..9ca8e75 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/policy/repository/AdminPolicySpecificationsTest.java @@ -0,0 +1,126 @@ +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; +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)); + Policy hiddenByAdmin = newPolicy("P004", "주거", PolicyVisibility.VISIBLE, null, null); + hiddenByAdmin.hideByAdmin(); + policyRepository.save(hiddenByAdmin); + } + + // 배치 수집(온통청년 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) + .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 + 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(4); + } +} 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 new file mode 100644 index 0000000..da4d67b --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyApplicationServiceTest.java @@ -0,0 +1,143 @@ +package com.bop.youthpick.admin.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.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.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class AdminPolicyApplicationServiceTest { + + @Mock private PolicyApplicationRepository policyApplicationRepository; + + @Mock private PolicyApplicationChecklistRepository 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))); + 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.findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + eq(APPLICATION_ID), any(Pageable.class))) + .thenReturn(new PageImpl<>(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); + } +} 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 new file mode 100644 index 0000000..a6afa56 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/policy/service/AdminPolicyServiceTest.java @@ -0,0 +1,182 @@ +package com.bop.youthpick.admin.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.admin.policy.dto.AdminPolicyUpdateRequest; +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.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.findAllById(List.of("11110"))).thenReturn(List.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).saveAll(any()); + } + + @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.findAllById(List.of("11110"))).thenReturn(List.of()); + + 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).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 + 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); + } +} diff --git a/src/test/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogControllerTest.java b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogControllerTest.java new file mode 100644 index 0000000..8b1c699 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminBatchJobLogControllerTest.java @@ -0,0 +1,63 @@ +package com.bop.youthpick.admin.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.admin.sync.dto.BatchJobLogResponse; +import com.bop.youthpick.admin.sync.service.AdminBatchJobLogService; +import com.bop.youthpick.sync.entity.BatchStatus; +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/admin/sync/controller/AdminPolicySyncControllerTest.java b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncControllerTest.java new file mode 100644 index 0000000..cd437d1 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncControllerTest.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.admin.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/admin/sync/controller/AdminPolicySyncJobControllerTest.java b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobControllerTest.java new file mode 100644 index 0000000..4e94cbe --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/sync/controller/AdminPolicySyncJobControllerTest.java @@ -0,0 +1,37 @@ +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.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; +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/admin/sync/repository/AdminBatchJobLogSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecificationsTest.java new file mode 100644 index 0000000..3374c96 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/sync/repository/AdminBatchJobLogSpecificationsTest.java @@ -0,0 +1,72 @@ +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; +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/admin/sync/service/AdminBatchJobLogServiceTest.java b/src/test/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogServiceTest.java new file mode 100644 index 0000000..97fbfbe --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/sync/service/AdminBatchJobLogServiceTest.java @@ -0,0 +1,46 @@ +package com.bop.youthpick.admin.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); + } +} diff --git a/src/test/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobServiceTest.java b/src/test/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobServiceTest.java new file mode 100644 index 0000000..80cab5c --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/sync/service/AdminPolicySyncJobServiceTest.java @@ -0,0 +1,41 @@ +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 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(); + } +} diff --git a/src/test/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryControllerTest.java b/src/test/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryControllerTest.java new file mode 100644 index 0000000..7a8564f --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/user/controller/AdminLoginHistoryControllerTest.java @@ -0,0 +1,48 @@ +package com.bop.youthpick.admin.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.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; +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/admin/user/controller/AdminUserControllerTest.java b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java new file mode 100644 index 0000000..e89de3e --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/user/controller/AdminUserControllerTest.java @@ -0,0 +1,165 @@ +package com.bop.youthpick.admin.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.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 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", + "SINGLE", + List.of("COMPUTER_SCIENCE"), + List.of("LOW_INCOME"), + 3000, + 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.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)) + .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/admin/user/repository/AdminLoginHistorySpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecificationsTest.java new file mode 100644 index 0000000..72cdd00 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/user/repository/AdminLoginHistorySpecificationsTest.java @@ -0,0 +1,85 @@ +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; +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/admin/user/repository/AdminUserSpecificationsTest.java b/src/test/java/com/bop/youthpick/admin/user/repository/AdminUserSpecificationsTest.java new file mode 100644 index 0000000..59d3e90 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/user/repository/AdminUserSpecificationsTest.java @@ -0,0 +1,82 @@ +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; +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/admin/user/service/AdminLoginHistoryServiceTest.java b/src/test/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryServiceTest.java new file mode 100644 index 0000000..bd7ef05 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/user/service/AdminLoginHistoryServiceTest.java @@ -0,0 +1,49 @@ +package com.bop.youthpick.admin.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); + } +} 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 new file mode 100644 index 0000000..95102a7 --- /dev/null +++ b/src/test/java/com/bop/youthpick/admin/user/service/AdminUserServiceTest.java @@ -0,0 +1,167 @@ +package com.bop.youthpick.admin.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.auth.service.RefreshTokenStore; +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; + + @Mock private RefreshTokenStore refreshTokenStore; + + private AdminUserService adminUserService; + + private static final Long USER_ID = 1L; + + @BeforeEach + void setUp() { + adminUserService = + new AdminUserService(userRepository, userProfileRepository, refreshTokenStore); + } + + @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("강남구"); + when(profile.getMaritalStatus()).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.maritalStatus()).isEqualTo("SINGLE"); + assertThat(result.major()).containsExactly("COMPUTER_SCIENCE"); + assertThat(result.specialCondition()).containsExactly("LOW_INCOME"); + assertThat(result.income()).isEqualTo(3000); + } + + @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 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()); + + assertThatThrownBy(() -> adminUserService.softDelete(USER_ID)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.USER_NOT_FOUND); + } +} 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..31f3187 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/controller/AuthControllerTest.java @@ -0,0 +1,220 @@ +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.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; +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; + + @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")) + .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_쿠키가_있으면_access_token만_재발급하고_refresh_쿠키는_다시_내려가지_않는다() throws Exception { + when(authService.refresh("old-refresh-token")) + .thenReturn(TokenResponse.of("new-access", "old-refresh-token", 1800)); + + 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().doesNotExist(RefreshTokenCookieSupport.COOKIE_NAME)); + + verify(refreshTokenCookieSupport, never()).issue(anyString()); + } + + @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..904eeef --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/AuthServiceTest.java @@ -0,0 +1,307 @@ +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.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 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; + @Mock private LoginHistoryRepository loginHistoryRepository; + + 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, + loginHistoryRepository); + } + + @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()); + verify(loginHistoryRepository, never()).save(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)); + verify(loginHistoryRepository).save(any(LoginHistory.class)); + 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()); + 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.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + 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이_유효하면_회전없이_access_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.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); + + TokenResponse tokens = authService.refresh("refresh-token"); + + assertThat(tokens.accessToken()).isEqualTo("new-access"); + assertThat(tokens.refreshToken()).isEqualTo("refresh-token"); + verify(jwtTokenProvider, never()).createRefreshToken(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", "닉네임")); + 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"); + verify(loginHistoryRepository).save(any(LoginHistory.class)); + } + + @Test + void 로그아웃하면_refresh_token을_삭제한다() { + authService.logout(1L); + + 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<>(); + 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..a78dfd6 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/CurrentUserArgumentResolverTest.java @@ -0,0 +1,107 @@ +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); + } + + @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); + } + + 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/auth/service/JwtAuthenticationFilterTest.java b/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..50bb50d --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/JwtAuthenticationFilterTest.java @@ -0,0 +1,101 @@ +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 비동기_재디스패치에서도_JWT_인증을_수행한다() { + assertThat(filter.shouldNotFilterAsyncDispatch()).isFalse(); + } + + @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..e194a64 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/JwtTokenProviderTest.java @@ -0,0 +1,95 @@ +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) + .extracting(ex -> ((AuthException) ex).getErrorCode()) + .isEqualTo(com.bop.youthpick.auth.exception.AuthErrorCode.EXPIRED_TOKEN); + } + + 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..ede2559 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/OAuthStateStoreTest.java @@ -0,0 +1,69 @@ +package com.bop.youthpick.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +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.getAndDelete("auth:oauth-state:state-1")).thenReturn("GOOGLE"); + + boolean consumed = oAuthStateStore.consume("state-1", "GOOGLE"); + + assertThat(consumed).isTrue(); + verify(valueOperations).getAndDelete("auth:oauth-state:state-1"); + } + + @Test + void provider가_다르면_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.getAndDelete("auth:oauth-state:state-1")).thenReturn("KAKAO"); + + boolean consumed = oAuthStateStore.consume("state-1", "GOOGLE"); + + assertThat(consumed).isFalse(); + } + + @Test + void state가_없으면_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + 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(); + } +} 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..4963b57 --- /dev/null +++ b/src/test/java/com/bop/youthpick/auth/service/RefreshTokenStoreTest.java @@ -0,0 +1,78 @@ +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.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.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; + +@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 저장된_해시와_일치하면_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 저장된_값과_불일치하면_matches가_false를_반환한다() { + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("auth:refresh-token:1")).thenReturn("different-hash"); + + 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(refreshTokenStore.matches(1L, "refresh-token")).isFalse(); + } + + @Test + void 삭제하면_userId_키를_지운다() { + refreshTokenStore.delete(1L); + + verify(redisTemplate).delete("auth:refresh-token:1"); + } +} 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; + } +} 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/common/ApiResponseTest.java b/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java new file mode 100644 index 0000000..77a532d --- /dev/null +++ b/src/test/java/com/bop/youthpick/global/common/ApiResponseTest.java @@ -0,0 +1,38 @@ +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 { + // 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(2); + assertThat(meta.get("totalCount").asLong()).isEqualTo(5); + assertThat(meta.get("totalPages").asInt()).isEqualTo(3); + } +} 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 new file mode 100644 index 0000000..346fc78 --- /dev/null +++ b/src/test/java/com/bop/youthpick/global/config/SecurityConfigTest.java @@ -0,0 +1,169 @@ +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.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.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_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()); + } + + @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 정책_상세는_공개지만_정책_채팅_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(); + + 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(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(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()); + } + + @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/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")); + } +} 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..64e0d89 --- /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.create( + 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..7c68375 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyApplicationControllerTest.java @@ -0,0 +1,433 @@ +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.create( + mock(User.class), + mock(Policy.class), + ApplicationStatus.INTERESTED, + "메모", + null); + when(policyApplicationService.create( + 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.create( + mock(User.class), + mock(Policy.class), + ApplicationStatus.PREPARING, + null, + null); + when(policyApplicationService.create( + 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.create( + 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.create( + 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.create( + 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) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"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) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"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) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("C001"))); + } + + @Test + void updateMemo_빈_문자열이면_메모를_비운다() throws Exception { + PolicyApplication application = + PolicyApplication.create( + 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) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memo\":\"\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memo").value(""))); + } + + @Test + void updateEndAt_유효한_요청이면_200과_수정된_마감일을_반환한다() throws Exception { + PolicyApplication application = + PolicyApplication.create( + 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) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"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.create( + 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) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"endAt\":null}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.endAt").doesNotExist())); + } + + @Test + void updateEndAt_파라미터를_생략해도_마감일을_비운다() throws Exception { + PolicyApplication application = + PolicyApplication.create( + 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) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .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) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"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/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/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/controller/PolicyControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java new file mode 100644 index 0000000..f0ba22b --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/controller/PolicyControllerTest.java @@ -0,0 +1,130 @@ +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.dto.RegionResponse; +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, + "0013010", + "0043010", + null, + null, + null, + "0055010", + "0066010", + "0077010", + 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(new RegionResponse("11680", "서울특별시", "강남구"))); + + @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.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("강남구")); + } + + @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/controller/RegionControllerTest.java b/src/test/java/com/bop/youthpick/policy/controller/RegionControllerTest.java new file mode 100644 index 0000000..05f9da2 --- /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.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; +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 RegionService regionService; + + @Test + void 지역_목록은_200과_전체_목록을_반환한다() throws Exception { + RegionResponse response = new RegionResponse("11110", "서울특별시", "종로구"); + when(regionService.getAllRegions()).thenReturn(List.of(response)); + + 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/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..4f5bacc --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/entity/PolicyTest.java @@ -0,0 +1,60 @@ +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); + } + + @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/PolicyApplicationChecklistRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepositoryTest.java new file mode 100644 index 0000000..dc5da6e --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyApplicationChecklistRepositoryTest.java @@ -0,0 +1,103 @@ +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.PolicyApplicationChecklist; +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.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PolicyApplicationChecklistRepositoryTest { + + @Autowired private PolicyApplicationChecklistRepository 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) { + PolicyApplicationChecklist item = + BeanUtils.instantiateClass(PolicyApplicationChecklist.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 + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .getContent(); + + assertThat(result) + .extracting(PolicyApplicationChecklist::getContent) + .containsExactly("서류 A", "서류 B", "서류 C"); + } + + @Test + void 관리_해제된_체크리스트는_조회에서_제외된다() { + PolicyApplicationChecklist target = + applicationChecklistRepository + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .getContent() + .get(0); + ReflectionTestUtils.setField(target, "deletedAt", LocalDateTime.now()); + entityManager.persistAndFlush(target); + entityManager.clear(); + + List result = + applicationChecklistRepository + .findByApplication_IdAndDeletedAtIsNullOrderByIdAsc( + applicationId, Pageable.unpaged()) + .getContent(); + + assertThat(result).hasSize(2); + assertThat(result) + .extracting(PolicyApplicationChecklist::getContent) + .containsExactly("서류 B", "서류 C"); + } +} 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..b00f75d --- /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.create(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 created = + policyApplicationService.create( + user.getId(), policy.getId(), ApplicationStatus.INTERESTED, null, null); + Long applicationId = created.getId(); + policyApplicationService.delete(applicationId, user.getId()); + entityManager.flush(); + entityManager.clear(); + + PolicyApplication reactivated = + policyApplicationService.create( + 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/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/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/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/repository/PolicyRepositoryTest.java b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java new file mode 100644 index 0000000..0ac6ea9 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/repository/PolicyRepositoryTest.java @@ -0,0 +1,379 @@ +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.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; +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.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +// @DataJpaTest는 슬라이스 스캔 대상에서 일반 @Configuration을 제외하므로, BaseEntity의 +// createdAt/updatedAt을 채우는 JpaAuditingConfig(@EnableJpaAuditing)를 직접 import한다. +@DataJpaTest +@Import(JpaAuditingConfig.class) +class PolicyRepositoryTest { + + @Autowired private PolicyRepository policyRepository; + @Autowired private RegionRepository regionRepository; + @Autowired private PolicyRegionRepository policyRegionRepository; + + @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)); + 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); + } + + // 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 관리자_숨김_정책은_공개_카드와_상세_조회에서_제외한다() { + 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); + // 신청기간 정보가 없는(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, + null, + PageRequest.of(0, 20)) + .getContent(); + + assertThat(result) + .extracting(Policy::getPolicyNo) + .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()); + } + + @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()); + } + + @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); + 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); + ReflectionTestUtils.setField(policy, "policyNo", policyNo); + ReflectionTestUtils.setField(policy, "title", policyNo + " title"); + ReflectionTestUtils.setField(policy, "visibility", visibility); + 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/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/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 new file mode 100644 index 0000000..d68f9c7 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationChecklistServiceTest.java @@ -0,0 +1,292 @@ +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.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 PolicyApplicationService policyApplicationService; + + 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, policyApplicationService); + } + + /** 소유자가 USER_ID인 활성 신청. owner.getId()는 소유권 검증에 안 걸리는 테스트에선 안 쓰일 수 있어 lenient로 둔다. */ + private PolicyApplication application() { + User owner = mock(User.class); + lenient().when(owner.getId()).thenReturn(USER_ID); + return PolicyApplication.create( + owner, mock(Policy.class), ApplicationStatus.APPLIED, null, null); + } + + @Test + void add_체크리스트를_정상적으로_생성한다() { + PolicyApplication application = application(); + when(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(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(policyApplicationService.findActive(APPLICATION_ID)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + + 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(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(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.findActiveWithApplicationById(5L)) + .thenReturn(Optional.of(checklist)); + + PolicyApplicationChecklist result = checklistService.update(5L, USER_ID, "서류 다시 준비"); + + assertThat(result.getContent()).isEqualTo("서류 다시 준비"); + } + + @Test + void update_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + when(applicationChecklistRepository.findActiveWithApplicationById(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.findActiveWithApplicationById(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 check_체크상태로_변경한다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findActiveWithApplicationById(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.findActiveWithApplicationById(5L)) + .thenReturn(Optional.of(checklist)); + + checklistService.uncheck(5L, USER_ID); + + assertThat(checklist.isChecked()).isFalse(); + } + + @Test + void check_대상이_없으면_CHECKLIST_NOT_FOUND_예외를_던진다() { + when(applicationChecklistRepository.findActiveWithApplicationById(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.findActiveWithApplicationById(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.findActiveWithApplicationById(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.findActiveWithApplicationById(5L)) + .thenReturn(Optional.of(checklist)); + + checklistService.delete(5L, USER_ID); + + assertThat(checklist.getDeletedAt()).isNotNull(); + } + + // "상위 신청관리가 삭제된 고아 체크리스트 → CHECKLIST_NOT_FOUND" 케이스는 이제 리포지토리 쿼리 + // (findActiveWithApplicationById의 부모 deleted_at 조건)가 담당한다. 목으로는 JPA 프록시/쿼리 동작이 + // 재현되지 않으므로 H2 통합 테스트(PolicyApplicationChecklistOrphanIntegrationTest)에서 검증한다. + + @Test + void check_소유자가_아니면_FORBIDDEN_예외를_던진다() { + PolicyApplicationChecklist checklist = + PolicyApplicationChecklist.create(application(), "제출 서류 준비"); + when(applicationChecklistRepository.findActiveWithApplicationById(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.findActiveWithApplicationById(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.findActiveWithApplicationById(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(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(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(policyApplicationService.findActive(APPLICATION_ID)) + .thenThrow(new CustomException(PolicyErrorCode.POLICY_APPLICATION_NOT_FOUND)); + + 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(policyApplicationService.findActive(APPLICATION_ID)).thenReturn(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..9b66bcb --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyApplicationServiceTest.java @@ -0,0 +1,632 @@ +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.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.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.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.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)); + + PolicyApplication result = + policyApplicationService.create( + 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.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyApplicationRepository.save(any(PolicyApplication.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PolicyApplication result = + policyApplicationService.create( + 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.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .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.create( + 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.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .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.create( + 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.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + + LocalDateTime tooLateEndAt = LocalDateTime.of(2026, 9, 1, 0, 0); + assertThatThrownBy( + () -> + policyApplicationService.create( + 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.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.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)); + + PolicyApplication result = + policyApplicationService.create( + USER_ID, POLICY_ID, ApplicationStatus.INTERESTED, " ", null); + + assertThat(result.getMemo()).isNull(); + } + + @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), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + assertThatThrownBy( + () -> + policyApplicationService.create( + 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된_행이_있으면_재활성화한다() { + 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), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + existing.delete(); + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = + policyApplicationService.create( + 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)); + 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); + existing.delete(); + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + PolicyApplication result = + policyApplicationService.create( + USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); + + assertThat(result.getEndAt()).isEqualTo(LocalDateTime.of(2026, 10, 1, 0, 0)); + } + + @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), + mock(Policy.class), + ApplicationStatus.INTERESTED, + null, + null); + existing.delete(); + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.of(existing)); + + policyApplicationService.create(USER_ID, POLICY_ID, ApplicationStatus.APPLIED, "재등록", null); + + verify(policyApplicationChecklistRepository).softDeleteAllByApplicationId(existing.getId()); + } + + @Test + void 저장_시점에_동시요청으로_유니크_제약이_위반되면_POLICY_ALREADY_EXISTS_예외를_던진다() { + when(policyApplicationRepository.findIncludingDeletedByUserIdAndPolicyId( + USER_ID, POLICY_ID)) + .thenReturn(Optional.empty()); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.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")); + + assertThatThrownBy( + () -> + policyApplicationService.create( + 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(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + policyApplicationService.create( + 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(userRepository.findById(USER_ID)).thenReturn(Optional.of(mock(User.class))); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + POLICY_ID, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + policyApplicationService.create( + 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.create( + 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.create( + 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.create( + 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.create( + 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.create( + 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.create( + 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.create( + 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.create(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.create( + 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.create( + 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.create( + 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(); + verify(policyApplicationChecklistRepository).softDeleteAllByApplicationId(10L); + } + + @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.create( + 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.create( + 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/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..b460a8f --- /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.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 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..e173280 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyChatInboundInterceptorTest.java @@ -0,0 +1,263 @@ +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.beans.factory.ObjectProvider; +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; + private MessageChannel clientOutboundChannel; + + @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(); + clientOutboundChannel = mock(MessageChannel.class); + @SuppressWarnings("unchecked") + ObjectProvider clientOutboundChannelProvider = mock(ObjectProvider.class); + when(clientOutboundChannelProvider.getObject()).thenReturn(clientOutboundChannel); + interceptor = + new PolicyChatInboundInterceptor( + jwtTokenProvider, + userRepository, + accessService, + registry, + clientOutboundChannelProvider); + 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(); + } + + @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, + 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"); + } +} 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..3e583f5 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyComparisonServiceTest.java @@ -0,0 +1,135 @@ +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.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 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.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(2L, 1L), PolicyVisibility.VISIBLE)) + .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.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 2L), PolicyVisibility.VISIBLE)) + .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()) + .findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 1L), PolicyVisibility.VISIBLE); + } + + @Test + void 존재하지_않는_정책이_섞여있으면_POLICY_NOT_FOUND_예외를_던진다() { + Policy first = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 2L), PolicyVisibility.VISIBLE)) + .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.findAllByIdInAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + List.of(1L, 2L, 3L), PolicyVisibility.VISIBLE)) + .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); + } +} 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..4f7cba4 --- /dev/null +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -0,0 +1,343 @@ +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.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; +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; +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.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) +class PolicyServiceTest { + + @Mock private PolicyRepository policyRepository; + @Mock private PolicyRegionRepository policyRegionRepository; + @Mock private PolicyRecentViewService policyRecentViewService; + @Mock private SearchLogService searchLogService; + + private PolicyService policyService; + + @BeforeEach + void setUp() { + policyService = + new PolicyService( + policyRepository, + policyRegionRepository, + policyRecentViewService, + searchLogService); + } + + @Test + void 노출_중인_정책이면_상세와_지역을_이름까지_반환한다() { + Policy policy = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findWithRegionByPolicyIdIn(List.of(1L))) + .thenReturn(List.of(newPolicyRegion(policy, "11680", "서울특별시", "강남구"))); + + PolicyDetailResponse response = policyService.getDetail(1L, null); + + 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 + void 없거나_삭제_숨김된_정책이면_POLICY_NOT_FOUND_예외를_던진다() { + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 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.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findWithRegionByPolicyIdIn(anyList())).thenReturn(List.of()); + + policyService.getDetail(1L, 7L); + + verify(policyRecentViewService).record(7L, policy); + } + + @Test + void 기록_저장이_실패해도_상세_조회는_정상_응답한다() { + Policy policy = newPolicy(1L, "청년 월세 지원"); + when(policyRepository.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findWithRegionByPolicyIdIn(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.findByIdAndVisibilityAndAdminHiddenFalseAndDeletedAtIsNull( + 1L, PolicyVisibility.VISIBLE)) + .thenReturn(Optional.of(policy)); + when(policyRegionRepository.findWithRegionByPolicyIdIn(anyList())).thenReturn(List.of()); + + policyService.getDetail(1L, null); + + 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 allSido = newPolicy(4L, "전 시도 정책"); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + pageableCaptor.capture())) + .thenReturn( + new PageImpl<>( + List.of(single, noRegion, multi, allSido), + PageRequest.of(0, 20), + 4)); + // 전국/외 N 같은 표시 문구는 더 이상 조립하지 않는다 — 시도명 목록(중복 제거·정렬)만 그대로 내려준다. + when(policyRegionRepository.findWithRegionByPolicyIdIn(List.of(1L, 2L, 3L, 4L))) + .thenReturn( + List.of( + newPolicyRegion(single, "11680", "서울특별시", "강남구"), + newPolicyRegion(multi, "11680", "서울특별시", "강남구"), + newPolicyRegion(multi, "26110", "부산광역시", "중구"), + newPolicyRegion(allSido, "11680", "서울특별시", "강남구"), + newPolicyRegion(allSido, "26110", "부산광역시", "중구"), + newPolicyRegion(allSido, "27110", "대구광역시", "중구"))); + + Page page = + policyService.getCards(null, null, null, null, null, null, PageRequest.of(0, 20)); + + // 정렬은 리포지토리 쿼리(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("한 시도 정책"); + 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( + List.of("서울특별시"), + List.of(), + List.of("부산광역시", "서울특별시"), + List.of("대구광역시", "부산광역시", "서울특별시")); + } + + @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)); + + policyService.getCards( + null, "50% 지원_special!", null, null, null, null, PageRequest.of(0, 20)); + + assertThat(keywordCaptor.getValue()).isEqualTo("%50!% 지원!_special!!%"); + } + + @Test + void region이_전국이면_지역_무관이라_시도명_필터를_걸지_않는다() { + ArgumentCaptor sidoNameCaptor = ArgumentCaptor.forClass(String.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + sidoNameCaptor.capture(), + isNull(), + isNull(), + isNull(), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + policyService.getCards(null, null, "전국", null, null, null, PageRequest.of(0, 20)); + + assertThat(sidoNameCaptor.getValue()).isNull(); + } + + @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, 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, null, " ", PageRequest.of(0, 20)); + + assertThat(jobCodeCaptor.getValue()).isNull(); + } + + @Test + void region이_시도명이면_그대로_시도명_필터로_전달한다() { + ArgumentCaptor sidoNameCaptor = ArgumentCaptor.forClass(String.class); + when(policyRepository.findCards( + eq(PolicyVisibility.VISIBLE), + any(LocalDate.class), + isNull(), + isNull(), + sidoNameCaptor.capture(), + isNull(), + isNull(), + isNull(), + any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + + policyService.getCards(null, null, "서울특별시", null, null, null, PageRequest.of(0, 20)); + + assertThat(sidoNameCaptor.getValue()).isEqualTo("서울특별시"); + } + + @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(), + 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, null, 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); + 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; + } + + 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); + } +} 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/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 new file mode 100644 index 0000000..343d46d --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/controller/PostControllerTest.java @@ -0,0 +1,218 @@ +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 본문이_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", "제목", "내용")); + + 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 첨부_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 = + new PostSummaryResponse(3L, 1L, "작성자", null, null, "FREE", "제목", "내용", 0, null); + when(postService.findAll(eq(null), eq(null), 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 { + 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 유효하지_않은_목록_카테고리는_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())) + .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/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 new file mode 100644 index 0000000..03c69ff --- /dev/null +++ b/src/test/java/com/bop/youthpick/post/service/PostServiceTest.java @@ -0,0 +1,234 @@ +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.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.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; +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; +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.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class PostServiceTest { + + @Mock private PostRepository postRepository; + @Mock private AttachmentRepository attachmentRepository; + @Mock private UserRepository userRepository; + @Mock private PolicyRepository policyRepository; + @Mock private PostViewLogStore postViewLogStore; + + private PostService postService; + + @BeforeEach + void setUp() { + postService = + new PostService( + postRepository, + attachmentRepository, + userRepository, + policyRepository, + postViewLogStore); + } + + @Test + void 자유글을_생성한다() { + User user = user(1L); + 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)); + + 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 자유글에_정책을_연결하면_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); + 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, null); + 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.findAll(any(Specification.class), eq(pageable))) + .thenReturn(new PageImpl<>(java.util.List.of(post), pageable, 1)); + + Page result = postService.findAll(null, null, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().title()).isEqualTo("제목"); + assertThat(result.getContent().getFirst().contentExcerpt()).isEqualTo("내용"); + } + + @Test + 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, 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 + void 작성자가_게시글을_수정하면_기존_이미지_첨부를_최신_목록으로_교체한다() { + Post post = Post.create(user(1L), null, PostCategory.FREE, "수정 전", "수정 전 내용"); + 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 + 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, 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; + } +} 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(); + } +} 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..a80c9ce --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/entity/PolicyBatchHistoryTest.java @@ -0,0 +1,93 @@ +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); + } + + @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/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/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 new file mode 100644 index 0000000..db6ae94 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyMapperTest.java @@ -0,0 +1,433 @@ +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.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; + +/** + * 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("대분류 정규화: 반각점·콤마다중·구명칭은 표준 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 { + 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: 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 { + 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(); + } + + // ---- 컬럼 길이 상한 절단 (#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/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..3d3f6c6 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicySyncServiceTest.java @@ -0,0 +1,324 @@ +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 전_시도를_커버하는_정책만_전국으로_표시한다() 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)); + 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 실패율이_10퍼센트를_넘으면_크래시_없이_실패_사유를_기록한다() throws IOException { + // title(NOT NULL) 없이 저장을 시도해 DB 저장이 실패하는 건을 섞어 실패율 10%를 넘긴다 — + // (#208 이전엔 title 길이(300) 초과로 이 실패를 유발했으나, PolicyMapper가 길이 초과를 절단해 저장하도록 + // 바뀌면서 더는 DB 저장 실패로 이어지지 않는다. plcyNm 누락 → title NOT NULL 위반으로 트리거를 바꿔 같은 + // 실패율 계산·기록 시나리오를 계속 검증한다.) + // 실패 메시지 포맷 문자열의 "10%"가 이스케이프 안 돼 있으면 여기서 UnknownFormatConversionException이 터진다. + 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 없음 → title NOT NULL 위반 + } + when(policyApiClient.fetchAll()).thenReturn(items); + + PolicyBatchHistory result = policySyncService.runFullSync(); + + 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으로 버려지면 안 된다. + // (#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 없음 → title NOT NULL 위반 + } + 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 + 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..7820283 --- /dev/null +++ b/src/test/java/com/bop/youthpick/sync/service/PolicyUpsertWriterTest.java @@ -0,0 +1,137 @@ +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), false))); + + 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), + false))); + + 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(), 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()) + .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/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java new file mode 100644 index 0000000..27b9ffa --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/controller/UserProfileControllerTest.java @@ -0,0 +1,293 @@ +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.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.entity.Region; +import com.bop.youthpick.user.entity.User; +import com.bop.youthpick.user.entity.UserProfile; +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 = UserProfileController.class) +@AutoConfigureMockMvc(addFilters = false) +class UserProfileControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private UserProfileService userProfileService; + + private static final String VALID_BODY = + """ + { + "birthYear": 2000, + "regionCode": "11110", + "employmentStatus": "EMPLOYED", + "educationLevel": "UNIV_GRADUATE", + "maritalStatus": "SINGLE", + "major": ["ENGINEERING"], + "specialCondition": ["BASIC_LIVELIHOOD"], + "income": 3000, + "categories": ["취업", "주거"], + "keywords": ["청년", "공모전"] + } + """; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @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("UNIV_GRADUATE"); + when(profile.getMaritalStatus()).thenReturn("SINGLE"); + when(profile.getMajor()).thenReturn("ENGINEERING"); + when(profile.getSpecialCondition()).thenReturn("BASIC_LIVELIHOOD"); + when(profile.getIncome()).thenReturn(3000); + when(profile.getCategories()).thenReturn("취업,주거"); + when(profile.getKeywords()).thenReturn("청년,공모전"); + when(profile.getStatus()).thenReturn("COMPLETED"); + when(userProfileService.submit(eq(1L), any())).thenReturn(profile); + authenticateAs(1L); + + authenticateAs(1L); + + 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.maritalStatus").value("SINGLE")) + .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("주거")); + } + + @Test + void 출생연도가_없으면_400과_C001을_반환한다() throws Exception { + String invalidBody = + """ + { + "regionCode": "11110", + "employmentStatus": "EMPLOYED", + "educationLevel": "UNIV_GRADUATE" + } + """; + authenticateAs(1L); + + authenticateAs(1L); + + 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": "UNIV_GRADUATE" + } + """; + authenticateAs(1L); + + authenticateAs(1L); + + 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 { + // 검증이 없던 시절에는 이런 값도 그대로 저장돼, 맞춤정책 취업 축이 조용히 0점이 됐다. + authenticateAs(1L); + + 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 { + authenticateAs(1L); + + 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 경로의_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); + 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("UNIV_GRADUATE"); + when(profile.getMaritalStatus()).thenReturn("SINGLE"); + when(profile.getMajor()).thenReturn("ENGINEERING"); + when(profile.getSpecialCondition()).thenReturn("BASIC_LIVELIHOOD"); + 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")); + } + + @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.getMaritalStatus()).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 = + 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/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(); + } +} 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/java/com/bop/youthpick/user/service/UserProfileServiceTest.java b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java new file mode 100644 index 0000000..aebc7a7 --- /dev/null +++ b/src/test/java/com/bop/youthpick/user/service/UserProfileServiceTest.java @@ -0,0 +1,204 @@ +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.UserProfileRequest; +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 UserProfileServiceTest { + + @Mock private UserRepository userRepository; + + @Mock private UserProfileRepository userProfileRepository; + + @Mock private RegionRepository regionRepository; + + private UserProfileService userProfileService; + + private static final Long USER_ID = 1L; + private static final UserProfileRequest REQUEST = + new UserProfileRequest( + 2000, + "11110", + "EMPLOYED", + "UNIVERSITY", + "SINGLE", + List.of("COMPUTER_SCIENCE"), + List.of("LOW_INCOME"), + 3000, + List.of("취업", "주거"), + List.of("청년", "공모전")); + + @BeforeEach + void setUp() { + userProfileService = + new UserProfileService(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 = userProfileService.submit(USER_ID, REQUEST); + + assertThat(result.getBirthYear()).isEqualTo(REQUEST.birthYear()); + assertThat(result.getEmploymentStatus()).isEqualTo(REQUEST.employmentStatus()); + assertThat(result.getEducationLevel()).isEqualTo(REQUEST.educationLevel()); + assertThat(result.getMaritalStatus()).isEqualTo(REQUEST.maritalStatus()); + 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 존재하지_않는_사용자면_USER_NOT_FOUND_예외를_던진다() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userProfileService.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(() -> userProfileService.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(() -> userProfileService.submit(USER_ID, REQUEST)) + .isInstanceOf(UserException.class) + .extracting(ex -> ((UserException) ex).getErrorCode()) + .isEqualTo(UserError.PROFILE_ALREADY_EXISTS); + } + + @Test + void 프로필이_있으면_조회한다() { + UserProfile profile = mock(UserProfile.class); + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.of(profile)); + + UserProfile result = userProfileService.getMyProfile(USER_ID); + + assertThat(result).isEqualTo(profile); + } + + @Test + void 프로필이_없으면_null을_반환한다() { + when(userProfileRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + UserProfile result = userProfileService.getMyProfile(USER_ID); + + assertThat(result).isNull(); + } + + @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(() -> userProfileService.submit(USER_ID, REQUEST)) + .isInstanceOf(CustomException.class) + .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.getMaritalStatus()).isEqualTo(REQUEST.maritalStatus()); + 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); + } +} 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 new file mode 100644 index 0000000..c636d2d --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,59 @@ +# 테스트 전용 — 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} + web: + pageable: + one-indexed-parameters: true + max-page-size: 100 + +youthpick: + cors: + allowed-origins: + - http://localhost:3000 + - http://localhost:5173 + - http://localhost:5174 + 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 + 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:} + 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:} 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": "" + } + ] + } +} 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 @@ + + + + + + + + + + + + + + +