diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..32ab23d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# .dockerignore + +# Git 내부 파일 +.git +.gitignore + +# IDE 설정 파일 (개발자 개인 설정) +.idea/ +*.iml +.vscode/ + +# 로컬 그레들 빌드 결과물 (도커 내부에서 어차피 새로 빌드함) +build/ +.gradle/ +out/ + +# 로그 파일 및 로컬 환경 변수 (보안 및 용량 관리) +*.log +.env +.env.* +.DS_Store +Thumbs.db + +# 문서 및 설명서 (서버 구동과 무관) +*.md +README.md + +# 도커 배포 전용 가이드 파일 (이미지 내부 진입 차단) +Dockerfile +docker-compose*.yml +.dockerignore \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e011806 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# ===== PostgreSQL ===== +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_PORT= + +# ===== Redis ===== +REDIS_PASSWORD= +REDIS_PORT= + +# ===== OpenSearch ===== +OPENSEARCH_PORT= +OPENSEARCH_PASSWORD= + diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..1bab468 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,144 @@ +# ============================================================ +# IPX-BE CD — 빌드 & EC2 배포 +# 흐름: 소스 빌드 → Docker 이미지 빌드 → GHCR 푸시 → EC2 배포 → 헬스체크 +# ============================================================ + +name: CD — IPX-BE 빌드 & 배포 + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/workflows/ci.yml' + workflow_dispatch: + +concurrency: + group: cd-ipx-be-production + cancel-in-progress: false + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/ceos-ipx/patent-spring + CONTAINER_NAME: patent-spring + +jobs: + # ===================================================== + # 1단계: 빌드 + 이미지 푸시 + # ===================================================== + build-and-push: + name: 빌드 & GHCR 푸시 + runs-on: ubuntu-latest + + outputs: + image-tag: ${{ steps.meta.outputs.sha }} + + steps: + - name: 소스코드 체크아웃 + uses: actions/checkout@v4 + + - name: JDK 21 설정 + Gradle 캐시 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Gradle 실행 권한 부여 + run: chmod +x ./gradlew + + - name: 애플리케이션 빌드 + run: ./gradlew build -x test --no-daemon --parallel --build-cache + + - name: Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: GHCR 로그인 + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: SHA 단축 태그 생성 + id: meta + run: echo "sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Docker 이미지 빌드 & 푸시 + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:sha-${{ steps.meta.outputs.sha }} + cache-from: type=gha,scope=ipx-be + cache-to: type=gha,mode=max,scope=ipx-be + + # ===================================================== + # 2단계: EC2 배포 + 헬스체크 + # ===================================================== + deploy: + name: EC2 배포 + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: EC2에 SSH 배포 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -e + cd /opt/ipx + + echo "=== [1/4] 새 이미지 pull ===" + docker compose -f docker-compose.prod.yml --env-file .env.prod pull app-spring + + echo "=== [2/4] 컨테이너 재생성 (의존 서비스는 건드리지 않음) ===" + docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-deps app-spring + + echo "=== [3/4] 헬스체크 대기 (최대 3분) ===" + HEALTHY=false + for i in $(seq 1 36); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' patent-spring 2>/dev/null || echo "missing") + echo "시도 $i/36 - 상태: $STATUS" + if [ "$STATUS" = "healthy" ]; then + HEALTHY=true + break + fi + sleep 5 + done + + if [ "$HEALTHY" != "true" ]; then + echo "❌ 헬스체크 실패" + echo "--- 컨테이너 로그 (마지막 100줄) ---" + docker logs patent-spring --tail 100 + exit 1 + fi + echo "✅ 헬스체크 통과" + + echo "=== [4/4] 사용하지 않는 이미지 정리 ===" + docker image prune -f + + echo "=== 배포 완료 ===" + + - name: 배포 결과 요약 + if: always() + run: | + if [ "${{ job.status }}" = "success" ]; then + echo "✅ IPX-BE Production 배포 성공" + echo "Image: ${{ env.IMAGE_NAME }}:sha-${{ needs.build-and-push.outputs.image-tag }}" + else + echo "❌ IPX-BE Production 배포 실패" + echo "수동 롤백 가이드:" + echo " ssh로 EC2 접속 후, 이전 sha 이미지 태그로 임시 변경" + echo " 또는 git revert 후 main에 재푸시" + fi \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..37e3464 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +# ============================================================ +# IPX-BE CI — PR 검증 파이프라인 +# ============================================================ + +name: CI — IPX-BE 빌드 검증 + +on: + pull_request: + branches: [ main, dev ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/workflows/cd.yml' + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + build: + name: 빌드 검증 + runs-on: ubuntu-latest + + steps: + - name: 소스코드 체크아웃 + uses: actions/checkout@v4 + + - name: JDK 21 설정 + Gradle 캐시 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Gradle 실행 권한 부여 + run: chmod +x ./gradlew + + - name: 빌드 검증 (테스트 생략, 추후 추가) + run: ./gradlew build -x test --no-daemon --parallel --build-cache + + - name: 빌드 결과 확인 + run: | + if [ -f build/libs/*-SNAPSHOT.jar ]; then + echo "✅ 빌드 성공" + ls -lh build/libs/ + else + echo "❌ JAR 파일 생성 실패" + exit 1 + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index f50c106..c6be107 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ ### environment files ### -.env \ No newline at end of file +.env +.env.prod \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a96650c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# ===== Build stage ===== +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /app + +COPY gradlew . +COPY gradle gradle +COPY build.gradle . +COPY settings.gradle . + +RUN chmod +x ./gradlew && ./gradlew dependencies --no-daemon + +COPY src src + +RUN ./gradlew bootJar -x test --no-daemon + +# ===== Runtime stage ==== +FROM eclipse-temurin:21-jre-alpine + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/build/libs/*-SNAPSHOT.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8080 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index 5d9097a..bc2e387 100644 --- a/build.gradle +++ b/build.gradle @@ -13,24 +13,44 @@ java { } } +jar { + enabled = false +} + repositories { mavenCentral() } dependencies { - 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-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-webflux' - compileOnly 'org.projectlombok:lombok' + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + implementation 'org.springframework.boot:spring-boot-starter-mail' + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-validation' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'com.fasterxml.jackson.core:jackson-databind' } tasks.named('test') { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..c74b501 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,191 @@ +# ============================================================ +# 운영 서버용 docker-compose.prod.yml +# ============================================================ +# 구성: app-spring + app-python + postgres + redis + opensearch + nginx + certbot +# ============================================================ + +# ============================================================ +# 운영 서버용 docker-compose.prod.yml +# 적재 규모: 20만 건 이내 (m7i-flex.large 8GB) +# ============================================================ + +# 공통 로깅 설정 (디스크 무한 증가 방지) +x-logging: &default-logging + driver: "json-file" + options: + max-size: "100m" + max-file: "5" + +services: + + # ===== Spring 서버 (메인) ===== + app-spring: + image: ghcr.io/${GITHUB_OWNER}/patent-spring:latest + container_name: patent-spring + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + app-python: + condition: service_started + env_file: + - .env.prod + environment: + SPRING_PROFILES_ACTIVE: prod + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + REDIS_HOST: redis + REDIS_PORT: 6379 + SEARCH_SERVER_URL: http://app-python:8000 + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - patent-net + logging: *default-logging + + # ===== Python 서버 (검색 전용) ===== + app-python: + image: ghcr.io/${GITHUB_OWNER}/patent-python:latest + container_name: patent-python + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + opensearch: + condition: service_healthy + env_file: + - .env.prod + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + OPENSEARCH_HOST: opensearch + OPENSEARCH_PORT: 9200 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s # BGE-M3 모델 로드 시간 고려 + volumes: + - huggingface_cache:/app/.cache/huggingface + networks: + - patent-net + logging: *default-logging + + # ===== PostgreSQL + pgvector ===== + postgres: + image: pgvector/pgvector:pg17 + container_name: patent-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - patent-net + logging: *default-logging + + # ===== Redis (Spring 전용) ===== + redis: + image: redis:7-alpine + container_name: patent-redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - patent-net + logging: *default-logging + + # ===== OpenSearch (Python 전용) ===== + opensearch: + build: + context: ./infra/opensearch + dockerfile: Dockerfile + container_name: patent-opensearch + restart: unless-stopped + environment: + - discovery.type=single-node + - OPENSEARCH_JAVA_OPTS=-Xms1536m -Xmx1536m # 20만 건 기준 1.5g + - plugins.security.disabled=true + - bootstrap.memory_lock=true + - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} + volumes: + - opensearch_data:/usr/share/opensearch/data + - ./infra/opensearch/synonyms_patent.txt:/usr/share/opensearch/config/analysis/synonyms_patent.txt:ro + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200 >/dev/null || exit 1"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 60s + networks: + - patent-net + logging: *default-logging + + # ===== Nginx 리버스 프록시 ===== + nginx: + image: nginx:alpine + container_name: patent-nginx + restart: unless-stopped + depends_on: + app-spring: + condition: service_healthy + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - certbot_www:/var/www/certbot:ro + networks: + - patent-net + logging: *default-logging + + # ===== Certbot (Let's Encrypt SSL 자동 갱신) ===== + certbot: + image: certbot/certbot + container_name: patent-certbot + restart: unless-stopped + volumes: + - /etc/letsencrypt:/etc/letsencrypt + - certbot_www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + logging: *default-logging + +volumes: + huggingface_cache: + postgres_data: + redis_data: + opensearch_data: + certbot_www: + +networks: + patent-net: + driver: bridge \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42b8ea3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +# ============================================================ +# 로컬 개발용 docker-compose.yml +# ============================================================ +# 공유 인프라를 Spring 레포 한 곳에서 관리: +# - postgres : Spring + Python 두 서버가 공유 +# - redis : Spring 전용 +# - opensearch : Python 전용 +# +# Spring/Python 앱은 컨테이너로 띄우지 않고 각자 IDE에서 로컬 실행 +# ============================================================ + +services: + + # ===== PostgreSQL + pgvector ===== + postgres: + image: pgvector/pgvector:pg17 + container_name: patent-postgres + ports: + - "${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-patent_db} + POSTGRES_USER: ${POSTGRES_USER:-ipx_patent_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ipx_patent_user}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ===== Redis ===== + redis: + image: redis:7-alpine + container_name: patent-redis + ports: + - "${REDIS_PORT:-6379}:6379" + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-localpassword}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ===== OpenSearch ===== + opensearch: + build: + context: ./infra/opensearch + dockerfile: Dockerfile + container_name: patent-opensearch + ports: + - "${OPENSEARCH_PORT:-9200}:9200" + - "9600:9600" + environment: + - discovery.type=single-node + - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m + - plugins.security.disabled=true + volumes: + - opensearch_data:/usr/share/opensearch/data + - ./infra/opensearch/synonyms_patent.txt:/usr/share/opensearch/config/analysis/synonyms_patent.txt + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200 >/dev/null || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 60s + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + opensearch_data: \ No newline at end of file diff --git a/infra/opensearch/Dockerfile b/infra/opensearch/Dockerfile new file mode 100644 index 0000000..a87dcb5 --- /dev/null +++ b/infra/opensearch/Dockerfile @@ -0,0 +1,8 @@ +# ============================================================ +# nori 플러그인이 포함된 OpenSearch 커스텀 이미지 +# ============================================================ + +FROM opensearchproject/opensearch:2.19.0 + +# 한국어 형태소 분석기(nori) 플러그인 설치 +RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-nori \ No newline at end of file diff --git a/infra/opensearch/index_mapping.json b/infra/opensearch/index_mapping.json new file mode 100644 index 0000000..8993402 --- /dev/null +++ b/infra/opensearch/index_mapping.json @@ -0,0 +1,142 @@ +{ + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "analysis": { + "tokenizer": { + "nori_user_dict_tokenizer": { + "type": "nori_tokenizer", + "decompound_mode": "mixed" + } + }, + "filter": { + "synonym_filter": { + "type": "synonym", + "synonyms_path": "analysis/synonyms_patent.txt", + "tokenizer": "keyword", + "lenient": true + }, + "nori_posfilter": { + "type": "nori_part_of_speech", + "stoptags": [ + "E", + "IC", + "J", + "MAG", + "MAJ", + "MM", + "SP", + "SSC", + "SSO", + "SC", + "SE", + "XPN", + "XSA", + "XSN", + "XSV", + "UNA", + "NA", + "VSV" + ] + } + }, + "analyzer": { + "korean_analyzer": { + "type": "custom", + "tokenizer": "nori_user_dict_tokenizer", + "filter": [ + "nori_posfilter", + "nori_readingform", + "lowercase" + ] + }, + "korean_synonym_analyzer": { + "type": "custom", + "tokenizer": "nori_user_dict_tokenizer", + "filter": [ + "nori_posfilter", + "nori_readingform", + "lowercase", + "synonym_filter" + ] + } + } + } + }, + "mappings": { + "properties": { + "application_number": { + "type": "keyword" + }, + "registration_number": { + "type": "keyword" + }, + "open_number": { + "type": "keyword" + }, + "application_date": { + "type": "date", + "format": "yyyy-MM-dd" + }, + "open_date": { + "type": "date", + "format": "yyyy-MM-dd" + }, + "registration_date": { + "type": "date", + "format": "yyyy-MM-dd" + }, + "title": { + "type": "text", + "analyzer": "korean_analyzer" + }, + "abstract_clean": { + "type": "text", + "analyzer": "korean_synonym_analyzer" + }, + "claims_independent": { + "type": "text", + "analyzer": "korean_synonym_analyzer" + }, + "applicant_name": { + "type": "text", + "analyzer": "korean_analyzer", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "inventor_name": { + "type": "text", + "analyzer": "korean_analyzer", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "ipc_codes": { + "type": "keyword" + }, + "cpc_codes": { + "type": "keyword" + }, + "legal_status": { + "type": "keyword" + }, + "big_drawing": { + "type": "keyword", + "index": false + }, + "drawing": { + "type": "keyword", + "index": false + }, + "collected_date": { + "type": "date", + "format": "yyyy-MM-dd" + } + } + } +} \ No newline at end of file diff --git a/infra/opensearch/synonyms_patent.txt b/infra/opensearch/synonyms_patent.txt new file mode 100644 index 0000000..bacf384 --- /dev/null +++ b/infra/opensearch/synonyms_patent.txt @@ -0,0 +1 @@ +// 미정 \ No newline at end of file diff --git a/infra/postgres/init.sql b/infra/postgres/init.sql new file mode 100644 index 0000000..dff83f6 --- /dev/null +++ b/infra/postgres/init.sql @@ -0,0 +1,192 @@ +--- ============================================================ +-- IPX 전체 DB 초기화 스크립트 +-- ============================================================ + +-- pgvector 확장 활성화 +CREATE EXTENSION IF NOT EXISTS vector; + +-- 벡터 검색 +CREATE TABLE patent_vectors ( + id BIGSERIAL NOT NULL, + + application_number VARCHAR(20) NOT NULL UNIQUE, -- 특허 식별 + + embedding vector(1024) NOT NULL, -- BGE-M3 임베딩 벡터 (1024차원) + + ipc_codes TEXT[] NOT NULL DEFAULT '{}', -- IPC 코드 배열 + legal_status VARCHAR(20) NOT NULL, -- 등록상태 + application_date DATE NOT NULL, -- 출원일자 + + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + + PRIMARY KEY(id) +); + +-- 출원번호 조회용 +CREATE INDEX idx_pv_app_num ON patent_vectors (application_number); + +-- IPC 코드 필터링용 GIN 인덱스 (배열 타입 전용 인덱스) +CREATE INDEX idx_pv_ipc ON patent_vectors USING GIN (ipc_codes); + +-- 법적 상태 필터링용 +CREATE INDEX idx_pv_legal_status ON patent_vectors (legal_status); + +-- 출원일 범위 필터링용 +CREATE INDEX idx_pv_app_date ON patent_vectors (application_date); + +-- HNSW 벡터 인덱스: 데이터 적재 완료 후 실행 +--CREATE INDEX idx_pv_hnsw ON patent_vectors +-- USING hnsw (embedding vector_cosine_ops) +-- WITH (m = 16, ef_construction = 200); + +-- =================================================================== +CREATE TABLE sync_history ( + id SERIAL NOT NULL, + job_type VARCHAR(20) NOT NULL, -- 'initial' or 'incremental' + query_date_from DATE, -- KIPRIS 조회 시작 날짜 + query_date_to DATE NOT NULL, -- KIPRIS 조회 종료 날짜 (= 다음 증분의 시작점) + records_added INTEGER NOT NULL DEFAULT 0, -- 신규 추가 건수 + status VARCHAR(20) NOT NULL, -- 'success' or 'failed' + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + + PRIMARY KEY(id) +); + +-- 최근 성공 수집 조회용 +CREATE INDEX idx_sh_status ON sync_history (status, created_at DESC); + + +-- ============================================================ +-- Spring JPA 관리 테이블 +-- ============================================================ + +-- 1. users +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255), + name VARCHAR(100) NOT NULL, + company VARCHAR(200), + provider VARCHAR(20) NOT NULL DEFAULT 'LOCAL', + provider_id VARCHAR(255), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + +-- 2. terms_agreements +CREATE TABLE IF NOT EXISTS terms_agreements ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + terms_type VARCHAR(50) NOT NULL, + terms_version VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); +CREATE INDEX IF NOT EXISTS idx_ta_user ON terms_agreements (user_id); + +-- 3. cases +CREATE TABLE IF NOT EXISTS cases ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + applicant_name VARCHAR(200), + inventor_name VARCHAR(200), + technical_field TEXT, + description TEXT, + user_input_ipc TEXT[] NOT NULL DEFAULT '{}', + search_completed_at TIMESTAMP, + novelty_completed_at TIMESTAMP, + inventive_completed_at TIMESTAMP, + report_completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); +CREATE INDEX IF NOT EXISTS idx_cases_user ON cases (user_id, created_at DESC); + +-- 4. invention_components +CREATE TABLE IF NOT EXISTS invention_components ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL REFERENCES cases(id) ON DELETE CASCADE, + label VARCHAR(1) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(case_id, label) + ); +CREATE INDEX IF NOT EXISTS idx_ic_case ON invention_components (case_id); + +-- 5. prior_arts +CREATE TABLE IF NOT EXISTS prior_arts ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL REFERENCES cases(id) ON DELETE CASCADE, + application_number VARCHAR(20) NOT NULL, + rank SMALLINT NOT NULL, + source VARCHAR(20) NOT NULL, + rrf_score FLOAT, + included BOOLEAN NOT NULL DEFAULT TRUE, + reason TEXT, + summary TEXT, + tech_purpose TEXT, + key_features TEXT, + matched_keywords TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(case_id, application_number) + ); +CREATE INDEX IF NOT EXISTS idx_pa_case_rank ON prior_arts (case_id, rank); + +-- 6. novelty_analyses +CREATE TABLE IF NOT EXISTS novelty_analyses ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL UNIQUE REFERENCES cases(id) ON DELETE CASCADE, + overall_verdict VARCHAR(20), + summary TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + +-- 7. novelty_comparisons +CREATE TABLE IF NOT EXISTS novelty_comparisons ( + id BIGSERIAL PRIMARY KEY, + novelty_analysis_id BIGINT NOT NULL REFERENCES novelty_analyses(id) ON DELETE CASCADE, + component_id BIGINT NOT NULL REFERENCES invention_components(id) ON DELETE CASCADE, + prior_art_id BIGINT NOT NULL REFERENCES prior_arts(id) ON DELETE CASCADE, + match_status VARCHAR(20) NOT NULL, + prior_art_excerpt TEXT, + UNIQUE(novelty_analysis_id, component_id, prior_art_id) + ); +CREATE INDEX IF NOT EXISTS idx_nc_analysis ON novelty_comparisons (novelty_analysis_id); + +-- 8. inventive_step_analyses +CREATE TABLE IF NOT EXISTS inventive_step_analyses ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL UNIQUE REFERENCES cases(id) ON DELETE CASCADE, + primary_art_id BIGINT NOT NULL REFERENCES prior_arts(id), + secondary_art_id BIGINT REFERENCES prior_arts(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + +-- 9. inventive_arguments +CREATE TABLE IF NOT EXISTS inventive_arguments ( + id BIGSERIAL PRIMARY KEY, + analysis_id BIGINT NOT NULL REFERENCES inventive_step_analyses(id) ON DELETE CASCADE, + argument_type VARCHAR(30) NOT NULL, + applicable BOOLEAN NOT NULL DEFAULT FALSE, + ai_recommended BOOLEAN NOT NULL DEFAULT FALSE, + content JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(analysis_id, argument_type) + ); +CREATE INDEX IF NOT EXISTS idx_ia_analysis ON inventive_arguments (analysis_id); + +-- 10. reports +CREATE TABLE IF NOT EXISTS reports ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL UNIQUE REFERENCES cases(id) ON DELETE CASCADE, + author_name VARCHAR(100) NOT NULL, + novelty_satisfied BOOLEAN NOT NULL, + inventive_satisfied BOOLEAN NOT NULL, + overall_conclusion TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..5a36259 --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,114 @@ +# ============================================================ +# Nginx 리버스 프록시 설정 +# ============================================================ +# 모든 사용자 요청은 Spring 서버(app-spring:8080)로 전달 +# Python 서버(app-python:8000)는 내부에서 Spring이 호출하므로 외부 노출 안 함 +# ============================================================ + +# DDOS 공격 방지 +# 일반 요청: 사실상 무제한 +limit_req_zone $binary_remote_addr zone=general:10m rate=500r/s; + +# 검색: 회사 단위라도 안 걸리도록 +limit_req_zone $binary_remote_addr zone=search:10m rate=50r/s; + +# ============================================================ +# HTTP → HTTPS 리다이렉트 +# ============================================================ +server { + listen 80; + # IPX 서버 도메인 + server_name api.ipx-patent.com; + + # Certbot ACME 챌린지 (SSL 발급/갱신) + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 그 외 모든 요청은 HTTPS로 리다이렉트 + location / { + return 301 https://$host$request_uri; + } +} + +# ============================================================ +# HTTPS 메인 서버 +# ============================================================ +server { + listen 443 ssl; + http2 on; + server_name api.ipx-patent.com; + + # SSL 인증서 (Let's Encrypt) + ssl_certificate /etc/letsencrypt/live/api.ipx-patent.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.ipx-patent.com/privkey.pem; + + # SSL 설정 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 보안 헤더 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 요청 본문 크기 제한 + client_max_body_size 10M; + + # 로그 + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # ============================================================ + # 검색 API (LLM 호출 포함 -> 더 긴 타임아웃 + 엄격한 rate limit) + # ============================================================ + location /api/search { + # IP당 초당 5회, burst 100 허용 + limit_req zone=search burst=100 nodelay; + + proxy_pass http://app-spring:8080; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # LLM(Gemini + Claude) 호출 + 벡터 검색까지 포함되므로 + proxy_connect_timeout 10s; + proxy_send_timeout 180s; + proxy_read_timeout 180s; + } + + # ============================================================ + # 헬스체크 + # ============================================================ + location /health { + proxy_pass http://app-spring:8080/actuator/health; + access_log off; + } + + # ============================================================ + # 일반 API (인증, 회원, 프로젝트 등) + # ============================================================ + location / { + # 일반 요청은 초당 1000회까지 허용 + limit_req zone=general burst=1000 nodelay; + + proxy_pass http://app-spring:8080; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/ArgumentType.java b/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/ArgumentType.java new file mode 100644 index 0000000..4d5c2e6 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/ArgumentType.java @@ -0,0 +1,8 @@ +package ceos.ipx.domain.analysis.inventivestep.entity; + +public enum ArgumentType { + NUMERICAL_LIMIT, // 수치한정 - 효과의 현저성 + COMBINATION_MOTIVATION, // 복수인용발명결합 - Teaching Away + COMMON_TECHNIQUE, // 주지관용기술 - 반박 논리 + SIMPLE_DESIGN // 단순설계변경 - 비자명성 논리 +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/InventiveArgument.java b/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/InventiveArgument.java new file mode 100644 index 0000000..6b86e80 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/InventiveArgument.java @@ -0,0 +1,77 @@ +package ceos.ipx.domain.analysis.inventivestep.entity; + +import ceos.ipx.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.HashMap; +import java.util.Map; + +@Entity +@Getter +@Table(name = "inventive_arguments", + uniqueConstraints = @UniqueConstraint(columnNames = {"analysis_id", "argument_type"}), + indexes = @Index(name = "idx_ia_analysis", columnList = "analysis_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class InventiveArgument extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "analysis_id", nullable = false) + private InventiveStepAnalysis analysis; + + @Enumerated(EnumType.STRING) + @Column(name = "argument_type", nullable = false, length = 30) + private ArgumentType argumentType; + + /** 적용 여부 (변리사 또는 AI가 선택 시 true) */ + @Column(nullable = false) + private Boolean applicable; + + /** AI 추천 여부 */ + @Column(name = "ai_recommended", nullable = false) + private Boolean aiRecommended; + + /** + * 논리 유형별 구조화된 데이터 + * - NUMERICAL_LIMIT: {"effect_table": [{"metric": "VOC", "prior": 320, "ours": 8}]} + * - COMBINATION_MOTIVATION: {"background_limit": "...", "teaching_away": "..."} + * - COMMON_TECHNIQUE: {"target_component": "B", "rebuttal": "..."} + * - SIMPLE_DESIGN: {"changed_component": "C", "non_obviousness": "..."} + */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map content = new HashMap<>(); + + @Builder + private InventiveArgument(InventiveStepAnalysis analysis, ArgumentType argumentType, + Boolean applicable, Boolean aiRecommended, + Map content) { + this.analysis = analysis; + this.argumentType = argumentType; + this.applicable = applicable != null ? applicable : false; + this.aiRecommended = aiRecommended != null ? aiRecommended : false; + this.content = content != null ? content : new HashMap<>(); + } + + public void markApplicable() { + this.applicable = true; + } + + public void markNotApplicable() { + this.applicable = false; + } + + public void updateContent(Map content) { + this.content = content != null ? content : new HashMap<>(); + this.applicable = true; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/InventiveStepAnalysis.java b/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/InventiveStepAnalysis.java new file mode 100644 index 0000000..0629d4c --- /dev/null +++ b/src/main/java/ceos/ipx/domain/analysis/inventivestep/entity/InventiveStepAnalysis.java @@ -0,0 +1,54 @@ +package ceos.ipx.domain.analysis.inventivestep.entity; + +import ceos.ipx.domain.cases.entity.Case; +import ceos.ipx.domain.cases.entity.PriorArt; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "inventive_step_analyses") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class InventiveStepAnalysis { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "case_id", nullable = false, unique = true) + private Case caseEntity; + + /** D1 - 사용자가 선택한 주인용 발명 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "primary_art_id", nullable = false) + private PriorArt primaryArt; + + /** D2 - AI가 추천한 부인용 발명 (선택) */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "secondary_art_id") + private PriorArt secondaryArt; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + private InventiveStepAnalysis(Case caseEntity, PriorArt primaryArt, PriorArt secondaryArt) { + this.caseEntity = caseEntity; + this.primaryArt = primaryArt; + this.secondaryArt = secondaryArt; + } + + public void selectSecondary(PriorArt secondaryArt) { + this.secondaryArt = secondaryArt; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/analysis/novelty/entity/MatchStatus.java b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/MatchStatus.java new file mode 100644 index 0000000..5e489a5 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/MatchStatus.java @@ -0,0 +1,7 @@ +package ceos.ipx.domain.analysis.novelty.entity; + +public enum MatchStatus { + IDENTICAL, // 동일 + SIMILAR, // 유사 + NOVEL // 신규 +} diff --git a/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyAnalysis.java b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyAnalysis.java new file mode 100644 index 0000000..ec423b9 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyAnalysis.java @@ -0,0 +1,51 @@ +package ceos.ipx.domain.analysis.novelty.entity; + +import ceos.ipx.domain.cases.entity.Case; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "novelty_analyses") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NoveltyAnalysis { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "case_id", nullable = false, unique = true) + private Case caseEntity; + + @Enumerated(EnumType.STRING) + @Column(name = "overall_verdict", length = 20) + private NoveltyVerdict overallVerdict; + + @Column(columnDefinition = "TEXT") + private String summary; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + private NoveltyAnalysis(Case caseEntity, NoveltyVerdict overallVerdict, String summary) { + this.caseEntity = caseEntity; + this.overallVerdict = overallVerdict; + this.summary = summary; + } + + public void updateResult(NoveltyVerdict verdict, String summary) { + this.overallVerdict = verdict; + this.summary = summary; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyComparison.java b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyComparison.java new file mode 100644 index 0000000..8b29724 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyComparison.java @@ -0,0 +1,56 @@ +package ceos.ipx.domain.analysis.novelty.entity; + +import ceos.ipx.domain.cases.entity.InventionComponent; +import ceos.ipx.domain.cases.entity.PriorArt; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "novelty_comparisons", + uniqueConstraints = @UniqueConstraint(columnNames = { + "novelty_analysis_id", "component_id", "prior_art_id" + }), + indexes = @Index(name = "idx_nc_analysis", columnList = "novelty_analysis_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NoveltyComparison { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "novelty_analysis_id", nullable = false) + private NoveltyAnalysis noveltyAnalysis; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "component_id", nullable = false) + private InventionComponent component; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "prior_art_id", nullable = false) + private PriorArt priorArt; + + @Enumerated(EnumType.STRING) + @Column(name = "match_status", nullable = false, length = 20) + private MatchStatus matchStatus; + + @Column(name = "prior_art_excerpt", columnDefinition = "TEXT") + private String priorArtExcerpt; + + @Builder + private NoveltyComparison(NoveltyAnalysis noveltyAnalysis, + InventionComponent component, + PriorArt priorArt, + MatchStatus matchStatus, + String priorArtExcerpt) { + this.noveltyAnalysis = noveltyAnalysis; + this.component = component; + this.priorArt = priorArt; + this.matchStatus = matchStatus; + this.priorArtExcerpt = priorArtExcerpt; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyVerdict.java b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyVerdict.java new file mode 100644 index 0000000..440b5e1 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/analysis/novelty/entity/NoveltyVerdict.java @@ -0,0 +1,7 @@ +package ceos.ipx.domain.analysis.novelty.entity; + +public enum NoveltyVerdict { + SAFE, // 신규성 충족 + RISKY, // 위험 + BLOCKED // 단일 문헌에 모든 구성 존재 +} diff --git a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..fe12a8d --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java @@ -0,0 +1,43 @@ +package ceos.ipx.domain.auth.controller; + +import ceos.ipx.domain.auth.service.AuthService; +import ceos.ipx.domain.user.dto.SignUpRequest; +import ceos.ipx.domain.user.dto.SignUpResponse; +import ceos.ipx.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Auth API", description = "인증/인가 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "일반 회원가입", description = "이메일 인증이 완료된 사용자의 정보를 받아 계정을 생성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "회원가입 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패 또는 비밀번호 확인 불일치"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "이메일 인증 미완료 또는 인증 토큰 만료"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 가입된 이메일"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/signup") + public ResponseEntity> signUp(@Valid @RequestBody SignUpRequest request) { + SignUpResponse response = authService.signUp(request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ApiResponse.ok(response)); + } +} diff --git a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java new file mode 100644 index 0000000..6145f15 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java @@ -0,0 +1,57 @@ +package ceos.ipx.domain.auth.service; + +import ceos.ipx.domain.user.dto.SignUpRequest; +import ceos.ipx.domain.user.dto.SignUpResponse; +import ceos.ipx.domain.user.entity.User; +import ceos.ipx.domain.user.repository.UserRepository; +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public SignUpResponse signUp(SignUpRequest request) { + if (userRepository.existsByEmail(request.email())) { + throw new BusinessException(ErrorCode.EMAIL_ALREADY_EXISTS); + } + + if (!request.password().equals(request.passwordConfirm())) { + throw new BusinessException(ErrorCode.PASSWORD_CONFIRM_MISMATCH); + } + + // TODO: 이메일 인증 토큰 검증 로직 추가 + // TODO: 필수 약관 동의 검증 및 저장 로직 추가 + + String encodedPassword = passwordEncoder.encode(request.password()); + + User user = User.builder() + .email(request.email()) + .passwordHash(encodedPassword) + .name(request.name()) + .company(request.company()) + .providerId(null) + .isActive(true) + .build(); + + User savedUser = userRepository.save(user); + + return new SignUpResponse( + savedUser.getId(), + savedUser.getEmail(), + savedUser.getName(), + savedUser.getCompany(), + savedUser.getProvider().name(), + true + ); + } +} diff --git a/src/main/java/ceos/ipx/domain/cases/entity/Case.java b/src/main/java/ceos/ipx/domain/cases/entity/Case.java new file mode 100644 index 0000000..24dc5e8 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/cases/entity/Case.java @@ -0,0 +1,117 @@ +package ceos.ipx.domain.cases.entity; + + +import ceos.ipx.domain.user.entity.User; +import ceos.ipx.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "cases", indexes = { + @Index(name = "idx_cases_user", columnList = "user_id, created_at DESC") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Case extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 500) + private String title; + + @Column(name = "applicant_name", length = 200) + private String applicantName; + + @Column(name = "inventor_name", length = 200) + private String inventorName; + + @Column(name = "technical_field", columnDefinition = "TEXT") + private String technicalField; + + @Column(columnDefinition = "TEXT") + private String description; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "user_input_ipc", columnDefinition = "TEXT[]") + private List userInputIpc = new ArrayList<>(); + + @Column(name = "search_completed_at") + private LocalDateTime searchCompletedAt; + + @Column(name = "novelty_completed_at") + private LocalDateTime noveltyCompletedAt; + + @Column(name = "inventive_completed_at") + private LocalDateTime inventiveCompletedAt; + + @Column(name = "report_completed_at") + private LocalDateTime reportCompletedAt; + + @Builder + private Case(User user, String title, String applicantName, String inventorName, + String technicalField, String description, List userInputIpc) { + this.user = user; + this.title = title; + this.applicantName = applicantName; + this.inventorName = inventorName; + this.technicalField = technicalField; + this.description = description; + this.userInputIpc = userInputIpc != null ? userInputIpc : new ArrayList<>(); + } + + // ===== 단계 완료 처리 ===== + public void completeSearch() { + this.searchCompletedAt = LocalDateTime.now(); + } + + public void completeNoveltyAnalysis() { + this.noveltyCompletedAt = LocalDateTime.now(); + } + + public void completeInventiveStepAnalysis() { + this.inventiveCompletedAt = LocalDateTime.now(); + } + + public void completeReport() { + this.reportCompletedAt = LocalDateTime.now(); + } + + // 재검색 시 모든 단계 초기화 + public void resetAllStages() { + this.searchCompletedAt = null; + this.noveltyCompletedAt = null; + this.inventiveCompletedAt = null; + this.reportCompletedAt = null; + } + + // ===== 정보 수정 ===== + public void updateInfo(String title, String applicantName, String inventorName, + String technicalField, String description, List userInputIpc) { + this.title = title; + this.applicantName = applicantName; + this.inventorName = inventorName; + this.technicalField = technicalField; + this.description = description; + this.userInputIpc = userInputIpc != null ? userInputIpc : new ArrayList<>(); + } + + // ===== 리포트 생성 가능 여부 ===== + public boolean canGenerateReport() { + return this.noveltyCompletedAt != null && this.inventiveCompletedAt != null; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/cases/entity/InventionComponent.java b/src/main/java/ceos/ipx/domain/cases/entity/InventionComponent.java new file mode 100644 index 0000000..b532106 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/cases/entity/InventionComponent.java @@ -0,0 +1,55 @@ +package ceos.ipx.domain.cases.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "invention_components", + uniqueConstraints = @UniqueConstraint(columnNames = {"case_id", "label"}), + indexes = @Index(name = "idx_ic_case", columnList = "case_id")) +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class InventionComponent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "case_id", nullable = false) + private Case caseEntity; + + @Column(nullable = false, length = 1) + private String label; // 'A', 'B', 'C', 'D' + + @Column(nullable = false, length = 200) + private String name; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + private InventionComponent(Case caseEntity, String label, String name, String description) { + this.caseEntity = caseEntity; + this.label = label; + this.name = name; + this.description = description; + } + + public void update(String name, String description) { + this.name = name; + this.description = description; + } +} diff --git a/src/main/java/ceos/ipx/domain/cases/entity/PriorArt.java b/src/main/java/ceos/ipx/domain/cases/entity/PriorArt.java new file mode 100644 index 0000000..0735e24 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/cases/entity/PriorArt.java @@ -0,0 +1,104 @@ +package ceos.ipx.domain.cases.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "prior_arts", + uniqueConstraints = @UniqueConstraint(columnNames = {"case_id", "application_number"}), + indexes = @Index(name = "idx_pa_case_rank", columnList = "case_id, rank")) +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PriorArt { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "case_id", nullable = false) + private Case caseEntity; + + @Column(name = "application_number", nullable = false, length = 20) + private String applicationNumber; + + /** D1=1, D2=2, D3=3 ... 검색 결과 우선순위 */ + @Column(nullable = false) + private Short rank; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PriorArtSource source; + + @Column(name = "rrf_score") + private Double rrfScore; + + /** 변리사가 분석 대상에서 제외하면 false */ + @Column(nullable = false) + private Boolean included; + + @Column(columnDefinition = "TEXT") + private String reason; + + @Column(columnDefinition = "TEXT") + private String summary; + + @Column(name = "tech_purpose", columnDefinition = "TEXT") + private String techPurpose; + + @Column(name = "key_features", columnDefinition = "TEXT") + private String keyFeatures; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "matched_keywords", columnDefinition = "TEXT[]") + private List matchedKeywords = new ArrayList<>(); + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + private PriorArt(Case caseEntity, String applicationNumber, Short rank, + PriorArtSource source, Double rrfScore, + String reason, String summary, String techPurpose, + String keyFeatures, List matchedKeywords) { + this.caseEntity = caseEntity; + this.applicationNumber = applicationNumber; + this.rank = rank; + this.source = source; + this.rrfScore = rrfScore; + this.included = true; + this.reason = reason; + this.summary = summary; + this.techPurpose = techPurpose; + this.keyFeatures = keyFeatures; + this.matchedKeywords = matchedKeywords != null ? matchedKeywords : new ArrayList<>(); + } + + public void exclude() { + this.included = false; + } + + public void include() { + this.included = true; + } + + /** D1, D2, M1, M2 형태의 라벨 (rank로부터 도출) */ + public String getLabel() { + return this.source == PriorArtSource.SEARCH ? "D" + this.rank : "M" + this.rank; + } +} + + diff --git a/src/main/java/ceos/ipx/domain/cases/entity/PriorArtSource.java b/src/main/java/ceos/ipx/domain/cases/entity/PriorArtSource.java new file mode 100644 index 0000000..dedb271 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/cases/entity/PriorArtSource.java @@ -0,0 +1,6 @@ +package ceos.ipx.domain.cases.entity; + +public enum PriorArtSource { + SEARCH, // 검색 결과로 들어옴 + MANUAL // 변리사가 출원번호로 직접 추가 +} diff --git a/src/main/java/ceos/ipx/domain/report/entity/Report.java b/src/main/java/ceos/ipx/domain/report/entity/Report.java new file mode 100644 index 0000000..bf19559 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/report/entity/Report.java @@ -0,0 +1,52 @@ +package ceos.ipx.domain.report.entity; + +import ceos.ipx.domain.cases.entity.Case; +import ceos.ipx.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "reports") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Report extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "case_id", nullable = false, unique = true) + private Case caseEntity; + + /** 작성 변리사 이름 */ + @Column(name = "author_name", nullable = false, length = 100) + private String authorName; + + @Column(name = "novelty_satisfied", nullable = false) + private Boolean noveltySatisfied; + + @Column(name = "inventive_satisfied", nullable = false) + private Boolean inventiveSatisfied; + + @Column(name = "overall_conclusion", nullable = false, columnDefinition = "TEXT") + private String overallConclusion; + + @Builder + private Report(Case caseEntity, String authorName, + Boolean noveltySatisfied, Boolean inventiveSatisfied, + String overallConclusion) { + this.caseEntity = caseEntity; + this.authorName = authorName; + this.noveltySatisfied = noveltySatisfied; + this.inventiveSatisfied = inventiveSatisfied; + this.overallConclusion = overallConclusion; + } + + public void updateConclusion(String overallConclusion) { + this.overallConclusion = overallConclusion; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/user/dto/SignUpRequest.java b/src/main/java/ceos/ipx/domain/user/dto/SignUpRequest.java new file mode 100644 index 0000000..2a00f8f --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/dto/SignUpRequest.java @@ -0,0 +1,61 @@ +package ceos.ipx.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; + +@Schema(description = "Sign-up request") +public record SignUpRequest( + @Schema(description = "Verified email address", example = "abcd@gmail.com") + @NotBlank(message = "Email is required.") + @Email(message = "Email must be a valid format.") + String email, + + @Schema( + description = "Email verification token returned after email verification", + example = "email-verification-token-example" + ) + @NotBlank(message = "Verification token is required.") + String verificationToken, + + @Schema(description = "User name", example = "Kim Planner") + @NotBlank(message = "Name is required.") + @Size(max = 100, message = "Name must be 100 characters or fewer.") + String name, + + @Schema(description = "Password", example = "Password123!") + @NotBlank(message = "Password is required.") + @Size(min = 8, max = 20, message = "Password must be between 8 and 20 characters.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&]).+$", + message = "Password must include at least one letter, one number, and one special character." + ) + String password, + + @Schema(description = "Password confirmation", example = "Password123!") + @NotBlank(message = "Password confirmation is required.") + @Size(min = 8, max = 20, message = "Password confirmation must be between 8 and 20 characters.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&]).+$", + message = "Password confirmation must include at least one letter, one number, and one special character." + ) + String passwordConfirm, + + @Schema(description = "Company name", example = "IPX", nullable = true) + @Size(max = 200, message = "Company must be 200 characters or fewer.") + String company, + + @ArraySchema( + schema = @Schema(implementation = TermsAgreementRequest.class), + arraySchema = @Schema(description = "List of terms agreements") + ) + @NotEmpty(message = "At least one terms agreement is required.") + List<@Valid TermsAgreementRequest> termsAgreements +) { +} diff --git a/src/main/java/ceos/ipx/domain/user/dto/SignUpResponse.java b/src/main/java/ceos/ipx/domain/user/dto/SignUpResponse.java new file mode 100644 index 0000000..757f440 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/dto/SignUpResponse.java @@ -0,0 +1,25 @@ +package ceos.ipx.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Sign-up response") +public record SignUpResponse( + @Schema(description = "Created user ID", example = "1") + Long userId, + + @Schema(description = "Signed-up user email", example = "abcd@gmail.com") + String email, + + @Schema(description = "User name", example = "Kim Planner") + String name, + + @Schema(description = "Company name", example = "IPX", nullable = true) + String company, + + @Schema(description = "Authentication provider", example = "LOCAL") + String provider, + + @Schema(description = "Whether the user profile is completed", example = "true") + boolean profileCompleted +) { +} diff --git a/src/main/java/ceos/ipx/domain/user/dto/TermsAgreementRequest.java b/src/main/java/ceos/ipx/domain/user/dto/TermsAgreementRequest.java new file mode 100644 index 0000000..c0fa726 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/dto/TermsAgreementRequest.java @@ -0,0 +1,17 @@ +package ceos.ipx.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Terms agreement item") +public record TermsAgreementRequest( + @Schema(description = "Terms type", example = "SERVICE") + @NotBlank(message = "Terms type is required.") + String type, + + @Schema(description = "Whether the user agreed to the terms", example = "true") + @NotNull(message = "Terms agreement status is required.") + Boolean agreed +) { +} diff --git a/src/main/java/ceos/ipx/domain/user/entity/TermsAgreement.java b/src/main/java/ceos/ipx/domain/user/entity/TermsAgreement.java new file mode 100644 index 0000000..3870e80 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/entity/TermsAgreement.java @@ -0,0 +1,47 @@ +package ceos.ipx.domain.user.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "terms_agreements", indexes = { + @Index(name = "idx_ta_user", columnList = "user_id") +}) +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TermsAgreement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "terms_type", nullable = false, length = 50) + private TermsType termsType; + + @Column(name = "terms_version", nullable = false, length = 20) + private String termsVersion; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + private TermsAgreement(User user, TermsType termsType, String termsVersion) { + this.user = user; + this.termsType = termsType; + this.termsVersion = termsVersion; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/user/entity/TermsType.java b/src/main/java/ceos/ipx/domain/user/entity/TermsType.java new file mode 100644 index 0000000..14f28bf --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/entity/TermsType.java @@ -0,0 +1,5 @@ +package ceos.ipx.domain.user.entity; + +public enum TermsType { + SERVICE, PRIVACY, MARKETING +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/user/entity/User.java b/src/main/java/ceos/ipx/domain/user/entity/User.java new file mode 100644 index 0000000..70ff58e --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/entity/User.java @@ -0,0 +1,76 @@ +package ceos.ipx.domain.user.entity; + +import ceos.ipx.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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 255) + private String email; + + @Column(name = "password_hash", length = 255) + private String passwordHash; + + @Column(nullable = false, length = 100) + private String name; + + @Column(length = 200) + private String company; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserProvider provider = UserProvider.LOCAL; + + @Column(name = "provider_id", length = 255) + private String providerId; + + @Column(name = "is_active", nullable = false) + private boolean isActive = true; + + @Builder + private User( + String email, + String passwordHash, + String name, + String company, + UserProvider provider, + String providerId, + Boolean isActive + ) { + this.email = email; + this.passwordHash = passwordHash; + this.name = name; + this.company = company; + this.provider = provider == null ? UserProvider.LOCAL : provider; + this.providerId = providerId; + this.isActive = isActive == null ? true : isActive; + } + + public void updateProfile(String name, String company) { + this.name = name; + this.company = company; + } + + public void deactivate() { + this.isActive = false; + } +} diff --git a/src/main/java/ceos/ipx/domain/user/entity/UserProvider.java b/src/main/java/ceos/ipx/domain/user/entity/UserProvider.java new file mode 100644 index 0000000..b9e0e20 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/entity/UserProvider.java @@ -0,0 +1,6 @@ +package ceos.ipx.domain.user.entity; + +public enum UserProvider { + LOCAL, + GOOGLE +} diff --git a/src/main/java/ceos/ipx/domain/user/repository/UserRepository.java b/src/main/java/ceos/ipx/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..ce2fc5e --- /dev/null +++ b/src/main/java/ceos/ipx/domain/user/repository/UserRepository.java @@ -0,0 +1,12 @@ +package ceos.ipx.domain.user.repository; + +import ceos.ipx.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/ceos/ipx/global/config/JpaAuditingConfig.java b/src/main/java/ceos/ipx/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..9205392 --- /dev/null +++ b/src/main/java/ceos/ipx/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package ceos.ipx.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/ceos/ipx/global/config/SwaggerConfig.java b/src/main/java/ceos/ipx/global/config/SwaggerConfig.java new file mode 100644 index 0000000..0d4e78a --- /dev/null +++ b/src/main/java/ceos/ipx/global/config/SwaggerConfig.java @@ -0,0 +1,19 @@ +package ceos.ipx.global.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI ipxOpenApi() { + return new OpenAPI() + .info(new Info() + .title("IPX API Documentation") + .description("OpenAPI documentation for the IPX Spring Boot application.") + .version("v1")); + } +} diff --git a/src/main/java/ceos/ipx/global/entity/BaseEntity.java b/src/main/java/ceos/ipx/global/entity/BaseEntity.java new file mode 100644 index 0000000..acc237c --- /dev/null +++ b/src/main/java/ceos/ipx/global/entity/BaseEntity.java @@ -0,0 +1,26 @@ +package ceos.ipx.global.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/ceos/ipx/global/exception/BusinessException.java b/src/main/java/ceos/ipx/global/exception/BusinessException.java new file mode 100644 index 0000000..799734b --- /dev/null +++ b/src/main/java/ceos/ipx/global/exception/BusinessException.java @@ -0,0 +1,13 @@ +package ceos.ipx.global.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException{ + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java new file mode 100644 index 0000000..b5d98b4 --- /dev/null +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -0,0 +1,22 @@ +package ceos.ipx.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "서버 내부 오류가 발생했습니다."), + + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AU001", "이미 사용 중인 이메일입니다."), + PASSWORD_CONFIRM_MISMATCH(HttpStatus.BAD_REQUEST, "AU002", "비밀번호와 비밀번호 확인이 일치하지 않습니다."), + + UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/ceos/ipx/global/exception/GlobalExceptionHandler.java b/src/main/java/ceos/ipx/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..df966b2 --- /dev/null +++ b/src/main/java/ceos/ipx/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package ceos.ipx.global.exception; + +import ceos.ipx.global.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handlerBusinessException(BusinessException e) { + log.error("BusinessException: {}", e.getMessage()); + + ErrorCode errorCode = e.getErrorCode(); + + return ResponseEntity + .status(errorCode.getStatus()) + .body(ApiResponse.fail(errorCode)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handlerValidationException(MethodArgumentNotValidException e) { + log.error("Validation Exception: {}", e.getMessage()); + + String errorMessage = e.getBindingResult().getAllErrors().getFirst().getDefaultMessage(); + + ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE; + + return ResponseEntity + .status(errorCode.getStatus()) + .body(ApiResponse.fail(errorCode, errorMessage)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handlerException(Exception e) { + log.error("Unexpected Exception:", e); + + ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR; + + return ResponseEntity + .status(errorCode.getStatus()) + .body(ApiResponse.fail(errorCode)); + } +} diff --git a/src/main/java/ceos/ipx/global/response/ApiResponse.java b/src/main/java/ceos/ipx/global/response/ApiResponse.java new file mode 100644 index 0000000..a72c35a --- /dev/null +++ b/src/main/java/ceos/ipx/global/response/ApiResponse.java @@ -0,0 +1,25 @@ +package ceos.ipx.global.response; + +import ceos.ipx.global.exception.ErrorCode; + +public record ApiResponse( + boolean success, + T data, + ErrorResponse error +) { + public static ApiResponse ok() { + return new ApiResponse<>(true, null, null); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null); + } + + public static ApiResponse fail(ErrorCode errorCode) { + return new ApiResponse<>(false, null, ErrorResponse.of(errorCode)); + } + + public static ApiResponse fail(ErrorCode errorCode, String customMessage) { + return new ApiResponse<>(false, null, ErrorResponse.of(errorCode, customMessage)); + } +} diff --git a/src/main/java/ceos/ipx/global/response/ErrorResponse.java b/src/main/java/ceos/ipx/global/response/ErrorResponse.java new file mode 100644 index 0000000..f12f451 --- /dev/null +++ b/src/main/java/ceos/ipx/global/response/ErrorResponse.java @@ -0,0 +1,25 @@ +package ceos.ipx.global.response; + +import ceos.ipx.global.exception.ErrorCode; + +public record ErrorResponse( + int status, + String code, + String message +) { + public static ErrorResponse of(ErrorCode errorCode) { + return new ErrorResponse( + errorCode.getStatus().value(), + errorCode.getCode(), + errorCode.getMessage() + ); + } + + public static ErrorResponse of(ErrorCode errorCode, String customMessage) { + return new ErrorResponse( + errorCode.getStatus().value(), + errorCode.getCode(), + customMessage + ); + } +} diff --git a/src/main/java/ceos/ipx/global/security/config/SecurityConfig.java b/src/main/java/ceos/ipx/global/security/config/SecurityConfig.java new file mode 100644 index 0000000..0a8c9ab --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/config/SecurityConfig.java @@ -0,0 +1,116 @@ +package ceos.ipx.global.security.config; + +import ceos.ipx.global.security.handler.CustomAccessDeniedHandler; +import ceos.ipx.global.security.handler.CustomAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +// import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + // private final JwtAuthenticationFilter jwtAuthenticationFilter; // JWT 필터 구현 후 주입 + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // ===== CORS 설정 ===== + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // ===== CSRF 비활성화 ===== + // JWT 기반 stateless 인증 사용 -> 쿠키가 아닌 헤더를 사용하므로 CSRF 공격으로부터 안전 + .csrf(AbstractHttpConfigurer::disable) + + // ===== 세션 정책: STATELESS ===== + // JWT는 stateless -> 서버는 세션 사용 X + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // ===== HTTP Basic / Form Login, Logout 비활성화 ===== + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) + + // ===== 경로별 권한 설정 ===== + .authorizeHttpRequests(auth -> auth + + // [permitAll] + .requestMatchers(SecurityWhitelist.PERMIT_ALL_PATHS).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + + // [authenticated] + .requestMatchers(SecurityWhitelist.AUTHENTICATED_PATHS).authenticated() + + // [기본 정책] 그 외 모든 요청은 인증 필요 + .anyRequest().authenticated() + ); + + // ===== JWT 필터 추가 ===== + // .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // 허용 도메인 + config.setAllowedOrigins(List.of( + "http://localhost:3000", // 프론트 개발 환경 + "https://ipx-patent.com", // 운영 도메인 + "https://www.ipx-patent.com" // 운영 도메인 + )); + + // 허용 HTTP 메서드 + config.setAllowedMethods(List.of( + "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" + )); + + // 허용 헤더 (모든 헤더 허용) + config.setAllowedHeaders(List.of("*")); + + // 클라이언트에서 응답으로 받을 수 있는 헤더 + config.setExposedHeaders(List.of( + "Authorization", + "Set-Cookie" + )); + + // 인증 정보 전송 허용 (쿠키, Authorization 헤더) + config.setAllowCredentials(true); + + // Preflight 요청 캐시 시간 (초) + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java new file mode 100644 index 0000000..b539772 --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java @@ -0,0 +1,53 @@ +package ceos.ipx.global.security.config; + +// 화이트리스트 상수 클래스 +public final class SecurityWhitelist { + + private SecurityWhitelist() { + // 인스턴스 생성 방지 + } + + // ===== 누구나 접근 가능한 경로 ===== + public static final String[] PERMIT_ALL_PATHS = { + "/health", + + // 인증 관련 API + "/api/auth/signup", // 회원가입 + "/api/auth/login", // 로그인 + "/api/auth/logout", // 로그아웃 + "/api/auth/refresh", // 토큰 재발급 + "/api/auth/email/send-otp", // 이메일 OTP 발송 + "/api/auth/email/verify-otp", // 이메일 OTP 인증 + "/api/auth/password/reset", // 비밀번호 재설정 요청 + "/api/auth/password/reset/verify", // 비밀번호 재설정 확인 + + // OAuth 콜백 (Google 로그인) + "/oauth2/**", + "/login/oauth2/**", + + // 헬스체크 / 모니터링 + "/actuator/health", + "/actuator/info", + + // API 문서 (Swagger 사용 시) + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + }; + + // ===== 인증 필요한 경로 ===== + public static final String[] AUTHENTICATED_PATHS = { + + // 사용자 정보 관리 + "/api/users/**", // 이름 수정, 계정 삭제 등 + + // 프로젝트 관리 + "/api/projects/**", // 프로젝트 CRUD, 후보 저장/삭제 + + // 검색 (로그인한 사용자만 사용 가능하도록) + "/api/search/**", // 특허 검색 + + // 추가 인증 필요 작업 + "/api/auth/me" // 내 정보 조회 + }; +} diff --git a/src/main/java/ceos/ipx/global/security/handler/CustomAccessDeniedHandler.java b/src/main/java/ceos/ipx/global/security/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..197d55a --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,46 @@ +package ceos.ipx.global.security.handler; + +import ceos.ipx.global.exception.ErrorCode; +import ceos.ipx.global.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + + log.warn("[인가 오류] URI: {}", request.getRequestURI()); + + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse( + errorCode.getStatus().value(), + errorCode.getCode(), + errorCode.getMessage() + ); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/ceos/ipx/global/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..5505947 --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,46 @@ +package ceos.ipx.global.security.handler; + +import ceos.ipx.global.exception.ErrorCode; +import ceos.ipx.global.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + log.error("[인증 오류] URI: {}", request.getRequestURI()); + + ErrorCode errorCode = ErrorCode.UNAUTHORIZED_USER; + + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse( + errorCode.getStatus().value(), + errorCode.getCode(), + errorCode.getMessage() + ); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..c133397 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,33 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/patent_db + username: ${POSTGRES_USER:ipx_patent_user} + password: ${POSTGRES_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + + data: + redis: + host: localhost + port: 6379 + +logging: + level: + root: INFO + ceos.ipx: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.orm.jdbc.bind: TRACE + org.springframework.security: DEBUG + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + try-it-out-enabled: true diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..1489121 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,34 @@ +spring: + datasource: + url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: false + + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + +logging: + level: + root: INFO + ceos.ipx: INFO + org.hibernate.SQL: WARN + org.springframework.security: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n" + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f9a228a..666dd6d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,28 @@ spring: application: name: ipx-be + + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + open-in-view: false + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never + +search-server: + base-url: ${SEARCH_SERVER_URL:http://localhost:8000} + timeout-seconds: 120 + +server: + port: 8080 diff --git a/src/test/java/ceos/ipx/domain/auth/service/AuthServiceTest.java b/src/test/java/ceos/ipx/domain/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..65cae87 --- /dev/null +++ b/src/test/java/ceos/ipx/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,71 @@ +package ceos.ipx.domain.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ceos.ipx.domain.user.dto.SignUpRequest; +import ceos.ipx.domain.user.dto.SignUpResponse; +import ceos.ipx.domain.user.dto.TermsAgreementRequest; +import ceos.ipx.domain.user.entity.User; +import ceos.ipx.domain.user.entity.UserProvider; +import ceos.ipx.domain.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AuthService authService; + + @Captor + private ArgumentCaptor userCaptor; + + @Test + void signUpUsesUserDefaultLocalProvider() { + SignUpRequest request = new SignUpRequest( + "user@example.com", + "verification-token", + "Test User", + "Password123!", + "Password123!", + "IPX", + List.of(new TermsAgreementRequest("SERVICE", true)) + ); + + when(userRepository.existsByEmail(request.email())).thenReturn(false); + when(passwordEncoder.encode(request.password())).thenReturn("encoded-password"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User savedUser = invocation.getArgument(0); + ReflectionTestUtils.setField(savedUser, "id", 1L); + return savedUser; + }); + + SignUpResponse response = authService.signUp(request); + + verify(userRepository).save(userCaptor.capture()); + User savedUser = userCaptor.getValue(); + + assertThat(savedUser.getProvider()).isEqualTo(UserProvider.LOCAL); + assertThat(savedUser.getProviderId()).isNull(); + assertThat(savedUser.isActive()).isTrue(); + assertThat(response.userId()).isEqualTo(1L); + assertThat(response.provider()).isEqualTo(UserProvider.LOCAL.name()); + } +} diff --git a/src/test/java/ceos/ipx/domain/user/dto/SignUpRequestTest.java b/src/test/java/ceos/ipx/domain/user/dto/SignUpRequestTest.java new file mode 100644 index 0000000..98659a0 --- /dev/null +++ b/src/test/java/ceos/ipx/domain/user/dto/SignUpRequestTest.java @@ -0,0 +1,64 @@ +package ceos.ipx.domain.user.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class SignUpRequestTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void tearDownValidator() { + validatorFactory.close(); + } + + @Test + void validPasswordsPassComplexityValidation() { + SignUpRequest request = new SignUpRequest( + "user@example.com", + "verification-token", + "Test User", + "Password123!", + "Password123!", + "IPX", + List.of(new TermsAgreementRequest("SERVICE", true)) + ); + + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + void invalidPasswordAndConfirmationFailComplexityValidation() { + SignUpRequest request = new SignUpRequest( + "user@example.com", + "verification-token", + "Test User", + "Password123", + "Password!!!", + "IPX", + List.of(new TermsAgreementRequest("SERVICE", true)) + ); + + Set invalidFields = validator.validate(request).stream() + .map(violation -> violation.getPropertyPath().toString()) + .collect(Collectors.toSet()); + + assertThat(invalidFields).containsExactlyInAnyOrder("password", "passwordConfirm"); + } +} diff --git a/src/test/java/ceos/ipx/domain/user/entity/UserTest.java b/src/test/java/ceos/ipx/domain/user/entity/UserTest.java new file mode 100644 index 0000000..5665b18 --- /dev/null +++ b/src/test/java/ceos/ipx/domain/user/entity/UserTest.java @@ -0,0 +1,37 @@ +package ceos.ipx.domain.user.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class UserTest { + + @Test + void builderDefaultsProviderToLocalWhenOmitted() { + User user = User.builder() + .email("local@example.com") + .passwordHash("encoded-password") + .name("Local User") + .company("IPX") + .providerId(null) + .isActive(true) + .build(); + + assertThat(user.getProvider()).isEqualTo(UserProvider.LOCAL); + } + + @Test + void builderKeepsExplicitGoogleProvider() { + User user = User.builder() + .email("google@example.com") + .passwordHash("encoded-password") + .name("Google User") + .company("IPX") + .provider(UserProvider.GOOGLE) + .providerId("google-provider-id") + .isActive(true) + .build(); + + assertThat(user.getProvider()).isEqualTo(UserProvider.GOOGLE); + } +}