diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1229302b..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,52 +0,0 @@ -# 워크플로우 이름 -name: Spring CI on main/develop - -# 워크플로우 실행 조건: main 또는 develop 브랜치로 Pull Request가 생성될 때 실행 -on: - pull_request: - branches: [ "main", "develop" ] - paths: - - 'src/**' # src 디렉토리 하위 파일이 변경될 때만 실행 - -jobs: - # ================================== - # CI Job: Gradle 테스트 및 빌드 실행 - # ================================== - build-and-test: - runs-on: ubuntu-latest - - steps: - # 1. 소스 코드 체크아웃 - - name: Checkout source code - uses: actions/checkout@v4 - - # 2. JDK 21 설치 - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - # 3. Gradle 캐시 설정 - # 프로젝트 루트의 gradle 파일들을 기준으로 캐시를 설정합니다. - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - # 4. gradlew 실행 권한 부여 - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - - # 5. Gradle 테스트 실행 - - name: Test with Gradle - run: ./gradlew test - - # 6. Gradle 빌드 실행 (테스트 성공 시) - - name: Build with Gradle - run: ./gradlew build diff --git a/.github/workflows/prod-server.yml b/.github/workflows/prod-server.yml new file mode 100644 index 00000000..302e2f9d --- /dev/null +++ b/.github/workflows/prod-server.yml @@ -0,0 +1,182 @@ +# 워크플로우 이름 +name: Spring CD (Production) + +# main 브랜치 PR에서만 실행 (이미 빌드된 Docker 이미지 사용) +on: + push: + branches: + - main + paths: + - 'src/**' + - 'build.gradle*' + - 'settings.gradle*' + - 'gradle/**' + - 'Dockerfile' + - '.github/workflows/**' + +jobs: + # ================================== + # CD: Deploy to Production Environment + # ================================== + cd-prod: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy to Test Environment + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.PROD_SERVER_HOST }} + username: ec2-user + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + + # GHCR 로그인 (EC2) + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{github.repository_owner}}" --password-stdin + + # 최신 이미지 pull + docker pull ghcr.io/${{ github.repository }}/zoopzoop:latest + + # NPM API 설정 + NPM_HOST="localhost:81" + NPM_EMAIL="${{secrets.NPM_ADMIN_EMAIL}}" + NPM_PASSWORD="${{secrets.NPM_ADMIN_PASSWORD}}" + PROXY_HOST_ID="${{secrets.NPM_PROXY_HOST_ID}}" + + # NPM API 토큰 가져오기 + echo "Getting NPM API token..." + TOKEN=$(curl -s -X POST "http://${NPM_HOST}/api/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"${NPM_EMAIL}\",\"secret\":\"${NPM_PASSWORD}\"}" | \ + jq -r '.token') + if [ "$TOKEN" == "null" ] || [ -z "$TOKEN" ]; then + echo "❌ Failed to get NPM API token" + exit 1 + fi + + # 현재 NPM Proxy Host 설정 확인 + echo "📋 Checking current NPM configuration... 📋" + CURRENT_CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \ + "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}") + + CURRENT_TARGET=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_host // .forward_host') + CURRENT_PORT=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_port // .forward_port') + + echo "Current NPM target: $CURRENT_TARGET:$CURRENT_PORT" + + # Blue-Green 배포 + if [ "$(docker ps -q -f name=zoopzoop-blue)" ]; then + NEW_CONTAINER=zoopzoop-green + OLD_CONTAINER=zoopzoop-blue + NEW_PORT=8082 + else + NEW_CONTAINER=zoopzoop-blue + OLD_CONTAINER=zoopzoop-green + NEW_PORT=8081 + fi + + echo "Starting new container: $NEW_CONTAINER on port $NEW_PORT" + docker run -d --restart unless-stopped \ + -p $NEW_PORT:8080 \ + --name $NEW_CONTAINER \ + --network common \ + -e SPRING_PROFILES_ACTIVE=server \ + -e SPRING_DATASOURCE_URL="${{secrets.PROD_DB_URL}}" \ + -e SPRING_DATASOURCE_USERNAME="${{secrets.PROD_DB_USERNAME}}" \ + -e SPRING_DATASOURCE_PASSWORD="${{secrets.PROD_DB_PASSWORD}}" \ + -e AWS_S3_BUCKET_NAME="${{secrets.AWS_S3_BUCKET_NAME}}" \ + -e AWS_S3_PREFIX="${{secrets.PROD_AWS_S3_PREFIX}}" \ + -e SPRING_RABBITMQ_HOST="${{secrets.PROD_RABBITMQ_HOST}}" \ + -e SPRING_RABBITMQ_PORT="${{secrets.PROD_RABBITMQ_PORT}}" \ + -e SPRING_RABBITMQ_USERNAME="${{secrets.PROD_RABBITMQ_USERNAME}}" \ + -e SPRING_RABBITMQ_PASSWORD="${{secrets.PROD_RABBITMQ_PASSWORD}}" \ + -e REDIS_HOST="${{secrets.PROD_REDIS_HOST}}" \ + -e REDIS_PASSWORD="${{secrets.PROD_REDIS_PASSWORD}}" \ + -e KAKAO_CLIENT_ID="${{secrets.OAUTH_KAKAO_CLIENT_ID}}" \ + -e GOOGLE_CLIENT_ID="${{secrets.OAUTH_GOOGLE_CLIENT_ID}}" \ + -e GOOGLE_CLIENT_SECRET="${{secrets.OAUTH_GOOGLE_CLIENT_SECRET}}" \ + -e KAKAO_REDIRECT_URI="${{secrets.PROD_OAUTH_KAKAO_REDIRECT_URI}}" \ + -e GOOGLE_REDIRECT_URI="${{secrets.PROD_OAUTH_GOOGLE_REDIRECT_URI}}" \ + -e SENTRY_DSN="${{secrets.SENTRY_DSN}}" \ + -e OPENAI_API_KEY="${{secrets.PROD_OPENAI_API_KEY}}" \ + -e LIVEBLOCKS_SECRET_KEY="${{secrets.LIVEBLOCKS_SECRET_KEY}}" \ + -e NAVER_CLIENT_ID="${{secrets.NAVER_CLIENT_ID}}" \ + -e NAVER_CLIENT_SECRET="${{secrets.NAVER_CLIENT_SECRET}}" \ + -e JWT_SECRET_KEY="${{secrets.JWT_SECRET_KEY}}" \ + -e JWT_ACCESS_TOKEN_VALIDITY="${{secrets.JWT_ACCESS_TOKEN_VALIDITY}}" \ + -e JWT_REFRESH_TOKEN_VALIDITY="${{secrets.JWT_REFRESH_TOKEN_VALIDITY}}" \ + -e FRONT_REDIRECT_DOMAIN="${{secrets.FRONT_REDIRECT_DOMAIN}}" \ + -e FRONT_MAIN_DOMAIN="${{secrets.MAIN_DOMAIN}}" \ + -e ELASTIC_HOST="${{secrets.PROD_ELASTIC_HOST}}" \ + ghcr.io/${{ github.repository }}/zoopzoop:latest + + + # 헬스체크 (Spring Boot Actuator) + for i in {1..30}; do + if curl -s http://localhost:$NEW_PORT/actuator/health | grep -q '"status":"UP"'; then + echo "✅New container is healthy!" + break + else + echo "Waiting for new container to be healthy..." + sleep 5 + fi + + if [ $i -eq 30 ]; then + echo "❌ Health check failed. Rolling back..." + docker stop $NEW_CONTAINER || true + docker rm $NEW_CONTAINER || true + exit 1 + fi + done + + # NPM에서 트래픽 스위칭 + echo "🔄 Switching traffic in Nginx Proxy Manager..." + DOMAIN_NAME=$(echo $CURRENT_CONFIG | jq -r '.domain_names[0]') + CERT_ID=$(echo "$CURRENT_CONFIG" | jq -r '.certificate_id') + + SWITCH_RESPONSE=$(curl -s -w "%{http_code}" -X PUT "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"domain_names\": [\"$DOMAIN_NAME\"], + \"forward_scheme\": \"http\", + \"forward_host\": \"$NEW_CONTAINER\", + \"forward_port\": 8080, + \"caching_enabled\": false, + \"block_exploits\": true, + \"advanced_config\": \"\", + \"locations\": [], + \"certificate_id\": $CERT_ID, + \"ssl_forced\": 1, + \"hsts_enabled\": 1, + \"hsts_subdomains\": 1 + }") + + HTTP_CODE=${SWITCH_RESPONSE: -3} + if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 201 ]; then + echo "✅ Traffic switching completed successfully!" + echo "🎯 NPM now points to: $NEW_CONTAINER:8080" + + # 최종 확인 + sleep 5 + echo "🔍 Final verification..." + VERIFY_CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \ + "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}") + VERIFY_TARGET=$(echo $VERIFY_CONFIG | jq -r '.forward_host') + echo "✅ Verified NPM target: $VERIFY_TARGET" + + else + echo "❌ Traffic switching failed! HTTP Code: $HTTP_CODE" + echo "Response: ${SWITCH_RESPONSE%???}" + echo "🔄 Rolling back new container..." + docker stop $NEW_CONTAINER || true + docker rm $NEW_CONTAINER || true + exit 1 + fi + + # 이전 컨테이너 종료 및 제거 + echo "Stopping old container: $OLD_CONTAINER" + docker stop $OLD_CONTAINER || true + docker rm $OLD_CONTAINER || true \ No newline at end of file diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 00000000..3b7369f3 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,56 @@ +name: Terraform Apply + +on: + workflow_dispatch: + +jobs: + terraform-apply: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + with: + terraform_version: 1.7.6 + + - name: Terraform Init + working-directory: ./infra/terraform #워크플로우 파일 위치 폴더 기준 x, 체크아웃된 저장소 루트 기준 + run: terraform init + + - name: Set Environment based on Branch + run: | + BRANCH=${{ github.ref_name }} + if [ "$BRANCH" = "develop" ] ; then + ENV="test" + elif [ "$BRANCH" = "main" ] ; then + ENV="prod" + else + echo "Unsupported branch: $BRANCH" + exit 1 + fi + echo "Environment: $ENV" + + if [ "$ENV" = "prod" ] ; then + echo "${{secrets.TFVARS_PROD}}" > ./infra/terraform/env/terraform.tfvars + else + echo "${{secrets.TFVARS_TEST}}" > ./infra/terraform/env/terraform.tfvars + fi + + - name: Select or Create Workspace + working-directory: ./infra/terraform + run: | + if terraform workspace list | grep -q "$ENV"; then + terraform workspace select "$ENV" + else + terraform workspace new "$ENV" + fi + + - name: Terraform Apply + working-directory: ./infra/terraform/env + run: terraform apply -auto-approve -var-file="terraform.tfvars" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/test-server-cd.yml b/.github/workflows/test-server-cd.yml new file mode 100644 index 00000000..541561ed --- /dev/null +++ b/.github/workflows/test-server-cd.yml @@ -0,0 +1,141 @@ +name: Spring CD (Test Server) + +on: + push: + branches: + - develop + +jobs: + cd-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy to Test Environment + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.TEST_SERVER_HOST }} + username: ec2-user + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + # GHCR 로그인 + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.repository_owner }}" --password-stdin + docker pull ghcr.io/${{ github.repository }}/zoopzoop:latest + + NPM_HOST="localhost:81" + NPM_EMAIL="${{ secrets.NPM_ADMIN_EMAIL }}" + NPM_PASSWORD="${{ secrets.NPM_ADMIN_PASSWORD }}" + PROXY_HOST_ID="${{ secrets.NPM_PROXY_HOST_ID }}" + + # NPM 토큰 + TOKEN=$(curl -s -X POST "http://${NPM_HOST}/api/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"${NPM_EMAIL}\",\"secret\":\"${NPM_PASSWORD}\"}" | jq -r '.token') + if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then + echo "❌ Failed to get NPM API token" + exit 1 + fi + + CURRENT_CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \ + "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}") + + CURRENT_TARGET=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_host // .forward_host') + CURRENT_PORT=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_port // .forward_port') + echo "Current NPM target: $CURRENT_TARGET:$CURRENT_PORT" + + if [ "$(docker ps -q -f name=zoopzoop-blue)" ]; then + NEW_CONTAINER=zoopzoop-green + OLD_CONTAINER=zoopzoop-blue + NEW_PORT=8082 + else + NEW_CONTAINER=zoopzoop-blue + OLD_CONTAINER=zoopzoop-green + NEW_PORT=8081 + fi + + docker run -d --restart unless-stopped \ + -p $NEW_PORT:8080 \ + --name $NEW_CONTAINER \ + --network common \ + -e SPRING_PROFILES_ACTIVE=server \ + -e SPRING_DATASOURCE_URL="${{secrets.TEST_DB_URL}}" \ + -e SPRING_DATASOURCE_USERNAME="${{secrets.TEST_DB_USERNAME}}" \ + -e SPRING_DATASOURCE_PASSWORD="${{secrets.TEST_DB_PASSWORD}}" \ + -e AWS_S3_BUCKET_NAME="${{secrets.AWS_S3_BUCKET_NAME}}" \ + -e AWS_S3_PREFIX="${{secrets.TEST_AWS_S3_PREFIX}}" \ + -e SPRING_RABBITMQ_HOST="${{secrets.TEST_RABBITMQ_HOST}}" \ + -e SPRING_RABBITMQ_PORT="${{secrets.TEST_RABBITMQ_PORT}}" \ + -e SPRING_RABBITMQ_USERNAME="${{secrets.TEST_RABBITMQ_USERNAME}}" \ + -e SPRING_RABBITMQ_PASSWORD="${{secrets.TEST_RABBITMQ_PASSWORD}}" \ + -e REDIS_HOST="${{secrets.TEST_REDIS_HOST}}" \ + -e REDIS_PASSWORD="${{secrets.TEST_REDIS_PASSWORD}}" \ + -e KAKAO_CLIENT_ID="${{secrets.OAUTH_KAKAO_CLIENT_ID}}" \ + -e GOOGLE_CLIENT_ID="${{secrets.OAUTH_GOOGLE_CLIENT_ID}}" \ + -e GOOGLE_CLIENT_SECRET="${{secrets.OAUTH_GOOGLE_CLIENT_SECRET}}" \ + -e KAKAO_REDIRECT_URI="${{secrets.TEST_OAUTH_KAKAO_REDIRECT_URI}}" \ + -e GOOGLE_REDIRECT_URI="${{secrets.TEST_OAUTH_GOOGLE_REDIRECT_URI}}" \ + -e SENTRY_DSN="${{secrets.SENTRY_DSN}}" \ + -e OPENAI_API_KEY="${{secrets.OPENAI_API_KEY}}" \ + -e LIVEBLOCKS_SECRET_KEY="${{secrets.LIVEBLOCKS_SECRET_KEY}}" \ + -e NAVER_CLIENT_ID="${{secrets.NAVER_CLIENT_ID}}" \ + -e NAVER_CLIENT_SECRET="${{secrets.NAVER_CLIENT_SECRET}}" \ + -e JWT_SECRET_KEY="${{secrets.JWT_SECRET_KEY}}" \ + -e JWT_ACCESS_TOKEN_VALIDITY="${{secrets.JWT_ACCESS_TOKEN_VALIDITY}}" \ + -e JWT_REFRESH_TOKEN_VALIDITY="${{secrets.JWT_REFRESH_TOKEN_VALIDITY}}" \ + -e FRONT_REDIRECT_DOMAIN="${{secrets.TEST_REDIRECT_DOMAIN}}" \ + -e FRONT_MAIN_DOMAIN="${{secrets.MAIN_DOMAIN}}" \ + -e ELASTIC_HOST="${{secrets.TEST_ELASTIC_HOST}}" \ + ghcr.io/${{ github.repository }}/zoopzoop:latest + + # 헬스체크 + for i in {1..30}; do + if curl -s http://localhost:$NEW_PORT/actuator/health | grep -q '"status":"UP"'; then + echo "✅ New container is healthy!" + break + else + echo "Waiting for new container to be healthy..." + sleep 5 + fi + if [ $i -eq 30 ]; then + echo "❌ Health check failed. Rolling back..." + docker stop $NEW_CONTAINER || true + docker rm $NEW_CONTAINER || true + exit 1 + fi + done + + # NPM 트래픽 스위칭 + DOMAIN_NAME=$(echo $CURRENT_CONFIG | jq -r '.domain_names[0]') + CERT_ID=$(echo "$CURRENT_CONFIG" | jq -r '.certificate_id') + + SWITCH_RESPONSE=$(curl -s -w "%{http_code}" -X PUT "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"domain_names\": [\"$DOMAIN_NAME\"], + \"forward_scheme\": \"http\", + \"forward_host\": \"$NEW_CONTAINER\", + \"forward_port\": 8080, + \"caching_enabled\": false, + \"block_exploits\": true, + \"advanced_config\": \"\", + \"locations\": [], + \"certificate_id\": $CERT_ID, + \"ssl_forced\": 1, + \"hsts_enabled\": 1, + \"hsts_subdomains\": 1 + }") + + HTTP_CODE=${SWITCH_RESPONSE: -3} + if [ "$HTTP_CODE" -ne 200 ] && [ "$HTTP_CODE" -ne 201 ]; then + echo "❌ Traffic switching failed! HTTP Code: $HTTP_CODE" + echo "Response: ${SWITCH_RESPONSE%???}" + docker stop $NEW_CONTAINER || true + docker rm $NEW_CONTAINER || true + exit 1 + fi + + docker stop $OLD_CONTAINER || true + docker rm $OLD_CONTAINER || true diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml new file mode 100644 index 00000000..9eb6d672 --- /dev/null +++ b/.github/workflows/test-server-ci.yml @@ -0,0 +1,144 @@ +# 워크플로우 이름 +name: Spring CI/CD Pipeline (Develop) + +# develop 브랜치 PR에서만 실행 +on: + pull_request: + branches: + - develop + paths: + - 'src/**' + - 'build.gradle*' + - 'settings.gradle*' + - 'gradle/**' + - 'Dockerfile' + - '.github/workflows/**' + +jobs: + # ================================== + # CI: Test and Build and Push Docker Image + # ================================== + ci: + runs-on: ubuntu-latest + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + + services: + # CI 작업이 실행되는 동안 RabbitMQ 서비스 컨테이너를 함께 실행 + rabbitmq: + image: rabbitmq:3-management + ports: + - 5672:5672 + # RabbitMQ가 완전히 준비될 때까지 기다리는 상태 확인 옵션 + options: >- + --health-cmd "rabbitmq-diagnostics check_running" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + # CI 작업이 실행되는 동안 ElasticSearch 서비스 컨테이너 함께 실행 + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.18.5 + ports: + - 9200:9200 + options: >- + --env discovery.type=single-node + --env xpack.security.enabled=false + --env ES_JAVA_OPTS="-Xms512m -Xmx512m" + + steps: + # 1. 소스 코드 체크아웃 + - name: Checkout source code + uses: actions/checkout@v4 + + # 2. JDK 21 설치 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # 3. Gradle 캐시 설정 + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # 4. gradlew 실행 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # 5. Gradle 테스트 실행 + - name: Test with Gradle + # 테스트 단계에서 RabbitMQ 연결을 위한 환경 변수 설정 + env: + SPRING_PROFILES_ACTIVE: test + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + SPRING_RABBITMQ_HOST: localhost + SPRING_RABBITMQ_USERNAME: guest + SPRING_RABBITMQ_PASSWORD: guest + SPRING_DATA_ELASTICSEARCH_HOST: localhost + SPRING_DATA_ELASTICSEARCH_PORT: 9200 + KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }} + GOOGLE_CLIENT_ID: ${{ secrets.OAUTH_GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_S3_BUCKET_NAME: ${{ secrets.AWS_S3_BUCKET_NAME }} + AWS_S3_PREFIX: test/ + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + JWT_ACCESS_TOKEN_VALIDITY: ${{ secrets.JWT_ACCESS_TOKEN_VALIDITY }} + JWT_REFRESH_TOKEN_VALIDITY: ${{ secrets.JWT_REFRESH_TOKEN_VALIDITY }} + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} + LIVEBLOCKS_SECRET_KEY: ${{ secrets.LIVEBLOCKS_SECRET_KEY }} + FRONT_MAIN_DOMAIN: ${{secrets.MAIN_DOMAIN}} + run: ./gradlew test --stacktrace + + # 6. 테스트 결과 요약 출력 + - name: Show test results + if: always() # 테스트 실패 여부와 상관없이 항상 실행 + run: | + echo "==== Test Results ====" + if compgen -G "build/test-results/test/TEST-*.xml" > /dev/null; then + total=$(grep ' - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index d963bed8..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md new file mode 100644 index 00000000..25ea04c2 --- /dev/null +++ b/DEV_GUIDE.md @@ -0,0 +1,203 @@ +# ZoopZoop 백엔드 개발 컨벤션 + +--- + +## 1. 기본 규칙 + +- **언어** : Java +- **변수명 규칙** : camelCase +- **Merge 방식** : Squash & Merge + +--- + +## 2. 이슈 템플릿 + +### 분류 + +- `design` : UI 관련 (프론트엔드) +- `fix` : 버그 수정 +- `feat` : 기능 추가 +- `refactor` : 리팩토링 +- `chore` : 문서, 환경 설정 등 + +### 이슈 네이밍 규칙 + +- `[분류] 작업 제목` +- EX: `[feat] 로그인 쿠키 설정` + +--- + +## 3. 지라 티켓 + +- 레이블은 기존과 동일하게 사용 +- 티켓 네이밍 규칙: `[BE] 분류 : 작업 제목` +- EX: `[BE] feat : 로그인 쿠키 설정` + +--- + +## 4. 브랜치 전략 + +### 네이밍 규칙 + +- 형식: `{분류}#{이슈 번호}` +- EX: `feat#19` (19번 이슈에서 파생된 브랜치) + +### 전략 + +- **main** : 메인 서버 자동 배포, 고정 브랜치 +- **develop** : 내부 테스트 서버 자동 배포, 고정 브랜치 +- **feature** : 각 기능 개발마다 생성/삭제 +- **hotfix** : 긴급 수정 시 생성/삭제 + +### 지라 연동 + +- 형식: `분류/지라 디폴트 생성 브랜치명` +- EX: `feat/OPS-87-FE-필터링` + +--- + +## 5. 커밋 메시지 규칙 + +- 분류 + - `design` : UI + - `fix` : 버그 수정 + - `feat` : 기능 추가 + - `refactor` : 리팩토링 + - `chore` : 문서, 환경 설정 + - `docs` : 주석, 문서화 처리 + - `new` : 새로운 파일 생성 + +- 복수 성격인 경우 핵심 키워드 하나만 사용 +- 지라 연동: `분류/지라 티켓 키 : 내용` +- EX: `feat/PROJ-123 : implement login service` + +--- + +## 6. PR 템플릿 + +### 네이밍 + +- PR 이름: 이슈 이름 +- EX: `[feat] 로그인 쿠키 설정` + +### 코드 리뷰 + +- **develop**: 페어 개발자 검토 (부재 시 팀장 대행), 자동 CI +- **main**: 팀장 주도 확인, 자동 CI/CD +- 지라 연동: `[분류/지라 티켓 키] 이슈 이름` + 예: `[feat/PROJ-123] 로그인 쿠키 설정` + +--- + +## 7. 티켓 상태 관리 + +- `Backlog` : 시작 전 +- `Ready` : 작업자 지정, 시작 가능 +- `In progress` : 개발 중 +- `In review` : PR 작성 및 검토 중 +- `Done` : 완료 + +### 작업 순서 + +1. Issue 생성 → 상태: `Backlog`, Labels/Projects 설정 +2. Assignee 지정 → 상태 변경: `Ready` +3. 개발 시작 → 상태: `In progress`, 브랜치 생성 +4. 브랜치에서 작업 진행 +5. PR 생성 → 상태: `In review` +6. PR 머지 후 → Issue & PR `Done`, 브랜치 삭제 + +--- + +## 8. 폴더 구조 규칙 + +```text +com +└── back + ├── domain + │ ├── member + │ └── team + │ ├── repository + │ ├── service + │ ├── controller + │ ├── entity + │ └── dto + └── global +``` +- domain 하위 depth는 1 유지 (필요 시 리팩토링) + +--- + +## 9. DTO 규칙 +### 규칙 +- Controller 단에서 request/response body와 매칭되는 경우 DTO 사용 +- Response body는 최소 정보 전달 (id 등 내부 키값은 숨김) +- Service 단에서는 DTO 재활용 지양 +- Controller DTO와 별도 정의 +- 필요한 경우 주석 충실히 작성 + +### 네이밍 +- reqBodyFor~ : Request Body DTO +- resBodyFor~ : Response Body DTO + +--- + +## 10. 테스트 코드 컨벤션 + +- 형식: given-when-then + + - Controller 단에서 단위 테스트 작성 + - Service 핵심 메서드 단위 테스트 작성 + - 예외 케이스, 엣지 케이스 포함 + +--- + +## 11. 예외 처리 방식 + +- 에러 코드: 개발 진행 중 판단 + +- 핵심 에러는 global 핸들러 사용 + +--- + +## 12. 보안 처리 + +- Spring Security 사용 + +- Secrets 값 관리 + +- 개발 환경: application-secret.yml (gitignore) + +- 운영 환경: Github Secrets, CI/CD에서 컨테이너 환경 변수로 입력 + +--- + +## 13. 문서화 컨벤션 + +- OpenAPI: Swagger 사용 + +- Controller, DTO, Entity에 API 어노테이션 충실 + +- 주석 처리: Javadoc 스타일 + +```java +/** + * @param aa + * @param bb + * @param cc + */ +``` + +--- + +## 14. HTTP 응답 양식 +```json +{ + "status": 200, + "msg": "사용자 정보를 조회했습니다.", + "data": { + "name": "$name", + "profileUrl": "$profileUrl" + } +} +``` +- 성공/실패 관계 없이 RsData 형태로 반환 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e7568269 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:21-jdk-slim +COPY build/libs/backend-0.0.1-SNAPSHOT.jar /app.jar + +ENV SPRING_PROFILES_ACTIVE=server + +ENTRYPOINT ["java","-jar","/app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 89e78abf..2635cb28 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,134 @@ -# WEB6_8_ZOOPZOOP_BE + WEB6_8_ZOOPZOOP_BE 사용자 맞춤형 자료 추천과 시각화 아카이빙을 결합한 협업 플랫폼 - 서버 파트 + +--- + +## 목차 +1. [소개](#소개) +2. [핵심 기능](#핵심-기능) +3. [기술 스택](#기술-스택) +4. [프로젝트 구조](#프로젝트-구조) +5. [설치 및 실행](#설치-및-실행) +6. [추가 자료](#추가-자료) + +--- + +## 💡소개 +본 프로젝트는 웹 서핑 중 발견한 정보를 **효율적으로 수집, 요약, 정리**하고, 이를 팀 단위로 **공유 및 브레인스토밍**까지 이어갈 수 있는 **지식 관리 및 협업 플랫폼**을 목표로 합니다. + +**홈페이지:** [ZoopZoop](https://www.zoopzoop.kro.kr/) + +FE Repository: [Link](https://github.com/prgrms-web-devcourse-final-project/WEB5_6_ZOOPZOOP_FE) + +Chrome Extension Repository: [Link](https://github.com/prgrms-web-devcourse-final-project/WEB5_6_ZOOPS_TENSION_FE) + +--- + +## 📄핵심 기능 + +#### A. 정보 수집 +- **크롬 확장자(Extension) 제공** + - 웹 페이지에서 원하는 부분 선택 후 저장 + - 제목, 본문 요약, URL, 태그, 썸네일을 카드뷰 형태로 개인 아카이브에 저장 +- **직접 URL 저장** + - 사용자가 원하는 웹 페이지 URL을 개인 아카이브에 직접 추가 가능 + +#### B. 개인 아카이브 +- **카테고리 분류** + - 폴더 생성 후 데이터를 카테고리별로 분류 가능 +- **대시보드** + - 수집한 정보 카드 형태로 한눈에 확인 + +#### C. 협업 기능 (공유 아카이브) +- 팀원 초대 → 동일한 대시보드 공유 +- 팀원이 공유한 데이터에 댓글 작성 가능 → 브레인스토밍 지원 +- 여러 카드 배열/연결 → **지식 맵(마인드맵) 형태 구성** +- 공통 수정 및 실시간 동기화 (Liveblocks 사용) +- **AI 추천 기능** → 공유된 URL 태그 기반 맞춤형 뉴스 추천 + +#### D. AI 기반 맞춤형 뉴스 추천 +- 뉴스 API 연동 → 특정 키워드 관련 최신 뉴스 수집 +- **오늘의 뉴스 추천** +- 개인 & 공유 아카이브 내용 기반 **맞춤형 뉴스 추천** + +--- + +## 🔧기술 스택 +- **Framework & Language:** Spring Boot 3.5.5, Java 21 +- **Database:** MySQL (production), H2 (development) +- **Security & Auth:** Spring Security, OAuth2, JWT +- **AI:** Spring AI, Groq +- **Testing:** JUnit, Mockito +- **Deployment:** Docker +- **Utilities & External Services:** + - Messaging: RabbitMQ + - Caching & TTL: Redis + - Documentation: Swagger + - Monitoring: Sentry + - Crawling: Jsoup + - Storage: AWS S3 + - Search Engine: Elastic Search + +--- + +## 📁프로젝트 구조 +``` +src/main/java/org/tuna/zoopzoop/backend +├── domain +│ └── archive # 아카이브 +│ ├── archive # 아카이브 로직 +│ └── folder # 아카이브 내 폴더 로직 +│ ├── auth # 인증/인가 비즈니스 로직 +│ ├── dashboard # Liveblocks 그래프 데이터 +│ ├── datasource # 자료 데이터 크롤링 및 저장 +│ ├── home # 백엔드 홈 화면 컨트롤러 +│ ├── member # 사용자 +│ ├── news # 뉴스 조회 API +│ ├── space # 스페이스(협업 공간) 관련 +│ ├── archive # 스페이스 공용 아카이브 +│ ├── membership # 스페이스 권한 관리 +│ └── space # 스페이스 비즈니스 로직 +│ └── SSE # SSE 연결 +└── global + ├── aspect # AOP 공통 로직 (로깅/응답 등) + ├── aws # AWS 관련 유틸리티 + ├── clients # 외부 API 클라이언트 + ├── config # 각종 환경 설정(JWT, ElasticSearch, Redis, RabbitMQ 등) + ├── exception # 글로벌 예외 처리 + ├── headlessBrowser # 크롤링용 헤드리스 브라우저 + ├── initData # 테스트용 초기 데이터 + ├── jpa # 공용 엔티티 + ├── rsData # RsData 응답 객체 + ├── security # Spring Security + ├── springDoc # OpenAPI/Swagger & 문서화 + ├── test # 모니터링 테스트 + └── webMvc # 공통 WebMVC 설정 +``` + +--- + +## 🔌설치 및 실행 + +``` +# 1. 환경 설정 +# application-secrets.yml.template를 참고하여 application-secrets.yml을 작성합니다. + +# 2. 의존성 설치 +./gradlew build + +# 3. RabbitMQ, Elastic Search 컨테이너 실행 +# 별도로 로컬 환경에 Redis가 설치되어 있어야 합니다. +docker compose up -d +docker ps // 정상적으로 실행 중인지 확인. + +# 4. 로컬 서버 실행 +java -jar build/libs/backend-0.0.1-SNAPSHOT.jar + +# 5. 정상 작동 확인 +# API 문서: http://localhost:8080/swagger-ui.html +``` + +--- + +## 📑추가 자료 +[**백엔드 개발 컨벤션**](./DEV_GUIDE.md) diff --git a/build.gradle b/build.gradle index d6120730..cb6516a7 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.5' id 'io.spring.dependency-management' version '1.1.7' + id "io.sentry.jvm.gradle" version "5.12.0" } group = 'org.tuna.zoopzoop' @@ -24,6 +25,14 @@ repositories { mavenCentral() } +ext { + springAiVersion = "1.0.0" +} + +test { + useJUnitPlatform() +} + dependencies { // Spring Data JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -31,12 +40,26 @@ dependencies { // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + // Spring Oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // Spring Web (MVC) implementation 'org.springframework.boot:spring-boot-starter-web' + // WebFlux (소셜 로그인을 위한 비동기 프레임워크) + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Bean Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + // QueryDSL JPA + APT (Jakarta) + implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + + // APT가 jakarta 패키지 인식하도록 추가 + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // Lombok (compile only) compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -59,13 +82,75 @@ dependencies { // OpenAPI / Swagger UI implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + // OpenAPI - nullable support + implementation "org.openapitools:jackson-databind-nullable:0.2.6" + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // Spring AI + implementation ('org.springframework.ai:spring-ai-starter-model-openai'){ + exclude group: 'io.swagger.core.v3', module: 'swagger-annotations' + } + + // 크롤링 + implementation("org.jsoup:jsoup:1.21.2") + + // Spring Boot Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Mysql driver + implementation 'mysql:mysql-connector-java:8.0.33' + + // AWS SDK for S3 + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.4.0' + + // Playwright for Java + implementation 'com.microsoft.playwright:playwright:1.54.0' + + // Sentry (모니터링 용) + implementation 'io.sentry:sentry-spring-boot-starter-jakarta:8.22.0' + + // Apache Commons Codec + implementation"commons-codec:commons-codec:1.19.0" + + // Redis (Spring starter) + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // RabbitMQ (Spring starter) + implementation 'org.springframework.boot:spring-boot-starter-amqp' + testImplementation 'org.springframework.amqp:spring-rabbit-test' + + // Awaitility (비동기 테스트 지원) + testImplementation 'org.awaitility:awaitility:4.2.0' + + // Elastic Search + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' + + // retry (ai retry용) + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' +} + +dependencyManagement { + imports { + mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}") + } } tasks.named('test') { useJUnitPlatform() } + +sentry { + // Generates a JVM (Java, Kotlin, etc.) source bundle and uploads your source code to Sentry. + // This enables source context, allowing you to see your source + // code as part of your stack traces in Sentry. + includeSourceContext = true + + org = "whitedoggy" + projectName = "zoopzoop-backend" + authToken = System.getenv("SENTRY_AUTH_TOKEN") +} diff --git a/db_dev.trace.db b/db_dev.trace.db deleted file mode 100644 index 12a29b94..00000000 --- a/db_dev.trace.db +++ /dev/null @@ -1,3 +0,0 @@ -2025-09-17 16:09:58.499216+09:00 jdbc[13]: exception -org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "TSPACE" not found; SQL statement: -SELECT * FROM TSPACE AG [42102-232] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..981efe7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: '3.8' +services: + rabbitmq: + image: rabbitmq:3-management + container_name: local-rabbitmq + ports: + - "5672:5672" # AMQP + - "15672:15672" # Management UI + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + restart: unless-stopped + volumes: + - rabbitmq-data:/var/lib/rabbitmq + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.18.5 + container_name: local-elasticsearch + ports: + - "9200:9200" + environment: + discovery.type: single-node + xpack.security.enabled: "false" + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + restart: unless-stopped + volumes: + - elasticsearch-data:/usr/share/elasticsearch/data + +volumes: + rabbitmq-data: + elasticsearch-data: diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 00000000..233b2d07 --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,7 @@ +.terraform/ +terraform.tfstate +terraform.tfstate.backup +.terraform.tfstate.lock.info +.terraform.lock.hcl +env/*.tfvars +.terraform.tfstate.d/ \ No newline at end of file diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 00000000..43e3be14 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,68 @@ +#루트에서 모듈 호출 +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} + +provider "aws" { + region = var.region +} + + +module "vpc" { + source = "./modules/vpc" + prefix = var.prefix + region = var.region +} + +module "sg"{ + source = "./modules/sg" + vpc_id = module.vpc.vpc_id + prefix = var.prefix + create_rds = var.create_rds +} + +module "iam"{ + source = "./modules/iam" + prefix = var.prefix +} + +module "ec2" { + source = "./modules/ec2" + ami = var.ami + ec2_instance_type = var.ec2_instance_type + subnet_id = module.vpc.subnet_ids[0] + ec2_sg_id = module.sg.ec2_sg_id + iam_instance_profile = module.iam.instance_profile_name + key_name = var.key_name + prefix = var.prefix + test_mysql_root_password = var.test_mysql_root_password + test_mysql_db_name = var.test_mysql_db_name + create_rds = var.create_rds +} + +module "rds" { + source = "./modules/rds" + + count = var.create_rds ? 1 : 0 + + identifier = var.identifier + engine = var.engine + engine_version = var.engine_version + rds_instance_class = var.rds_instance_type + allocated_storage = var.allocated_storage + storage_type = var.storage_type + prod_mysql_db_username = var.prod_mysql_db_username + prod_mysql_root_password = var.prod_mysql_root_password + prod_mysql_db_name = var.prod_mysql_db_name + vpc_security_group_ids = [module.sg.rds_sg_id] + private_subnet_ids = module.vpc.private_subnet_ids + multi_az = var.multi_az + skip_final_snapshot = var.skip_final_snapshot + tags = { + Name = "${var.prefix}-rds" + } +} diff --git a/infra/terraform/modules/ec2/main.tf b/infra/terraform/modules/ec2/main.tf new file mode 100644 index 00000000..000697c5 --- /dev/null +++ b/infra/terraform/modules/ec2/main.tf @@ -0,0 +1,61 @@ +locals { + mysql_user_data= var.create_rds?"": <<-END1 + # MySQL 컨테이너 실행 (테스트 환경일 때만) +docker run -d --name mysql \ + --restart unless-stopped \ + --network common \ + -p 3306:3306 \ + -e MYSQL_ROOT_PASSWORD=${var.test_mysql_root_password} \ + -e MYSQL_DATABASE=${var.test_mysql_db_name} \ + -v /opt/mysql/data:/var/lib/mysql \ + mysql:latest +END1 + + user_data = <<-END2 +#!/bin/bash +# Swap 설정 +dd if=/dev/zero of=/swapfile bs=128M count=32 +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile +echo "/swapfile swap swap defaults 0 0" >> /etc/fstab + +# Docker 설치 +yum install -y docker +systemctl enable docker +systemctl start docker +docker network create common + +# Nginx, Redis, MySQL 컨테이너 실행 (테스트용) +docker run -d --name npm \ + --restart unless-stopped \ + --network common \ + -p 80:80 -p 443:443 -p 81:81 \ + -v /opt/npm/data:/data \ + -v /opt/npm/letsencrypt:/etc/letsencrypt \ + jc21/nginx-proxy-manager:latest +# docker run -d --name redis_1 --restart unless-stopped --network common -p 6379:6379 -e TZ=Asia/Seoul redis + +${local.mysql_user_data} +END2 +} + +resource "aws_instance" "this" { + ami = var.ami + instance_type = var.ec2_instance_type + subnet_id = var.subnet_id + vpc_security_group_ids = [var.ec2_sg_id] + iam_instance_profile = var.iam_instance_profile + associate_public_ip_address = true + key_name = var.key_name + + root_block_device { + volume_type = "gp3" + volume_size = 25 + tags = { Name = "${var.prefix}-ebs" } + } + + user_data = local.user_data + + tags = { Name = "${var.prefix}-ec2" } +} diff --git a/infra/terraform/modules/ec2/outputs.tf b/infra/terraform/modules/ec2/outputs.tf new file mode 100644 index 00000000..b7c05684 --- /dev/null +++ b/infra/terraform/modules/ec2/outputs.tf @@ -0,0 +1,3 @@ +output "ec2_public_ip" { + value = aws_instance.this.public_ip +} diff --git a/infra/terraform/modules/ec2/variables.tf b/infra/terraform/modules/ec2/variables.tf new file mode 100644 index 00000000..9980bedb --- /dev/null +++ b/infra/terraform/modules/ec2/variables.tf @@ -0,0 +1,20 @@ +variable "ami" { type = string } +variable "ec2_instance_type" { type = string } +variable "subnet_id" { type = string } +variable "ec2_sg_id" { type = string } +variable "iam_instance_profile" { type = string } +variable "key_name" { type = string } +variable "prefix" { type = string } +# variable "redis_password" { type = string } +variable "test_mysql_root_password" { + type = string + default = null +} +variable "test_mysql_db_name" { + type=string + default = null +} +variable "create_rds" { + type = bool + default = false +} \ No newline at end of file diff --git a/infra/terraform/modules/iam/main.tf b/infra/terraform/modules/iam/main.tf new file mode 100644 index 00000000..202b41d5 --- /dev/null +++ b/infra/terraform/modules/iam/main.tf @@ -0,0 +1,31 @@ +# EC2 역할 생성 +resource "aws_iam_role" "ec2_role" { + name = "${var.prefix}-ec2-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +# 역할에 S3 접근 정책 부착 (사용하지 않을 경우 주석 처리) +resource "aws_iam_role_policy_attachment" "s3_full" { + role = aws_iam_role.ec2_role.name + policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess" +} + +# 역할에 SSM 접근 정책 부착 (AWS Systems Manager) +resource "aws_iam_role_policy_attachment" "ssm" { + role = aws_iam_role.ec2_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM" +} + +# EC2에서 역할을 사용할 수 있게 인스턴스 프로파일 생성 +resource "aws_iam_instance_profile" "this" { + name = "${var.prefix}-instance-profile" + role = aws_iam_role.ec2_role.name +} diff --git a/infra/terraform/modules/iam/outputs.tf b/infra/terraform/modules/iam/outputs.tf new file mode 100644 index 00000000..87ca677d --- /dev/null +++ b/infra/terraform/modules/iam/outputs.tf @@ -0,0 +1,3 @@ +output "instance_profile_name" { + value = aws_iam_instance_profile.this.name +} diff --git a/infra/terraform/modules/iam/variables.tf b/infra/terraform/modules/iam/variables.tf new file mode 100644 index 00000000..c9bd54cb --- /dev/null +++ b/infra/terraform/modules/iam/variables.tf @@ -0,0 +1 @@ +variable "prefix" {type=string} \ No newline at end of file diff --git a/infra/terraform/modules/rds/main.tf b/infra/terraform/modules/rds/main.tf new file mode 100644 index 00000000..21aa8314 --- /dev/null +++ b/infra/terraform/modules/rds/main.tf @@ -0,0 +1,22 @@ +resource "aws_db_instance" "this" { + identifier = var.identifier + engine = var.engine + engine_version = var.engine_version + instance_class = var.rds_instance_class + allocated_storage = var.allocated_storage + storage_type = var.storage_type + username = var.prod_mysql_db_username + password = var.prod_mysql_root_password + db_name = var.prod_mysql_db_name + vpc_security_group_ids = var.vpc_security_group_ids + db_subnet_group_name = aws_db_subnet_group.this.name + skip_final_snapshot = var.skip_final_snapshot + multi_az = var.multi_az + tags = var.tags +} + +resource "aws_db_subnet_group" "this" { + name = "${var.identifier}-db-subnet-group" + subnet_ids = var.private_subnet_ids + tags = var.tags +} \ No newline at end of file diff --git a/infra/terraform/modules/rds/outputs.tf b/infra/terraform/modules/rds/outputs.tf new file mode 100644 index 00000000..e1166aa2 --- /dev/null +++ b/infra/terraform/modules/rds/outputs.tf @@ -0,0 +1,7 @@ +output "endpoint"{ + value = aws_db_instance.this.endpoint +} + +output "arn"{ + value = aws_db_instance.this.arn +} \ No newline at end of file diff --git a/infra/terraform/modules/rds/variables.tf b/infra/terraform/modules/rds/variables.tf new file mode 100644 index 00000000..83584a94 --- /dev/null +++ b/infra/terraform/modules/rds/variables.tf @@ -0,0 +1,14 @@ +variable "identifier" {} +variable "engine" {} +variable "engine_version" {} +variable "rds_instance_class" {} +variable "allocated_storage" {} +variable "storage_type" {} +variable "prod_mysql_db_username" {} +variable "prod_mysql_root_password" {} +variable "prod_mysql_db_name" {} +variable "vpc_security_group_ids" { type = list(string) } +variable "private_subnet_ids" { type = list(string) } +variable "multi_az" { type = bool } +variable "tags" { type = map(string) } +variable "skip_final_snapshot" { type = bool } \ No newline at end of file diff --git a/infra/terraform/modules/sg/ec2_sg.tf b/infra/terraform/modules/sg/ec2_sg.tf new file mode 100644 index 00000000..262b3b81 --- /dev/null +++ b/infra/terraform/modules/sg/ec2_sg.tf @@ -0,0 +1,64 @@ +resource "aws_security_group" "ec2_sg" { + name = "${var.prefix}-ec2-sg" + vpc_id = var.vpc_id + + description = "EC2 security group" + + ingress { + description = "Allow HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Allow HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Allow SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + ingress{ + from_port = 81 + to_port = 81 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + # 개발시에만 열어놓고, 운영시에는 닫기 + ingress{ + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + # 개발시에만 열어놓고, 운영시에는 닫기 + ingress{ + from_port = 3306 + to_port = 3306 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.prefix}-ec2-sg" + } +} diff --git a/infra/terraform/modules/sg/outputs.tf b/infra/terraform/modules/sg/outputs.tf new file mode 100644 index 00000000..c598e120 --- /dev/null +++ b/infra/terraform/modules/sg/outputs.tf @@ -0,0 +1,7 @@ +output "ec2_sg_id" { + value = aws_security_group.ec2_sg.id +} + +output "rds_sg_id" { + value = var.create_rds?aws_security_group.rds_sg[0].id:null +} \ No newline at end of file diff --git a/infra/terraform/modules/sg/rds_sg.tf b/infra/terraform/modules/sg/rds_sg.tf new file mode 100644 index 00000000..b8b940b1 --- /dev/null +++ b/infra/terraform/modules/sg/rds_sg.tf @@ -0,0 +1,28 @@ +resource "aws_security_group" "rds_sg" { + count = var.create_rds ? 1 : 0 + + name = "${var.prefix}-rds-sg" + vpc_id = var.vpc_id + + description = "RDS security group" + + # EC2에서 RDS 접속 허용 + ingress { + description = "Allow MySQL from EC2" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_groups = [aws_security_group.ec2_sg.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.prefix}-rds-sg" + } +} diff --git a/infra/terraform/modules/sg/variables.tf b/infra/terraform/modules/sg/variables.tf new file mode 100644 index 00000000..97aeb59a --- /dev/null +++ b/infra/terraform/modules/sg/variables.tf @@ -0,0 +1,3 @@ +variable "prefix" {type=string} +variable "vpc_id" {type=string} +variable "create_rds" {type = string} \ No newline at end of file diff --git a/infra/terraform/modules/vpc/main.tf b/infra/terraform/modules/vpc/main.tf new file mode 100644 index 00000000..e210cb48 --- /dev/null +++ b/infra/terraform/modules/vpc/main.tf @@ -0,0 +1,71 @@ + +resource "aws_vpc" "this"{ + cidr_block = "10.0.0.0/16" + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.prefix}-vpc" + } +} + +resource "aws_subnet" "public"{ + vpc_id = aws_vpc.this.id + cidr_block = "10.0.1.0/24" + availability_zone = "${var.region}a" + map_public_ip_on_launch = true + tags = {Name = "${var.prefix}-subnet-public"} +} + +resource "aws_subnet" "private"{ + vpc_id = aws_vpc.this.id + cidr_block = "10.0.2.0/24" + availability_zone = "${var.region}b" + map_public_ip_on_launch = false + tags = {Name = "${var.prefix}-subnet-private"} +} + +resource "aws_subnet" "private2"{ + vpc_id = aws_vpc.this.id + cidr_block = "10.0.3.0/24" + availability_zone = "${var.region}c" + map_public_ip_on_launch = false + tags = {Name = "${var.prefix}-subnet-private2"} +} + +resource "aws_internet_gateway" "this"{ + vpc_id = aws_vpc.this.id + + tags = { + Name = "${var.prefix}-igw" + } +} + +resource "aws_route_table" "this" { + vpc_id = aws_vpc.this.id + + route{ + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } + + tags = { + Name = "${var.prefix}-public-rt" + } +} + +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.this.id +} + +# 고가용성 구성이 필요할때 +# resource "aws_route_table_association" "c" { +# subnet_id = aws_subnet.c.id +# route_table_id = aws_route_table.this.id +# } +# +# resource "aws_route_table_association" "d" { +# subnet_id = aws_subnet.d.id +# route_table_id = aws_route_table.this.id +# } diff --git a/infra/terraform/modules/vpc/outputs.tf b/infra/terraform/modules/vpc/outputs.tf new file mode 100644 index 00000000..a30b9948 --- /dev/null +++ b/infra/terraform/modules/vpc/outputs.tf @@ -0,0 +1,18 @@ +output "vpc_id" { + value = aws_vpc.this.id +} + +output "subnet_ids" { + value = [ + aws_subnet.public.id, + aws_subnet.private.id, + aws_subnet.private2.id + ] +} + +output "private_subnet_ids"{ + value = [ + aws_subnet.private.id, + aws_subnet.private2.id + ] +} \ No newline at end of file diff --git a/infra/terraform/modules/vpc/variables.tf b/infra/terraform/modules/vpc/variables.tf new file mode 100644 index 00000000..04c7959d --- /dev/null +++ b/infra/terraform/modules/vpc/variables.tf @@ -0,0 +1,2 @@ +variable "prefix" {type=string} +variable "region" {type=string} \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 00000000..9a446954 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,3 @@ +output "ec2_public_ip" { + value = module.ec2.ec2_public_ip +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 00000000..061aa598 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,68 @@ +#공통 변수 정의 + +#EC2 +variable "region" { type = string } +variable "prefix" { type = string } +variable "ami" { type = string } +variable "ec2_instance_type" { type = string } +variable "key_name" { type = string } +#variable "redis_password" { type = string } +variable "test_mysql_root_password" { + type = string + default = null +} +variable "test_mysql_db_name" { + type = string + default = null +} + + +#RDS +variable "create_rds" { + description = "RDS 생성 여부" + type = bool +} +variable "identifier" { + type = string + default = null +} +variable "engine" { + type = string + default = null +} +variable "engine_version" { + type = string + default = null +} +variable "rds_instance_type" { + type = string + default = null +} +variable "allocated_storage" { + type = number + default = null +} +variable "storage_type" { + type = string + default = null +} +variable "multi_az" { + type = bool + default = null +} +variable "prod_mysql_db_username" { + type = string + default = null +} +variable "prod_mysql_root_password" { + type = string + default = null +} +variable "prod_mysql_db_name" { + type = string + default = null +} +variable "skip_final_snapshot" { + type = string + default = null +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java index ba492291..a4896d5d 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java +++ b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java @@ -2,12 +2,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableJpaAuditing +@EnableScheduling public class BackendApplication { - public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); } + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/controller/ApiV1NotificationController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/controller/ApiV1NotificationController.java new file mode 100644 index 00000000..06528c19 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/controller/ApiV1NotificationController.java @@ -0,0 +1,34 @@ +package org.tuna.zoopzoop.backend.domain.SSE.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.tuna.zoopzoop.backend.domain.SSE.service.EmitterService; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class ApiV1NotificationController { + private final EmitterService emitterService; + + /** + * SSE 구독 엔드포인트 + * @param userDetails - 현재 인증된 사용자 정보 + * @return SseEmitter - 클라이언트와의 SSE 연결을 관리하는 객체 + */ + @GetMapping(value = "/subscribe", produces = "text/event-stream") + public SseEmitter subscribe( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + // 1. 현재 로그인한 사용자의 ID를 가져옴 + Long memberId = (long) userDetails.getMember().getId(); + + // 2. EmitterService를 통해 Emitter를 생성하고 반환 + return emitterService.addEmitter(memberId); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java new file mode 100644 index 00000000..a5ef4f2c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/SSE/service/EmitterService.java @@ -0,0 +1,75 @@ +package org.tuna.zoopzoop.backend.domain.SSE.service; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class EmitterService { + // 1. 모든 Emitter를 저장하는 ConcurrentHashMap + private final Map emitters = new ConcurrentHashMap<>(); + + /** + * 새로운 Emitter 생성 및 저장 + * @param memberId - 사용자 ID + * @return SseEmitter - 생성된 Emitter 객체 + */ + public SseEmitter addEmitter(Long memberId) { + // 1시간 타임아웃 설정 + SseEmitter emitter = new SseEmitter(3600L * 1000); + this.emitters.put(memberId, emitter); + + // Emitter 완료 또는 타임아웃 시 Map에서 삭제 + emitter.onCompletion(() -> this.emitters.remove(memberId)); + emitter.onTimeout(() -> this.emitters.remove(memberId)); + + // 503 에러 방지를 위한 더미 이벤트 전송 + try { + emitter.send(SseEmitter.event().name("connect").data("SSE connected!")); + } catch (IOException e) { + // 예외 처리 + } + + return emitter; + } + + /** + * 특정 사용자에게 이벤트 전송 + * @param memberId - 사용자 ID + * @param eventName - 이벤트 이름 + * @param data - 전송할 데이터 객체 + */ + public void sendNotification(Long memberId, String eventName, Object data) { + SseEmitter emitter = this.emitters.get(memberId); + if (emitter != null) { + try { + // data 객체를 JSON 문자열로 변환하여 전송해야 함 (Controller에서는 자동 변환) + emitter.send(SseEmitter.event().name(eventName).data(data)); + } catch (IOException e) { + this.emitters.remove(memberId); + } + } + } + + /** + * 20초마다 모든 Emitter에 하트비트 전송 + * 클라이언트와의 연결 유지를 위해 주기적으로 빈 이벤트를 전송 + */ + @Scheduled(fixedRate = 20000) + public void sendHeartbeat() { + // 모든 Emitter에 하트비트 전송 + emitters.forEach((userId, emitter) -> { + try { + // SSE 주석(comment)을 사용하여 클라이언트에서 별도 이벤트를 발생시키지 않음 + emitter.send(SseEmitter.event().comment("keep-alive")); + } catch (IOException e) { + // 전송 실패 시, 클라이언트 연결이 끊어진 것으로 간주하고 Map에서 제거 + emitters.remove(userId); + } + }); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java index 51049ee9..f68480f8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java @@ -5,19 +5,43 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.enums.ArchiveType; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; +import java.util.ArrayList; +import java.util.List; + @Getter @Setter @Entity @NoArgsConstructor -@Inheritance(strategy = InheritanceType.JOINED) public class Archive extends BaseEntity { - @Column + // Personal / Shared 생성 후 불변 + @Column(nullable = false, updatable = false) @Enumerated(EnumType.STRING) private ArchiveType archiveType; + //아카이브 삭제(아마도 계정 탈퇴) 시 폴더 일괄 삭제 + @OneToMany(mappedBy = "archive", cascade = CascadeType.ALL, orphanRemoval = true) + private List folders = new ArrayList<>(); + public Archive(ArchiveType archiveType) { this.archiveType = archiveType; } + + public void addFolder(Folder folder) { + if (!this.folders.contains(folder)) { + this.folders.add(folder); + } + if (folder.getArchive() != this) { + folder.setArchive(this); + } + } + + public void removeFolder(Folder folder) { + this.folders.remove(folder); + if (folder.getArchive() == this) { + folder.setArchive(null); + } + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java index 69ad4ff6..4cd90b9c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java @@ -1,13 +1,11 @@ package org.tuna.zoopzoop.backend.domain.archive.archive.entity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.enums.ArchiveType; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @@ -15,17 +13,35 @@ @Setter @Entity @NoArgsConstructor +@Table( + uniqueConstraints = { + // Archive가 하나의 PersonalArchive에 연결됨 + @UniqueConstraint( + name = "uk_personal_archive__archive_id", + columnNames = "archive_id" + ), + // Member가 하나의 PersonalArchive만 가짐 + @UniqueConstraint( + name = "uk_personal_archive__member_id", + columnNames = "member_id" + ) + } +) public class PersonalArchive extends BaseEntity { - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "archive_id", nullable = false) + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, optional = false) + @JoinColumn(name = "archive_id") public Archive archive; - @OneToOne - @JoinColumn(name = "member_id", nullable = false) + @OneToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") private Member member; public PersonalArchive(Member member) { this.member = member; this.archive = new Archive(ArchiveType.PERSONAL); + + // default 폴더 자동 생성 및 연결 + Folder defaultFolder = new Folder("default"); + archive.addFolder(defaultFolder); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/SharingArchive.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/SharingArchive.java index 564107c2..599f4c4d 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/SharingArchive.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/SharingArchive.java @@ -8,6 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.enums.ArchiveType; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @@ -27,5 +28,9 @@ public class SharingArchive extends BaseEntity { public SharingArchive(Space space) { this.space = space; this.archive = new Archive(ArchiveType.SHARED); + + // 🔧 default 폴더 자동 생성 + Folder defaultFolder = new Folder("default"); + this.archive.addFolder(defaultFolder); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/repository/PersonalArchiveRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/repository/PersonalArchiveRepository.java new file mode 100644 index 00000000..127afa95 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/repository/PersonalArchiveRepository.java @@ -0,0 +1,24 @@ +package org.tuna.zoopzoop.backend.domain.archive.archive.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; + +import java.util.Optional; + +public interface PersonalArchiveRepository extends JpaRepository { + /** + * 회원의 PersonalArchive 조회 + * + * @param memberId 회원 Id + * @return PersonalArchive 엔티티 + */ + @Query(""" + select pa + from PersonalArchive pa + join fetch pa.archive a + where pa.member.id = :memberId +""") + Optional findByMemberId(@Param("memberId") Integer memberId); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java new file mode 100644 index 00000000..7142e46d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java @@ -0,0 +1,129 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.controller; + +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.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.resBodyForCreateFolder; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.PersonalArchiveFolderService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/archive/folder") +@RequiredArgsConstructor +@Tag(name = "ApiV1Folder", description = "개인 아카이브의 폴더 CRUD") +public class FolderController { + + private final PersonalArchiveFolderService personalArchiveFolderService; + + /** + * 내 PersonalArchive 안에 새 폴더 생성 + */ + @Operation(summary = "폴더 생성", description = "내 PersonalArchive 안에 새 폴더를 생성합니다.") + @PostMapping + public RsData createFolder( + @Valid @RequestBody reqBodyForCreateFolder rq, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + FolderResponse createFile = personalArchiveFolderService.createFolder(member.getId(), rq.folderName()); + resBodyForCreateFolder rs = new resBodyForCreateFolder(createFile.folderName(), createFile.folderId()); + + return new RsData<>("200",rq.folderName() + " 폴더가 생성됐습니다.", rs); + } + + /** + * 내 PersonalArchive 안의 folder 삭제 + */ + @DeleteMapping("/{folderId}") + public ResponseEntity> deleteFolder( + @PathVariable Integer folderId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + if (folderId == 0) + throw new IllegalArgumentException("default 폴더는 삭제할 수 없습니다."); + + + Member member = userDetails.getMember(); + String deletedFolderName = personalArchiveFolderService.deleteFolder(member.getId(), folderId); + + return ResponseEntity.ok( + new RsData<>("200", deletedFolderName + " 폴더가 삭제됐습니다.", null) + ); + } + + /** + * 폴더 이름 수정 + * @param folderId 수정할 폴더 Id + * @param body 수정할 폴더 값 + */ + @PatchMapping("/{folderId}") + public ResponseEntity>> updateFolderName( + @PathVariable Integer folderId, + @RequestBody Map body, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + if (folderId == 0) + throw new IllegalArgumentException("default 폴더는 이름을 변경할 수 없습니다."); + + + Member member = userDetails.getMember(); + String newName = body.get("folderName"); + String updatedName = personalArchiveFolderService.updateFolderName(member.getId(), folderId, newName); + + return ResponseEntity.ok( + new RsData<>("200", "폴더 이름이 " + updatedName + " 으로 변경됐습니다.", + Map.of("folderName", updatedName)) + ); + } + + /** + * 개인 아카이브의 폴더 이름 전부 조회 + * "default", "폴더1", "폴더2" + */ + @Operation(summary = "폴더 이름 조회", description = "내 PersonalArchive 안에 이름을 전부 조회합니다.") + @GetMapping + public ResponseEntity>> getFolders( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + List folders = personalArchiveFolderService.getFolders(member.getId()); + + return ResponseEntity.ok( + new RsData<>("200", "개인 아카이브의 폴더 목록을 불러왔습니다.", folders) + ); + } + + /** + * 폴더 안의 파일 목록 조회 + */ + @GetMapping("/{folderId}/files") + public ResponseEntity getFilesInFolder( + @PathVariable Integer folderId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + int memberId = userDetails.getMember().getId(); + + Integer targetFolderId = (folderId == 0) + ? personalArchiveFolderService.getDefaultFolderId(memberId) + : folderId; + + FolderFilesDto rs = personalArchiveFolderService.getFilesInFolder(memberId, targetFolderId); + + return ResponseEntity.ok( + new RsData<>("200","해당 폴더의 파일 목록을 불러왔습니다.", rs) + ); + } + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/FolderResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/FolderResponse.java new file mode 100644 index 00000000..78d38b04 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/FolderResponse.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.dto; + +public record FolderResponse( + String folderName, + int folderId +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/reqBodyForCreateFolder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/reqBodyForCreateFolder.java new file mode 100644 index 00000000..73690c42 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/reqBodyForCreateFolder.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.dto; + +import jakarta.validation.constraints.NotBlank; + +public record reqBodyForCreateFolder( + @NotBlank String folderName +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/resBodyForCreateFolder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/resBodyForCreateFolder.java new file mode 100644 index 00000000..8288dd65 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/resBodyForCreateFolder.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.dto; + +public record resBodyForCreateFolder( + String folderName, + int folderId +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java index 4bb27d21..c86b1cb4 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java @@ -5,16 +5,33 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; +import java.util.ArrayList; +import java.util.List; + @Getter @Setter @Entity @NoArgsConstructor +@Table( + // 복합 Unique 제약(archive_id, name) + uniqueConstraints = { + @UniqueConstraint( + name = "uk_folder__archive_id__name", + columnNames = {"archive_id", "name"} + ) + }, + // Archive 별 조회 속도 개선 + indexes = { + @Index( name = "idx_folder__archive_id", columnList = "archive_id") + } +) public class Folder extends BaseEntity { //연결된 아카이브 id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "archive_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "archive_id") private Archive archive; //폴더 이름 @@ -22,6 +39,16 @@ public class Folder extends BaseEntity { private String name; //디폴트 폴더 여부 - @Column(nullable = false) + @Column(nullable = false, name = "is_default") private boolean isDefault = false; + + // 폴더 삭제 시 데이터 softdelete + @OneToMany(mappedBy = "folder") + private List dataSources = new ArrayList<>(); + + + public Folder(String name) { + this.name = name; + this.isDefault = true; + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java new file mode 100644 index 00000000..91b68ca7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -0,0 +1,88 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; + +import java.util.List; +import java.util.Optional; + +public interface FolderRepository extends JpaRepository{ + /** + * 폴더 중복명 검사 + * @param archiveId 아카이브 Id + * @param filename "파일명" + * @param filenameEnd "파일명 + \ufffff" + */ + @Query(""" + select f.name + from Folder f + where f.archive.id = :archiveId + and f.name >= :filename + and f.name < :filenameEnd + """) + List findNamesForConflictCheck(@Param("archiveId") Integer archiveId, + @Param("filename") String filename, + @Param("filenameEnd") String filenameEnd); + // 개인 아카이브의 폴더 조회 + List findByArchive(Archive archive); + + /** + * 아카이브 Id로 default 폴더 조회 + * @param archiveId 조회할 archive Id + */ + Optional findByArchiveIdAndIsDefaultTrue(Integer archiveId); + + /** + * 회원 Id로 default 폴더 조회 + * @param memberId 조회할 회원 Id + */ + @Query(""" + select f + from Folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where pa.member.id = :memberId + and f.isDefault = true + """) + Optional findDefaultFolderByMemberId(@Param("memberId") Integer memberId); + + // 한 번의 조인으로 존재 + 소유권(memberId) 검증 + @Query(""" + select f + from Folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where f.id = :folderId + and pa.member.id = :memberId + """) + Optional findByIdAndMemberId(@Param("folderId") Integer folderId, + @Param("memberId") Integer memberId); + + Optional findByArchiveIdAndName(Integer archiveId, String name); + + List findAllByArchiveId(Integer archiveId); + + @Query(""" + select f from Folder f + join f.archive a + join PersonalArchive pa on pa.archive.id = a.id + where pa.member.id = :memberId and f.isDefault = true + """) + Optional findDefaultByMemberId(@Param("memberId") Integer memberId); + + Optional findByIdAndArchiveId(Integer folderId, Integer archiveId); + + @Query(""" + select f.name + from Folder f + where f.archive.id = :archiveId + and f.name = :name + and f.id <> :excludeFolderId + """) + List existsNameInArchiveExceptSelf(@Param("archiveId") Integer archiveId, + @Param("name") String name, + @Param("excludeFolderId") Integer excludeFolderId); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java new file mode 100644 index 00000000..213aceab --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -0,0 +1,187 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.service; + +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class FolderService { + + private final FolderRepository folderRepository; + private final DataSourceRepository dataSourceRepository; + + // ===== 생성 ===== + @Transactional + public FolderResponse createFolder(Archive archive, String folderName) { + if (archive == null) throw new NoResultException("아카이브가 존재하지 않습니다."); + if (folderName == null || folderName.trim().isEmpty()) + throw new IllegalArgumentException("폴더 이름은 비어 있을 수 없습니다."); + + final String requested = folderName.trim(); + String unique = generateUniqueFolderName(archive.getId(), requested); + + for (int attempt = 0; attempt < 2; attempt++) { + try { + Folder folder = new Folder(); + folder.setArchive(archive); + folder.setName(unique); + folder.setDefault(false); + + Folder saved = folderRepository.save(folder); + return new FolderResponse(saved.getName(), saved.getId()); + } catch (DataIntegrityViolationException e) { + unique = generateUniqueFolderName(archive.getId(), requested); + } + } + throw new IllegalStateException("동시성 충돌로 폴더 생성에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + // ===== 삭제 ===== + @Transactional + public String deleteFolder(Archive archive, Integer folderId) { + Folder folder = folderRepository.findByIdAndArchiveId(folderId, archive.getId()) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + + if (folder.isDefault()) + throw new IllegalArgumentException("default 폴더는 삭제할 수 없습니다."); + + // 기본 폴더 확보 (같은 archive) + Folder defaultFolder = folderRepository.findByArchiveIdAndIsDefaultTrue(archive.getId()) + .orElseThrow(() -> new IllegalStateException("default 폴더가 존재하지 않습니다.")); + + // 폴더 내 자료 이관 + soft delete(네 정책 유지) + List dataSources = dataSourceRepository.findAllByFolderId(folderId); + LocalDate now = LocalDate.now(); + for (DataSource ds : dataSources) { + ds.setFolder(defaultFolder); + ds.setActive(false); + ds.setDeletedAt(now); + } + + String name = folder.getName(); + folderRepository.delete(folder); + return name; + } + + // ===== 이름 변경 ===== + @Transactional + public String updateFolderName(Archive archive, Integer folderId, String newName) { + if (newName == null || newName.trim().isEmpty()) + throw new IllegalArgumentException("폴더 이름은 비어 있을 수 없습니다."); + + Folder folder = folderRepository.findByIdAndArchiveId(folderId, archive.getId()) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + + if (folder.isDefault()) + throw new IllegalArgumentException("default 폴더는 이름을 변경할 수 없습니다."); + + // 같은 Archive 내 동명 검사 (자기 자신 제외) + List conflict = folderRepository.existsNameInArchiveExceptSelf( + archive.getId(), newName.trim(), folder.getId()); + if (!conflict.isEmpty()) { + throw new IllegalArgumentException("이미 존재하는 폴더명입니다."); + } + + folder.setName(newName.trim()); + folderRepository.save(folder); + return folder.getName(); + } + + // ===== 목록 조회 ===== + @Transactional(readOnly = true) + public List getFolders(Archive archive) { + return folderRepository.findByArchive(archive).stream() + .map(f -> new FolderResponse(f.getName(), f.getId())) + .toList(); + } + + // ===== 폴더 내 파일 조회 ===== + @Transactional(readOnly = true) + public FolderFilesDto getFilesInFolder(Archive archive, Integer folderId) { + Folder folder = folderRepository.findByIdAndArchiveId(folderId, archive.getId()) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + + var files = dataSourceRepository.findAllByFolderAndIsActiveTrue(folder).stream() + .map(ds -> new FileSummary( + ds.getId(), + ds.getTitle(), + ds.getDataCreatedDate(), + ds.getSummary(), + ds.getSourceUrl(), + ds.getImageUrl(), + ds.getTags() == null ? List.of() : ds.getTags().stream().map(Tag::getTagName).toList(), + ds.getCategory() == null ? null : ds.getCategory().name() + )) + .toList(); + + return new FolderFilesDto(folder.getId(), folder.getName(), files); + } + + // ===== 기본 폴더 ID 조회 (Archive 스코프) ===== + @Transactional(readOnly = true) + public Integer getDefaultFolderId(Archive archive) { + return folderRepository.findByArchiveIdAndIsDefaultTrue(archive.getId()) + .orElseThrow(() -> new NoResultException("default 폴더를 찾을 수 없습니다.")) + .getId(); + } + + // ===== 이름 충돌 유틸 ===== + private static final Pattern SUFFIX_PATTERN = Pattern.compile("^(.*?)(?: \\((\\d+)\\))?$"); + + private String generateUniqueFolderName(Integer archiveId, String requested) { + NameParts nameParts = NameParts.split(requested); + String file = nameParts.base(); + String fileEnd = file + "\uffff"; + List existing = folderRepository.findNamesForConflictCheck(archiveId, file, fileEnd); + return pickNextAvailable(file, existing); + } + + private static String pickNextAvailable(String file, List existing) { + boolean baseUsed = false; + Set used = new HashSet<>(); + Pattern p = Pattern.compile("^" + Pattern.quote(file) + "(?: \\((\\d+)\\))?$"); + for (String s : existing) { + var m = p.matcher(s); + if (m.matches()) { + if (m.group(1) == null) baseUsed = true; + else used.add(Integer.parseInt(m.group(1))); + } + } + if (!baseUsed) return file; + for (int k = 1; k <= used.size() + 1; k++) { + if (!used.contains(k)) return file + " (" + k + ")"; + } + return file + " (" + (used.size() + 1) + ")"; + } + + private record NameParts(String base, Integer num) { + static NameParts split(String name) { + var m = SUFFIX_PATTERN.matcher(name.trim()); + if (m.matches()) { + String base = m.group(1).trim(); + Integer n = m.group(2) != null ? Integer.valueOf(m.group(2)) : null; + return new NameParts(base, n); + } + return new NameParts(name.trim(), null); + } + } +} + diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/PersonalArchiveFolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/PersonalArchiveFolderService.java new file mode 100644 index 00000000..73edaa7d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/PersonalArchiveFolderService.java @@ -0,0 +1,69 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.service; + +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; +import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PersonalArchiveFolderService { + + private final PersonalArchiveRepository personalArchiveRepository; + private final FolderRepository folderRepository; + private final FolderService folderService; + + @Transactional + public FolderResponse createFolder(Integer memberId, String folderName) { + Archive archive = personalArchiveRepository.findByMemberId(memberId) + .map(PersonalArchive::getArchive) + .orElseThrow(() -> new NoResultException("개인 아카이브가 없습니다.")); + return folderService.createFolder(archive, folderName); + } + + @Transactional + public String deleteFolder(Integer memberId, Integer folderId) { + // 개인 전용 “소유 확인” 쿼리로 빠르게 가드 + Folder folder = folderRepository.findByIdAndMemberId(folderId, memberId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + return folderService.deleteFolder(folder.getArchive(), folderId); + } + + @Transactional + public String updateFolderName(Integer memberId, Integer folderId, String newName) { + Folder folder = folderRepository.findByIdAndMemberId(folderId, memberId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + return folderService.updateFolderName(folder.getArchive(), folderId, newName); + } + + @Transactional(readOnly = true) + public List getFolders(Integer memberId) { + Archive archive = personalArchiveRepository.findByMemberId(memberId) + .map(PersonalArchive::getArchive) + .orElseThrow(() -> new NoResultException("개인 아카이브가 존재하지 않습니다.")); + return folderService.getFolders(archive); + } + + @Transactional(readOnly = true) + public FolderFilesDto getFilesInFolder(Integer memberId, Integer folderId) { + Folder folder = folderRepository.findByIdAndMemberId(folderId, memberId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + return folderService.getFilesInFolder(folder.getArchive(), folderId); + } + + @Transactional(readOnly = true) + public Integer getDefaultFolderId(Integer memberId) { + Folder folder = folderRepository.findDefaultFolderByMemberId(memberId) + .orElseThrow(() -> new NoResultException("default 폴더를 찾을 수 없습니다.")); + return folder.getId(); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java new file mode 100644 index 00000000..2fae5d32 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -0,0 +1,155 @@ +package org.tuna.zoopzoop.backend.domain.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData; +import org.tuna.zoopzoop.backend.domain.auth.entity.AuthResult; +import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; +import org.tuna.zoopzoop.backend.domain.auth.service.refresh.RefreshTokenService; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/auth") +@Tag(name = "ApiV1AuthController", description = "인증/인가 REST API 컨트롤러") +public class ApiV1AuthController { + private final JwtUtil jwtUtil; + private final RefreshTokenService refreshTokenService; + private final AuthResult authResult; + + /** + * 사용자 로그아웃 API + * @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입. + */ + @GetMapping("/logout") + @Operation(summary = "사용자 로그아웃") + public ResponseEntity> logout( + @CookieValue(name = "sessionId") + String sessionId, + HttpServletResponse response) { + + // 서버에서 RefreshToken 삭제 + refreshTokenService.deleteBySessionId(sessionId); + + // 클라이언트 쿠키 삭제 (AccessToken + SessionId) + ResponseCookie accessCookie = ResponseCookie.from("accessToken", "") + .httpOnly(true) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + + ResponseCookie sessionCookie = ResponseCookie.from("sessionId", "") + .httpOnly(true) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>("200", "정상적으로 로그아웃 했습니다.", null)); + } + + /** + * refreshToken 기반으로 accessToken 재발급 + * @param sessionId 쿠키에 포함된 현재 로그인한 사용자의 sessionId. + * @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입. + */ + @PostMapping("/refresh") + @Operation(summary = "사용자 액세스 토큰 재발급 (서버 저장 RefreshToken 사용)") + public ResponseEntity> refreshToken( + @CookieValue(name = "sessionId") + String sessionId, + HttpServletResponse response + ) { + if (sessionId == null) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new RsData<>("401", "세션이 존재하지 않습니다.", null)); + } + + // sessionId로 RefreshToken 조회 + RefreshToken refreshTokenEntity; + try { + refreshTokenEntity = refreshTokenService.getBySessionId(sessionId); + } catch (AuthenticationException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new RsData<>( + "401", + e.getMessage(), + null + )); + } + + String refreshToken = refreshTokenEntity.getRefreshToken(); + + // RefreshToken 유효성 검사 + if (!jwtUtil.validateToken(refreshToken) || !jwtUtil.isRefreshToken(refreshToken)) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new RsData<>("401", "유효하지 않은 리프레시 토큰입니다.", null)); + } + + Member member = refreshTokenEntity.getMember(); + + // 새 AccessToken 발급 + String newAccessToken = jwtUtil.generateToken(member); + + ResponseCookie accessCookie = ResponseCookie.from("accessToken", newAccessToken) + .httpOnly(true) + .path("/") + .maxAge(jwtUtil.getAccessTokenValiditySeconds()) + .sameSite("Lax") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>("200", "액세스 토큰을 재발급 했습니다.", null)); + } + + /** + * 확장프로그램의 액세스 토큰 발급을 위한 백그라운드 풀링에 대응하는 API + * @param state 확장프로그램 로그인 시 전달한 state 값. + */ + @GetMapping("/result") + @Operation(summary = "확장프로그램 백그라운드 풀링 대응 API") + public ResponseEntity> pullingResult( + @RequestParam String state + ) { + AuthResultData resultData = authResult.get(state); + if(resultData == null) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(new RsData<>( + "404", + "state에 해당하는 토큰이 준비되지 않았거나, 잘못된 state 입니다.", + null + ) + ); + } + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "토큰이 정상적으로 발급되었습니다.", + resultData + )); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoAuthService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoAuthService.java new file mode 100644 index 00000000..4256c7c7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoAuthService.java @@ -0,0 +1,67 @@ +package org.tuna.zoopzoop.backend.domain.auth.deprecated; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoAuthService { +// private final WebClient webClient; +// private final MemberRepository memberRepository; +// private final JwtUtil jwtUtil; +// +// @Value("${kakao.client_id}") +// private String CLIENT_ID; +// @Value("${kakao.redirect_uri}") +// private String REDIRECT_URI; +// +// private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token"; +// private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; +// +// public Map loginWithKakao(String code) { +// // 1. 카카오에서 토큰 발급 +// KakaoTokenResponse tokenResponse = webClient.post() +// .uri(TOKEN_URL) +// .contentType(MediaType.APPLICATION_FORM_URLENCODED) +// .body(BodyInserters.fromFormData("grant_type", "authorization_code") +// .with("client_id", CLIENT_ID) +// .with("redirect_uri", REDIRECT_URI) +// .with("code", code)) +// .retrieve() +// .bodyToMono(KakaoTokenResponse.class) +// .block(); +// +// // 2. 토큰에서 AccessToken 가져오기. +// String accessToken = tokenResponse.access_token(); +// +// // 3. AccessToken을 통해 카카오 사용자 정보 가져오기. +// KakaoUserInfoResponse userInfo = webClient.get() +// .uri(USER_INFO_URL) +// .headers(headers -> headers.setBearerAuth(accessToken)) +// .retrieve() +// .bodyToMono(KakaoUserInfoResponse.class) +// .block(); +// +// // 4. Member 엔티티 리턴 +// // a. kakaoKey 값을 가진 Member 객체가 이미 존재하는 경우, 그대로 가져옴. +// // b. 존재하지 않을 경우, 새로 만듬. +// Member member = memberRepository.findByKakaoKey(userInfo.id()) +// .orElseGet(() -> memberRepository.save( +// Member.builder() +// .name(userInfo.kakao_account().profile().nickname()) +// .profileImageUrl(userInfo.kakao_account().profile().profile_image_url()) +// .kakaoKey(userInfo.id()) +// .build() +// )); +// +// // 5. AccessToken 및 RefreshToken 생성. +// String jwtAccessToken = jwtUtil.generateToken(member); +// String jwtRefreshToken = jwtUtil.generateRefreshToken(member); +// +// Map tokens = new HashMap<>(); +// tokens.put("accessToken", jwtAccessToken); +// tokens.put("refreshToken", jwtRefreshToken); +// +// return tokens; +// } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoLoginController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoLoginController.java new file mode 100644 index 00000000..af6a2e3c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoLoginController.java @@ -0,0 +1,45 @@ +package org.tuna.zoopzoop.backend.domain.auth.deprecated; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class KakaoLoginController { +// private final KakaoAuthService kakaoAuthService; +// private final JwtUtil jwtUtil; +// private final JwtProperties jwtProperties; +// +// @GetMapping("/oauth/kakao") +// public ResponseEntity>> kakaoCallback(@RequestParam String code) { +// Map tokens = kakaoAuthService.loginWithKakao(code); +// ResponseCookie accessCookie = ResponseCookie.from("accessToken", tokens.get("accessToken")) +// .httpOnly(true) +// .path("/") +// .maxAge(jwtProperties.getAccessTokenValidity() / 1000) +// .sameSite("Lax") +// .build(); +// +// ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", tokens.get("refreshToken")) +// .httpOnly(true) +// .secure(false) +// .path("/") +// .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) +// .sameSite("Lax") +// .build(); +// +// HttpHeaders headers = new HttpHeaders(); +// headers.add(HttpHeaders.SET_COOKIE, accessCookie.toString()); +// headers.add(HttpHeaders.SET_COOKIE, refreshCookie.toString()); +// +// return ResponseEntity +// .status(HttpStatus.OK) +// .headers(headers) +// .body(new RsData<>( +// "200", +// "카카오 로그인에 성공했습니다.", +// tokens +// ) +// ); +// } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoTokenResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoTokenResponse.java new file mode 100644 index 00000000..56e3338e --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoTokenResponse.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.auth.deprecated; + +public record KakaoTokenResponse( + String access_token, + String token_type, + String refresh_token, + Long expires_in, + String scope, + Long refresh_token_expires_in +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoUserInfoResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoUserInfoResponse.java new file mode 100644 index 00000000..8ed5994e --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoUserInfoResponse.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.auth.deprecated; + +public record KakaoUserInfoResponse( + Long id, + KakaoAccount kakao_account +) { + public record KakaoAccount(Profile profile) { + public record Profile(String nickname, String profile_image_url) {} + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dev/controller/DevController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dev/controller/DevController.java new file mode 100644 index 00000000..44161877 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dev/controller/DevController.java @@ -0,0 +1,35 @@ +package org.tuna.zoopzoop.backend.domain.auth.dev.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +import java.util.Map; + +@Profile({"local","dev","staging","test"}) +@RestController +@RequestMapping("/dev") +@RequiredArgsConstructor +public class DevController { + + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + @GetMapping("/token") + public Map issueToken( + @RequestParam(name = "provider") Provider provider, + @RequestParam(name = "key") String key + ) { + Member m = memberRepository.findByProviderAndProviderKey(provider, key) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "member not found")); + + String accessToken = jwtUtil.generateToken(m); + return Map.of("accessToken", accessToken); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java new file mode 100644 index 00000000..07694675 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java @@ -0,0 +1,15 @@ +package org.tuna.zoopzoop.backend.domain.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthResultData implements Serializable { + private String accessToken; + private String sessionId; +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java new file mode 100644 index 00000000..f80c0dfb --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java @@ -0,0 +1,26 @@ +package org.tuna.zoopzoop.backend.domain.auth.entity; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class AuthResult { + private final RedisTemplate redisTemplate; + private static final String PREFIX = "auth:result:"; + + public void put(String state, String accessToken, String sessionId) { + AuthResultData data = new AuthResultData(accessToken, sessionId); + redisTemplate.opsForValue().set(PREFIX + state, data, Duration.ofMinutes(1)); // TTL 1분, 프론트단에선 백그라운드 풀링 형식으로 계속 작동할 것이므로. + } + + public AuthResultData get(String state) { + AuthResultData data = redisTemplate.opsForValue().get(PREFIX + state); + if (data != null) redisTemplate.delete(PREFIX + state); + return data; + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/RefreshToken.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/RefreshToken.java new file mode 100644 index 00000000..b1a8ec8a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/RefreshToken.java @@ -0,0 +1,39 @@ +package org.tuna.zoopzoop.backend.domain.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RefreshToken extends BaseEntity { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", unique = true, nullable = false) + private Member member; + + @Column(name = "session_id", unique = true, nullable = false) + private String sessionId; + + @Column(unique = true, nullable = false, length = 512) + private String refreshToken; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "expired_at") + private LocalDateTime expiredAt; + + @PrePersist + public void prePersist() { + if(createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..f673afba --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,57 @@ +package org.tuna.zoopzoop.backend.domain.auth.global; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private final OAuth2AuthorizationRequestResolver defaultResolver; + + public CustomOAuth2AuthorizationRequestResolver(ClientRegistrationRepository repo, String authorizationRequestBaseUri) { + this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(repo, authorizationRequestBaseUri); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + return customize(defaultResolver.resolve(request), request); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + return customize(defaultResolver.resolve(request, clientRegistrationId), request); + } + + private OAuth2AuthorizationRequest customize(OAuth2AuthorizationRequest req, HttpServletRequest request) { + if (req == null) return null; + + String source = request.getParameter("source"); // 로그인 시작 시 전달된 source + OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.from(req); + + if ("extension".equals(source)) { + String state = request.getParameter("state"); + Map stateData = new HashMap<>(); + stateData.put("source", "extension"); + stateData.put("customState", state); + stateData.put("originalState", req.getState()); + + try { + String encodedState = Base64.getUrlEncoder() + .encodeToString(new ObjectMapper().writeValueAsBytes(stateData)); + builder.state(encodedState); + } catch (Exception e) { + e.printStackTrace(); + return builder.build(); + } + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java new file mode 100644 index 00000000..f9ce1e20 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java @@ -0,0 +1,47 @@ +package org.tuna.zoopzoop.backend.domain.auth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; + +@Component +@RequiredArgsConstructor +public class OAuth2FailureHandler implements AuthenticationFailureHandler { + @Value("${front.redirect_domain}") + private String redirect_domain; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + // 프론트로 리다이렉트 + // 필요하면 쿼리 파라미터로 에러 정보 전달 + + String source = request.getParameter("source"); + + if("extension".equals(source)){ + String redirectUrl = + "https://" + redirect_domain + "/extension/callback " + + "?success=false" + + "&error=" + URLEncoder.encode(exception.getMessage(), "UTF-8"); + response.sendRedirect(redirectUrl); + return; + } + + String redirectUrl = + "https://" + redirect_domain + "/auth/callback" + + "?success=false" + + "&error=" + URLEncoder.encode(exception.getMessage(), "UTF-8"); + + response.sendRedirect(redirectUrl); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 00000000..a692ea89 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,169 @@ +package org.tuna.zoopzoop.backend.domain.auth.handler; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.auth.entity.AuthResult; +import org.tuna.zoopzoop.backend.domain.auth.service.refresh.RefreshTokenService; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.Base64; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final JwtUtil jwtUtil; + private final JwtProperties jwtProperties; + private final MemberRepository memberRepository; + private final MemberService memberService; + private final RefreshTokenService refreshTokenService; + private final AuthResult authResult; + + @Value("${front.redirect_domain}") + private String redirect_domain; + + @Value("${front.main_domain}") + private String main_domain; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + // OAuth2 로그인 사용자의 속성 + // 소셜 로그인 공급자(Google, Kakao) + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(); + + // 공급자 별로 DB 에서 회원 조회 + Member member; + if ("kakao".equals(registrationId)) { + String kakaoId = oAuth2User.getAttributes().get("id").toString(); + member = memberService.findByKakaoKey(kakaoId); + } else if ("google".equals(registrationId)) { + String googleId = (String) oAuth2User.getAttributes().get("sub"); + member = memberService.findByGoogleKey(googleId); + } else { + throw new IllegalArgumentException(registrationId + "는 지원하지 않는 소셜 로그인입니다."); + } + + // 조회된 회원 정보를 기반으로 AccessToken 생성 + String accessToken = jwtUtil.generateToken(member); + + // RefreshToken 생성 및 DB 저장, SessionId 생성 + String refreshToken = jwtUtil.generateRefreshToken(member); + String sessionId = refreshTokenService.saveSession(member, refreshToken); + + log.info("[OAuth2SuccessHandler] Member: {}, SessionId: {}", member.getId(), sessionId); + + String state = request.getParameter("state"); + if(state != null && state.startsWith("ey")) { + Map stateData = new ObjectMapper().readValue( + Base64.getUrlDecoder().decode(state), + new TypeReference>() { + } + ); + + String source = stateData.get("source"); + String customState = stateData.get("customState"); + + log.info("[OAuth2SuccessHandler] Source: {}", source); + log.info("[OAuth2SuccessHandler] CustomState: {}", customState); + + // 확장 프로그램에서 로그인 했을 경우. + if ("extension".equals(source)) { + authResult.put(customState, accessToken, sessionId); + response.sendRedirect("https://" + redirect_domain + "/extension/success"); + response.flushBuffer(); + return; + } + } + + if ("dev".equalsIgnoreCase(activeProfile)) { + // 로컬 테스트용. profile이 dev인 경우. + ResponseCookie accessCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getAccessTokenValidity() / 1000) + .secure(false) + .sameSite("Lax") + .build(); + + ResponseCookie sessionCookie = ResponseCookie.from("sessionId", sessionId) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) // RefreshToken 유효기간과 동일하게 + .secure(false) + .sameSite("Lax") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); + + response.sendRedirect("http://localhost:8080"); + return; + } + + if ("http://localhost:3000".equals(redirect_domain)) { + // server 환경일 때: URL 파라미터로 토큰 전달 + // 프론트엔드 local 테스트용. + String redirectUrl = redirect_domain + "/api/auth/callback" + + "?success=true" + + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + + "&sessionId=" + URLEncoder.encode(sessionId, "UTF-8"); + response.sendRedirect(redirectUrl); + + } else { + ResponseCookie accessCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getAccessTokenValidity() / 1000) + // .domain() // 프론트엔드 & 백엔드 상위 도메인 + // .secure(true) // https 필수 설정. + .domain(main_domain) + .secure(true) + .sameSite("None") + .build(); + + ResponseCookie sessionCookie = ResponseCookie.from("sessionId", sessionId) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) // RefreshToken 유효기간과 동일하게 + .domain(main_domain) + .secure(true) + .sameSite("None") + .build(); + + // HTTP 응답에서 쿠키 값 추가. + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); + + // 로그인 성공 후 리다이렉트. + // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. + response.sendRedirect("https://" + redirect_domain + "/api/auth/callback"); + } + // 보안을 좀 더 강화하고자 한다면 CSRF 토큰 같은 걸 생각해볼 수 있겠으나, + // 일단은 구현하지 않음.(개발 과정 중에 번거로워질 수 있을 듯 함.) + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..2d328fff --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,18 @@ +package org.tuna.zoopzoop.backend.domain.auth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findBySessionId(String sessionId); + Optional findByMember(Member member); + List findAllByMember(Member member); + List findAllByExpiredAtBefore(LocalDateTime dateTime); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/CustomOAuth2UserService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/CustomOAuth2UserService.java new file mode 100644 index 00000000..1c3219b1 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,42 @@ +package org.tuna.zoopzoop.backend.domain.auth.service.oauth2; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + // @RequiredArgsConstructor 어노테이션을 통해, OAuth2UserInfoService를 인터페이스로 사용하는 + // GoogleUserInfoService, KakaoUserInfoService를 한번에 주입. + private final List oauth2UserInfoServices; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // SpringBoot OAuth2 공급자에서 사용자 정보 받아오기. + OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest); + + // 공급자(Google, Kakao) 받아오기. + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + // oauth2UserInfoService 리스트를 순회하며 공급자를 지원하는 서비스를 찾음. + OAuth2UserInfoService userInfoService = oauth2UserInfoServices.stream() + .filter(service -> service.supports(registrationId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(registrationId + "는 지원하지 않는 소셜 로그인입니다.")); + // 지원하지 않는 공급자의 경우 예외 발생. + // 하지만 발생할 일 없는 예외. + + // 선택된 서비스에서 사용자 정보 처리. + Member member = userInfoService.processUser(oAuth2User.getAttributes()); + + return oAuth2User; + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/GoogleUserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/GoogleUserInfoService.java new file mode 100644 index 00000000..77832d64 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/GoogleUserInfoService.java @@ -0,0 +1,38 @@ +package org.tuna.zoopzoop.backend.domain.auth.service.oauth2; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class GoogleUserInfoService implements OAuth2UserInfoService { + // Google 소셜 로그인의 경우 + private final MemberRepository memberRepository; + private final MemberService memberService; + + // 이 서비스(=GoogleUserInfoService)가, 해당 공급자를 지원하는 지에 대한 여부 확인. + // Google 공급자만 지원. + @Override + public boolean supports(String registrationId) { + return "google".equalsIgnoreCase(registrationId); + } + + + // Google 에서 받은 사용자 정보 Map(=attributes)에서 필요한 값 추출. + // 이후 추출한 값을 통해 Member 엔티티 생성. + @Override + public Member processUser(Map attributes) { + String googleId = (String) attributes.get("sub"); // 구글 user-id + String name = (String) attributes.get("name"); + String profileImage = (String) attributes.get("picture"); + + return memberRepository.findByProviderAndProviderKey(Provider.GOOGLE, googleId) + .orElseGet(() -> memberService.createMember(name, profileImage, googleId, Provider.GOOGLE)); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/KakaoUserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/KakaoUserInfoService.java new file mode 100644 index 00000000..52f0c784 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/KakaoUserInfoService.java @@ -0,0 +1,45 @@ +package org.tuna.zoopzoop.backend.domain.auth.service.oauth2; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class KakaoUserInfoService implements OAuth2UserInfoService { + // Kakao 소셜 로그인의 경우 + private final MemberRepository memberRepository; + private final MemberService memberService; + + // 이 서비스(=KakaoUserInfoService)가, 해당 공급자를 지원하는 지에 대한 여부 확인. + // Kakao 공급자만 지원. + @Override + public boolean supports(String registrationId) { + return "kakao".equalsIgnoreCase(registrationId); + } + + // Kakao 에서 받은 사용자 정보 Map(=attributes)에서 필요한 값 추출. + // 이후 추출한 값을 통해 Member 엔티티 생성. + @Override + public Member processUser(Map attributes) { + String kakaoId = attributes.get("id").toString(); + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + /* + Kakao API의 경우, 허용한 사용자 정보(nickname, profile_image, email 등)를 profile Map 으로 묶어서 전달. + 즉, 필요한 값을 추출하기 위해선 attributes 에서 profile을 가져오고, profile 에서 필요한 값을 추출해야 함. + */ + + String name = (String) profile.get("nickname"); + String profileImage = (String) profile.get("profile_image_url"); + + return memberRepository.findByProviderAndProviderKey(Provider.KAKAO,kakaoId) + .orElseGet(() -> memberService.createMember(name, profileImage, kakaoId, Provider.KAKAO)); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/OAuth2UserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/OAuth2UserInfoService.java new file mode 100644 index 00000000..358f0b50 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/oauth2/OAuth2UserInfoService.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.auth.service.oauth2; + +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +import java.util.Map; + +public interface OAuth2UserInfoService { + boolean supports(String registrationId); // 이 서비스가 해당 provider(Google, Kakao)를 처리하는지 + Member processUser(Map attributes); // 받아온 정보를 바탕으로 Member 엔티티 생성 or 가져오기 +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/refresh/RefreshTokenCleanupService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/refresh/RefreshTokenCleanupService.java new file mode 100644 index 00000000..a2bf3f22 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/refresh/RefreshTokenCleanupService.java @@ -0,0 +1,24 @@ +package org.tuna.zoopzoop.backend.domain.auth.service.refresh; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; +import org.tuna.zoopzoop.backend.domain.auth.repository.RefreshTokenRepository; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RefreshTokenCleanupService { + private final RefreshTokenRepository refreshTokenRepository; + + @Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행 + public void deleteExpiredTokens() { + List expiredTokens = refreshTokenRepository.findAllByExpiredAtBefore(LocalDateTime.now()); + if (!expiredTokens.isEmpty()) { + refreshTokenRepository.deleteAll(expiredTokens); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/refresh/RefreshTokenService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/refresh/RefreshTokenService.java new file mode 100644 index 00000000..b34dceb0 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/refresh/RefreshTokenService.java @@ -0,0 +1,63 @@ +package org.tuna.zoopzoop.backend.domain.auth.service.refresh; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; +import org.tuna.zoopzoop.backend.domain.auth.repository.RefreshTokenRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + private final JwtUtil jwtUtil; + + private LocalDateTime getExpirationLocalDateTimeFromToken(String token) { + Date expirationDate = jwtUtil.getExpirationDateFromToken(token); // 기존 메서드 + if (expirationDate == null) return null; + + return LocalDateTime.ofInstant(expirationDate.toInstant(), ZoneId.systemDefault()); + } + + public String saveSession(Member member, String refreshToken) { + String sessionId = UUID.randomUUID().toString(); + + refreshTokenRepository.findByMember(member).ifPresent(refreshTokenRepository::delete); + + RefreshToken token = RefreshToken.builder() + .member(member) + .refreshToken(refreshToken) + .sessionId(sessionId) + .expiredAt(getExpirationLocalDateTimeFromToken(refreshToken)) + .build(); + + refreshTokenRepository.save(token); + return sessionId; + } + + public RefreshToken getBySessionId(String sessionId) { + return refreshTokenRepository.findBySessionId(sessionId) + .orElseThrow(() -> new BadCredentialsException("세션을 찾을 수 없습니다.")); + } + + public void deleteBySessionId(String sessionId) { + RefreshToken token = refreshTokenRepository.findBySessionId(sessionId) + .orElseThrow(() -> new BadCredentialsException("잘못된 요청입니다.")); + refreshTokenRepository.delete(token); + } + + public void deleteByMember(Member member) { + List tokens = refreshTokenRepository.findAllByMember(member); + if (!tokens.isEmpty()) { + refreshTokenRepository.deleteAll(tokens); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java new file mode 100644 index 00000000..2196dbc4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java @@ -0,0 +1,75 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.service.DashboardService; +import org.tuna.zoopzoop.backend.domain.dashboard.service.GraphService; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.nio.file.AccessDeniedException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/dashboard") +@Tag(name = "ApiV1GraphController", description = "React-flow 데이터 컨트롤러") +public class ApiV1DashboardController { + private final DashboardService dashboardService; + + /** + * React-flow 데이터 저장(갱신) API + * @param dashboardId React-flow 데이터의 dashboard 식별 id + * @param requestBody React-flow 에서 보내주는 body 전체 + * @param signature Liveblocks-Signature 헤더 값 + * @return ResponseEntity> + */ + @PutMapping("/{dashboardId}/graph") + @Operation(summary = "React-flow 데이터 저장(갱신)") + public ResponseEntity> queueGraphUpdate( + @PathVariable Integer dashboardId, + @RequestBody String requestBody, + @RequestHeader("Liveblocks-Signature") String signature + ) { + dashboardService.queueGraphUpdate(dashboardId, requestBody, signature); + + return ResponseEntity + .status(HttpStatus.ACCEPTED) + .body(new RsData<>( + "202", + "데이터 업데이트 요청이 성공적으로 접수되었습니다.", + null + )); + } + + /** + * React-flow 데이터 조회 API + * @param dashboardId React-flow 데이터의 dashboard 식별 id + */ + @GetMapping("/{dashboardId}/graph") + @Operation(summary = "React-flow 데이터 조회") + public ResponseEntity> getGraph( + @PathVariable Integer dashboardId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) throws AccessDeniedException { + // TODO : 권한 체크 로직 추가 + Member member = userDetails.getMember(); + dashboardService.verifyAccessPermission(member, dashboardId); + + Graph graph = dashboardService.getGraphByDashboardId(dashboardId); + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "ID: " + dashboardId + " 의 React-flow 데이터를 조회했습니다.", + BodyForReactFlow.from(graph) + )); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java new file mode 100644 index 00000000..037b8882 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java @@ -0,0 +1,145 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.EdgeType; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.NodeType; + +import java.util.List; +import java.util.Map; + +// Request, Response 범용 Dto +public record BodyForReactFlow( + List nodes, + List edges +) { + + public record NodeDto( + @JsonProperty("id") String nodeKey, + @JsonProperty("type") String nodeType, + Map data, + @JsonProperty("position") PositionDto positionDto + ) { + public record PositionDto( + @JsonProperty("x") double x, + @JsonProperty("y") double y + ) {} + } + + public record EdgeDto( + @JsonProperty("id") String edgeKey, + @JsonProperty("source") String sourceNodeKey, + @JsonProperty("target") String targetNodeKey, + @JsonProperty("type") String edgeType, + @JsonProperty("animated") boolean isAnimated, + @JsonProperty("style") StyleDto styleDto + ) { + public record StyleDto( + String stroke, + Double strokeWidth + ) {} + } + + // DTO -> Entity, BodyForReactFlow를 Graph 엔티티로 변환 + public Graph toEntity() { + Graph graph = new Graph(); + + List nodeEntities = this.nodes().stream() + .map(dto -> { + Node node = new Node(); + node.setNodeKey(dto.nodeKey()); + node.setNodeType(NodeType.valueOf(dto.nodeType().toUpperCase())); + node.setData(dto.data()); + node.setPositonX(dto.positionDto().x()); + node.setPositonY(dto.positionDto().y()); + node.setGraph(graph); // 연관관계 설정 + return node; + }) + .toList(); + + List edgeEntities = this.edges().stream() + .map(dto -> { + Edge edge = new Edge(); + edge.setEdgeKey(dto.edgeKey()); + edge.setSourceNodeKey(dto.sourceNodeKey()); + edge.setTargetNodeKey(dto.targetNodeKey()); + edge.setEdgeType(EdgeType.valueOf(dto.edgeType().toUpperCase())); + edge.setAnimated(dto.isAnimated()); + if (dto.styleDto() != null) { + edge.setStroke(dto.styleDto().stroke()); + edge.setStrokeWidth(dto.styleDto().strokeWidth()); + } + edge.setGraph(graph); // 연관관계 설정 + return edge; + }) + .toList(); + + graph.getNodes().addAll(nodeEntities); + graph.getEdges().addAll(edgeEntities); + + return graph; + } + + // Entity -> DTO, Graph 엔티티를 ResBodyForReactFlow로 변환 + public static BodyForReactFlow from(Graph graph) { + List nodeDtos = graph.getNodes().stream() + .map(n -> new NodeDto( + n.getNodeKey(), + n.getNodeType().name().toUpperCase(), + n.getData(), + new NodeDto.PositionDto(n.getPositonX(), n.getPositonY()) + )) + .toList(); + + List edgeDtos = graph.getEdges().stream() + .map(e -> new EdgeDto( + e.getEdgeKey(), + e.getSourceNodeKey(), + e.getTargetNodeKey(), + e.getEdgeType().name().toUpperCase(), + e.isAnimated(), + new EdgeDto.StyleDto(e.getStroke(), e.getStrokeWidth()) + )) + .toList(); + + return new BodyForReactFlow(nodeDtos, edgeDtos); + } + + public List toNodeEntities(Graph graph) { + return this.nodes().stream() + .map(dto -> { + Node node = new Node(); + node.setNodeKey(dto.nodeKey()); + node.setNodeType(NodeType.valueOf(dto.nodeType().toUpperCase())); + node.setData(dto.data()); + node.setPositonX(dto.positionDto().x()); + node.setPositonY(dto.positionDto().y()); + node.setGraph(graph); // 연관관계 설정 + return node; + }) + .toList(); + } + + public List toEdgeEntities(Graph graph) { + return this.edges().stream() + .map(dto -> { + Edge edge = new Edge(); + edge.setEdgeKey(dto.edgeKey()); + edge.setSourceNodeKey(dto.sourceNodeKey()); + edge.setTargetNodeKey(dto.targetNodeKey()); + edge.setEdgeType(EdgeType.valueOf(dto.edgeType().toUpperCase())); + edge.setAnimated(dto.isAnimated()); + if (dto.styleDto() != null) { + edge.setStroke(dto.styleDto().stroke()); + edge.setStrokeWidth(dto.styleDto().strokeWidth()); + } + edge.setGraph(graph); // 연관관계 설정 + return edge; + }) + .toList(); + } + + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/GraphUpdateMessage.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/GraphUpdateMessage.java new file mode 100644 index 00000000..6ea10ede --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/GraphUpdateMessage.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.dto; + +public record GraphUpdateMessage( + Integer dashboardId, + String requestBody +){ +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ReqBodyForLiveblocksAuth.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ReqBodyForLiveblocksAuth.java new file mode 100644 index 00000000..8a6fd606 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ReqBodyForLiveblocksAuth.java @@ -0,0 +1,15 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.dto; + +import java.util.List; +import java.util.Map; + +public record ReqBodyForLiveblocksAuth( + String userId, + UserInfo userInfo, + Map> permissions +) { + public record UserInfo( + String name, + String avatar + ) {} +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ResBodyForAuthToken.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ResBodyForAuthToken.java new file mode 100644 index 00000000..c7e29e22 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ResBodyForAuthToken.java @@ -0,0 +1,5 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.dto; + +public record ResBodyForAuthToken( + String token +){ } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Dashboard.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Dashboard.java new file mode 100644 index 00000000..b13006c4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Dashboard.java @@ -0,0 +1,36 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +@Entity +@Getter +@Setter +public class Dashboard extends BaseEntity { + // 대시보드의 이름 + @Column(nullable = false) + private String name; + + // 이 대시보드가 속한 스페이스 + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "space_id") + private Space space; + + // 이 대시보드가 담고 있는 그래프 콘텐츠 (1:1 관계) + // Cascade 설정을 통해 Dashboard 저장 시 Graph도 함께 저장되도록 함 + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "graph_id") + private Graph graph; + + // Dashboard 생성 시 비어있는 Graph를 함께 생성하는 편의 메서드 + public static Dashboard create(String name, Space space) { + Dashboard dashboard = new Dashboard(); + dashboard.setName(name); + dashboard.setSpace(space); + dashboard.setGraph(new Graph()); // 비어있는 Graph 생성 및 연결 + return dashboard; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java new file mode 100644 index 00000000..65538cc1 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java @@ -0,0 +1,38 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.EdgeType; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +@Getter +@Setter +@Entity +public class Edge extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "graph_id") + private Graph graph; + + @Column + private String edgeKey; + + @Column + private String sourceNodeKey; + + @Column + private String targetNodeKey; + + @Column + @Enumerated(EnumType.STRING) + private EdgeType edgeType; + + @Column + boolean isAnimated; + + @Column + private String stroke; + + @Column + private Double strokeWidth; +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java new file mode 100644 index 00000000..7715d2d6 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java @@ -0,0 +1,27 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Entity +public class Graph extends BaseEntity { + + @Version + private Long version; + + @OneToMany(mappedBy = "graph", cascade = CascadeType.ALL, orphanRemoval = true) + private List nodes = new ArrayList<>(); + + @OneToMany(mappedBy = "graph", cascade = CascadeType.ALL, orphanRemoval = true) + private List edges = new ArrayList<>(); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java new file mode 100644 index 00000000..64a3b3a8 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java @@ -0,0 +1,38 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.NodeType; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@Setter +@Entity +public class Node extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "graph_id") + private Graph graph; + + @Column + private String nodeKey; + + @Column + @Enumerated(EnumType.STRING) + private NodeType nodeType; + + @ElementCollection + @CollectionTable(name = "node_data", joinColumns = @JoinColumn(name = "node_id")) + @MapKeyColumn(name = "data_key") + @Column(name = "data_value") + private Map data = new HashMap<>(); + + @Column + private double positonX; + + @Column + private double positonY; +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/EdgeType.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/EdgeType.java new file mode 100644 index 00000000..7d90eaa8 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/EdgeType.java @@ -0,0 +1,8 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.enums; + +public enum EdgeType { + DEFAULT, + STRAIGHT, + STEP, + SMOOTHSTEP +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/NodeType.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/NodeType.java new file mode 100644 index 00000000..0111ba3b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/NodeType.java @@ -0,0 +1,5 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.enums; + +public enum NodeType { + CUSTOM +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumer.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumer.java new file mode 100644 index 00000000..fffdb834 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/extraComponent/GraphUpdateConsumer.java @@ -0,0 +1,39 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.extraComponent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage; +import org.tuna.zoopzoop.backend.domain.dashboard.service.DashboardService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GraphUpdateConsumer { + private final DashboardService dashboardService; + private final ObjectMapper objectMapper; + + @RabbitListener(queues = "graph.update.queue") + public void handleGraphUpdate(GraphUpdateMessage message) { + log.info("Received graph update message for dashboardId: {}", message.dashboardId()); + try { + BodyForReactFlow dto = objectMapper.readValue(message.requestBody(), BodyForReactFlow.class); + dashboardService.updateGraph(message.dashboardId(), dto); + log.info("Successfully updated graph for dashboardId: {}", message.dashboardId()); + } catch (ObjectOptimisticLockingFailureException e) { + // Optimistic Lock 충돌 발생! + // 내가 처리하려던 메시지는 이미 구버전 데이터에 대한 요청이었음. + // 따라서 이 메시지는 무시하고 정상 처리된 것으로 간주. + log.warn("Stale update attempt for dashboardId: {}. A newer version already exists. Discarding message.", message.dashboardId()); + // 예외를 다시 던지지 않으므로, 메시지는 큐에서 정상적으로 제거(ACK)됩니다. + } catch (Exception e) { + // 실제 운영에서는 메시지를 재시도하거나, 실패 큐(Dead Letter Queue)로 보내는 등의 + // 정교한 에러 처리 로직이 필요합니다. + log.error("Failed to process graph update for dashboardId: {}", message.dashboardId(), e); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/DashboardRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/DashboardRepository.java new file mode 100644 index 00000000..0b163042 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/DashboardRepository.java @@ -0,0 +1,9 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard; + +@Repository +public interface DashboardRepository extends JpaRepository { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/EdgeRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/EdgeRepository.java new file mode 100644 index 00000000..1d7823ab --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/EdgeRepository.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; + +public interface EdgeRepository extends JpaRepository { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/GraphRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/GraphRepository.java new file mode 100644 index 00000000..e75b7682 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/GraphRepository.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; + +import java.util.Optional; + +public interface GraphRepository extends JpaRepository { + Optional findGraphById(Integer id); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/NodeRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/NodeRepository.java new file mode 100644 index 00000000..6342c6a2 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/NodeRepository.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; + +public interface NodeRepository extends JpaRepository { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java new file mode 100644 index 00000000..46cabef7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java @@ -0,0 +1,201 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.binary.Hex; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.ReqBodyForLiveblocksAuth; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; +import org.tuna.zoopzoop.backend.domain.dashboard.repository.DashboardRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.global.clients.liveblocks.LiveblocksClient; + +import java.nio.file.AccessDeniedException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional +public class DashboardService { + private final DashboardRepository dashboardRepository; + private final MembershipService membershipService; + private final ObjectMapper objectMapper; + private final SignatureService signatureService; + private final RabbitTemplate rabbitTemplate; + private final SpaceService spaceService; + private final LiveblocksClient liveblocksClient; + + + // =========================== Graph 관련 메서드 =========================== + + /** + * 대시보드 ID를 통해 Graph 데이터를 조회하는 메서드 + */ + @Transactional(readOnly = true) + public Graph getGraphByDashboardId(Integer dashboardId) { + Dashboard dashboard = dashboardRepository.findById(dashboardId) + .orElseThrow(() -> new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다.")); + + return dashboard.getGraph(); + } + + /** + * 특정 대시보드의 Graph 데이터를 덮어쓰는(수정) 메서드 + */ + public void updateGraph(Integer dashboardId, BodyForReactFlow dto) { + Graph graph = getGraphByDashboardId(dashboardId); + + // 기존 Graph의 노드와 엣지를 모두 삭제 + graph.getNodes().clear(); + graph.getEdges().clear(); + + // DTO로부터 새로운 노드와 엣지 Entity 리스트를 생성 + List newNodes = dto.toNodeEntities(graph); // DTO에 변환 로직이 있다고 가정 + List newEdges = dto.toEdgeEntities(graph); + + // Graph에 새로운 리스트를 추가 + graph.getNodes().addAll(newNodes); + graph.getEdges().addAll(newEdges); + + } + + /** + * 서명 검증 후 Graph 업데이트를 수행하는 메서드 + * @param dashboardId 대시보드 ID + * @param requestBody 요청 바디 + * @param signatureHeader 서명 헤더 + */ + public void verifyAndUpdateGraph(Integer dashboardId, String requestBody, String signatureHeader) { + // 1. 서명 검증 + if (!signatureService.isValidSignature(requestBody, signatureHeader)) { + throw new SecurityException("Invalid webhook signature."); + } + + // 2. 검증 통과 후, 기존 업데이트 로직 실행 + try { + BodyForReactFlow dto = objectMapper.readValue(requestBody, BodyForReactFlow.class); + updateGraph(dashboardId, dto); + } catch (NoResultException e) { + throw new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다."); + } + catch (Exception e) { + throw new RuntimeException("Failed to process request body.", e); + } + } + + // =========================== 권한 관련 메서드 =========================== + + /** + * 대시보드 접근 권한을 검증하는 메서드 + * @param member 접근을 시도하는 멤버 + * @param dashboardId 접근하려는 대시보드 ID + */ + public void verifyAccessPermission(Member member, Integer dashboardId) throws AccessDeniedException { + Dashboard dashboard = dashboardRepository.findById(dashboardId) + .orElseThrow(() -> new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다.")); + + try { + membershipService.findByMemberAndSpace(member, dashboard.getSpace()); + } catch (NoResultException e) { + throw new AccessDeniedException("대시보드의 접근 권한이 없습니다."); + } + } + + // =========================== message 관리 메서드 =========================== + + /** + * Graph 업데이트 요청을 RabbitMQ 큐에 비동기적으로 발행하는 메서드 + * @param dashboardId 대시보드 ID + * @param requestBody 요청 바디 + * @param signatureHeader 서명 헤더 + */ + public void queueGraphUpdate(Integer dashboardId, String requestBody, String signatureHeader){ + // 서명 검증은 동기적으로 즉시 처리 + if (!signatureService.isValidSignature(requestBody, signatureHeader)) { + throw new SecurityException("Invalid webhook signature."); + } + + // 대시보드 존재 여부 확인 + if (!dashboardRepository.existsById(dashboardId)) { + throw new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다."); + } + + // 큐에 보낼 메시지 생성 + GraphUpdateMessage message = new GraphUpdateMessage(dashboardId, requestBody); + + // RabbitMQ에 메시지 발행 + rabbitTemplate.convertAndSend("zoopzoop.exchange", "graph.update.rk", message); + } + + // =========================== 기타 메서드 =========================== + + /** + * 특정 스페이스에 대한 Liveblocks 접속 토큰(JWT)을 발급합니다. + * @param spaceId 스페이스 ID + * @param member 토큰을 요청하는 멤버 + * @return 발급된 JWT 문자열 + * @throws AccessDeniedException 멤버가 해당 스페이스에 속해있지 않거나 권한이 없는 경우 + */ + @Transactional(readOnly = true) + public String getAuthTokenForSpace(Integer spaceId, Member member) throws AccessDeniedException { + Space space = spaceService.findById(spaceId); + + // 해당 스페이스에 멤버가 속해있는지, PENDING 상태는 아닌지 확인 + Membership membership = membershipService.findByMemberAndSpace(member, space); + if (membership.getAuthority().equals(Authority.PENDING)) { + throw new AccessDeniedException("스페이스에 가입된 멤버가 아닙니다."); + } + + // Liveblocks Room ID 생성 + String roomId = "space_" + space.getId(); + + // Liveblocks에 전달할 사용자 정보 생성 + String userId = String.valueOf(member.getId()); + ReqBodyForLiveblocksAuth.UserInfo userInfo = new ReqBodyForLiveblocksAuth.UserInfo( + member.getName(), + member.getProfileImageUrl() + ); + + // Liveblocks 권한 설정 (내 서비스의 Authority -> Liveblocks 권한으로 변환) + List permissions; + switch (membership.getAuthority()) { + case ADMIN, READ_WRITE: + permissions = List.of("room:write"); + break; + case READ_ONLY: + permissions = Collections.emptyList(); // 빈 리스트는 읽기 전용을 의미 + break; + default: + // PENDING 등 다른 상태는 위에서 이미 필터링됨 + throw new AccessDeniedException("유효하지 않은 권한입니다."); + } + + // Liveblocks Client에 전달할 요청 객체 생성 + ReqBodyForLiveblocksAuth authRequest = new ReqBodyForLiveblocksAuth( + userId, + userInfo, + Map.of(roomId, permissions) + ); + + // LiveblocksClient를 통해 토큰 발급 요청 + return liveblocksClient.getAuthToken(authRequest); + } + + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphService.java new file mode 100644 index 00000000..d6069ffd --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphService.java @@ -0,0 +1,26 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.service; + +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.repository.GraphRepository; + +@Service +@RequiredArgsConstructor +public class GraphService { + private final GraphRepository graphRepository; + + @Transactional + public Graph saveGraph(Graph graph) { + return graphRepository.save(graph); + } + + public Graph getGraph(Integer id) { + return graphRepository.findGraphById(id).orElseThrow(() -> + new NoResultException(id + " id를 가진 그래프를 찾을 수 없습니다.") + ); + } +} + diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java new file mode 100644 index 00000000..242e925b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java @@ -0,0 +1,77 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.service; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.binary.Hex; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Service +@RequiredArgsConstructor +public class SignatureService { + @Value("${liveblocks.secret-key}") + private String liveblocksSecretKey; + + // 5분 (밀리초 단위) + private static final long TOLERANCE_IN_MILLIS = 5 * 60 * 1000; + + /** + * LiveBlocks Webhook 요청의 유효성을 검증하는 메서드 + * @param requestBody 요청 바디 + * @param signatureHeader LiveBlocks가 제공하는 서명 헤더 + * @return 서명이 유효하면 true, 그렇지 않으면 false + */ + public boolean isValidSignature(String requestBody, String signatureHeader) { + // [임시 코드] 로컬 테스트를 위해 무조건 true 반환 +// if ("true".equals(System.getProperty("local.test.skip.signature"))) { +// return true; +// } + + try { + // 1. 헤더 파싱 + String[] parts = signatureHeader.split(","); + long timestamp = -1; + String signatureHashFromHeader = null; + + for (String part : parts) { + String[] pair = part.split("=", 2); + if (pair.length == 2) { + if ("t".equals(pair[0])) { + timestamp = Long.parseLong(pair[1]); + } else if ("v1".equals(pair[0])) { + signatureHashFromHeader = pair[1]; + } + } + } + + if (timestamp == -1 || signatureHashFromHeader == null) { + return false; // 헤더 형식이 잘못됨 + } + + // 2. 리플레이 공격 방지를 위한 타임스탬프 검증 (선택사항) + long now = System.currentTimeMillis(); + if (now - timestamp > TOLERANCE_IN_MILLIS) { + return false; // 너무 오래된 요청 + } + + // 3. 서명 재생성 + String payload = timestamp + "." + requestBody; + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(liveblocksSecretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + byte[] expectedHashBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + + // 4. 서명 비교 (타이밍 공격 방지를 위해 MessageDigest.isEqual 사용) + byte[] signatureHashBytesFromHeader = Hex.decodeHex(signatureHashFromHeader); + return MessageDigest.isEqual(expectedHashBytes, signatureHashBytesFromHeader); + + } catch (Exception e) { + // 파싱 실패, 디코딩 실패 등 모든 예외는 검증 실패로 간주 + return false; + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java new file mode 100644 index 00000000..3d3fd308 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.datasource.ai.dto; + +import java.time.LocalDate; + +public record AiExtractorDto( + String title, // 제목 + LocalDate dataCreatedDate, // 작성일자 + String content, // ai한테 줘야할 내용 + String imageUrl, // 썸네일 이미지 url + String source // 출처 +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AnalyzeContentDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AnalyzeContentDto.java new file mode 100644 index 00000000..f56f1fc4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AnalyzeContentDto.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.datasource.ai.dto; + +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; + +import java.util.List; + +public record AnalyzeContentDto( + String summary, + Category category, // ENUM 그대로 매핑 (AI 출력도 ENUM 이름으로) + List tags +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java new file mode 100644 index 00000000..5e9268b5 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java @@ -0,0 +1,57 @@ +package org.tuna.zoopzoop.backend.domain.datasource.ai.prompt; + +public class AiPrompt { + // 불특정 사이트 메타데이터 추출 프롬프트 + public static final String EXTRACTION = """ + 아래 HTML 전문에서 필요한 정보를 JSON 형식으로 추출해 주세요. + 반환 JSON 구조: + { + "title": "제목", + "datacreatedDate": "작성일자 (YYYY-MM-DD)", + "content": "본문 내용", + "imageUrl": "썸네일 이미지 URL", + "source": "출판사 이름 or 서비스 이름 or 도메인 이름" + } + + HTML 전문: + %s + + - 반드시 JSON 형식으로만 출력해 주세요. + - 해당정보가 없으면 반드시 빈 문자열로 출력해 주세요. + """; + + // 내용 요약, 태그 추출, 카테고리 선정 프롬프트 + public static final String SUMMARY_TAG_CATEGORY = """ + 너는 뉴스, 블로그 등 내용 요약 및 분류 AI야. 아래의 규칙에 따라 답변해. + + [규칙] + 1. 주어진 content를 50자 이상 100자 이하로 간단히 요약해라. + 2. 아래 Category 목록 중에서 content와 가장 적절한 카테고리 하나를 정확히 선택해라. + - POLITICS("정치") + - ECONOMY("경제") + - SOCIETY("사회") + - IT("IT") + - SCIENCE("과학") + - CULTURE("문화") + - SPORTS("스포츠") + - ENVIRONMENT("환경") + - HISTORY("역사") + - WORLD("세계") + 3. 내가 제공하는 태그 목록을 참고해서, content와 관련된 태그를 3~5개 생성해라. + - 제공된 태그와 중복 가능하다. + - 필요하면 새로운 태그를 만들어도 된다. + 4. 출력은 반드시 아래 JSON 형식으로 해라. Markdown 문법(```)은 쓰지 마라. + - 해당정보가 없을 시 summary는 빈 문자열, category는 null, tags는 빈 리스트로 출력해줘라. + + [출력 JSON 형식] + { + "summary": "내용 요약 (50~100자)", + "category": "선택된 카테고리 ENUM 이름", + "tags": ["태그1", "태그2", "태그3", ...] + } + + [입력 데이터] + content: %s + existingTags: %s + """; +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java new file mode 100644 index 00000000..fa94e7aa --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java @@ -0,0 +1,79 @@ +package org.tuna.zoopzoop.backend.domain.datasource.ai.service; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AiExtractorDto; +import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AnalyzeContentDto; +import org.tuna.zoopzoop.backend.domain.datasource.ai.prompt.AiPrompt; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AiService { + private final ChatClient chatClient; + + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 500), + retryFor = {JsonParseException.class, JsonProcessingException.class} + ) + public AiExtractorDto extract(String rawHtml) { + AiExtractorDto response = chatClient.prompt() + .user(AiPrompt.EXTRACTION.formatted(rawHtml)) + .call() + .entity(AiExtractorDto.class); + + return response; + } + + @Recover + public AiExtractorDto extractRecover(Exception e, String rawHtml) { + return new AiExtractorDto( + "", + null, + "", + "", + "" + ); + } + + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 500), + retryFor = {JsonParseException.class, JsonProcessingException.class} + ) + public AnalyzeContentDto analyzeContent(String content, List tagList) { + // JSON 배열 문자열로 변환 + String tags = tagList.stream() + .map(Tag::getTagName) // 태그명만 추출 + .map(tagName -> "\"" + tagName + "\"") + .collect(Collectors.joining(", ", "[", "]")); + + AnalyzeContentDto response = chatClient.prompt() + .user(AiPrompt.SUMMARY_TAG_CATEGORY.formatted(content, tags)) + .call() + .entity(AnalyzeContentDto.class); + + return response; + } + + @Recover + public AnalyzeContentDto analyzeContentRecover(Exception e, String content, List tagList) { + return new AnalyzeContentDto( + "", + null, + new ArrayList<>() + ); + } + +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java new file mode 100644 index 00000000..58e2e32a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java @@ -0,0 +1,232 @@ +package org.tuna.zoopzoop.backend.domain.datasource.controller; + +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.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.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.tuna.zoopzoop.backend.domain.datasource.dto.*; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; +import org.tuna.zoopzoop.backend.domain.datasource.service.PersonalDataSourceService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/archive") +@RequiredArgsConstructor +@Tag(name = "ApiV1DataSource(Personal)", description = "개인 아카이브 자료 API") +public class DataSourceController { + + private final PersonalDataSourceService personalApp; + + // ===== 등록 (개인만) ===== + // DataSourceController + + @Operation(summary = "자료 등록", description = "내 PersonalArchive 안에 자료를 등록합니다.") + @PostMapping("") + public ResponseEntity>> createDataSource( + @Valid @RequestBody reqBodyForCreateDataSource rq, + @AuthenticationPrincipal CustomUserDetails user + ) throws IOException { + int id = personalApp.create( + user.getMember().getId(), + rq.sourceUrl(), + rq.folderId(), + DataSourceService.CreateCmd.builder().build() + ); + return ResponseEntity.ok( + new RsData<>("200", "새로운 자료가 등록됐습니다.", Map.of("dataSourceId", id)) + ); + } + + + // ===== 단건 삭제 ===== + @Operation(summary = "자료 단건 삭제", description = "내 PersonalArchive 안에 자료를 단건 삭제합니다.") + @DeleteMapping("/{dataSourceId}") + public ResponseEntity>> delete( + @PathVariable Integer dataSourceId, + @AuthenticationPrincipal CustomUserDetails user + ) { + int deletedId = personalApp.deleteOne(user.getMember().getId(), dataSourceId); + return ResponseEntity.ok( + new RsData<>("200", deletedId + "번 자료가 삭제됐습니다.", Map.of("dataSourceId", deletedId)) + ); + } + + // ===== 다건 삭제 ===== + @Operation(summary = "자료 다건 삭제", description = "내 PersonalArchive 안에 자료를 다건 삭제합니다.") + @DeleteMapping("/delete") + public ResponseEntity> deleteMany( + @Valid @RequestBody reqBodyForDeleteMany rq, + @AuthenticationPrincipal CustomUserDetails user + ) { + personalApp.deleteMany(user.getMember().getId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "복수개의 자료가 삭제됐습니다.", null)); + } + + // ===== 소프트 삭제/복원 ===== + @Operation(summary = "자료 다건 임시 삭제", description = "내 PersonalArchive 안에 자료들을 임시 삭제합니다.") + @PatchMapping("/soft-delete") + public ResponseEntity> softDelete(@RequestBody @Valid IdsRequest rq, + @AuthenticationPrincipal CustomUserDetails user) { + personalApp.softDelete(user.getMember().getId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "자료들이 임시 삭제됐습니다.", null)); + } + + @Operation(summary = "자료 다건 복원", description = "내 PersonalArchive 안에 자료들을 복원합니다.") + @PatchMapping("/restore") + public ResponseEntity> restore(@RequestBody @Valid IdsRequest rq, + @AuthenticationPrincipal CustomUserDetails user) { + personalApp.restore(user.getMember().getId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "자료들이 복구됐습니다.", null)); + } + + // ===== 이동 ===== + @Operation(summary = "자료 단건 이동", description = "내 PersonalArchive 안에 자료를 단건 이동합니다.") + @PatchMapping("/{dataSourceId}/move") + public ResponseEntity>> moveDataSource( + @PathVariable Integer dataSourceId, + @Valid @RequestBody reqBodyForMoveDataSource rq, + @AuthenticationPrincipal CustomUserDetails user + ) { + var result = personalApp.moveOne(user.getMember().getId(), dataSourceId, rq.folderId()); + String msg = result.dataSourceId() + "번 자료가 " + result.folderId() + "번 폴더로 이동했습니다."; + return ResponseEntity.ok( + new RsData<>("200", msg, + Map.of("folderId", result.folderId(), "dataSourceId", result.dataSourceId())) + ); + } + + @Operation(summary = "자료 다건 이동", description = "내 PersonalArchive 안에 자료들을 다건 이동합니다.") + @PatchMapping("/move") + public ResponseEntity> moveMany( + @Valid @RequestBody reqBodyForMoveMany rq, + @AuthenticationPrincipal CustomUserDetails user + ) { + personalApp.moveMany(user.getMember().getId(), rq.folderId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "복수 개의 자료를 이동했습니다.", null)); + } + + // ===== 수정 ===== + // JSON만 수정 + @Operation(summary = "자료 수정", description = "내 PersonalArchive 안에 자료를 수정합니다.") + @PatchMapping(path = "/{dataSourceId}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> updateDataSourceJson( + @PathVariable Integer dataSourceId, + @RequestBody @Valid reqBodyForUpdateDataSource body, + @AuthenticationPrincipal CustomUserDetails user + ) { + boolean anyPresent = + (body.title() != null && body.title().isPresent()) || + (body.summary() != null && body.summary().isPresent()) || + (body.sourceUrl() != null && body.sourceUrl().isPresent()) || + (body.imageUrl() != null && body.imageUrl().isPresent()) || + (body.source() != null && body.source().isPresent()) || + (body.tags() != null && body.tags().isPresent()) || + (body.category() != null && body.category().isPresent()); + if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다."); + + int updatedId = personalApp.update( + user.getMember().getId(), + dataSourceId, + DataSourceService.UpdateCmd.builder() + .title(body.title()) + .summary(body.summary()) + .source(body.source()) + .sourceUrl(body.sourceUrl()) + .imageUrl(body.imageUrl()) + .category(body.category()) + .tags(body.tags()) + .build() + ); + + return ResponseEntity.ok( + new RsData<>("200", updatedId + "번 자료가 수정됐습니다.", Map.of("dataSourceId", updatedId)) + ); + } + + // 이미지 포함 수정 + @Operation(summary = "자료 수정(이미지+JSON)", description = "내 PersonalArchive 안에 자료를 수정합니다") + @PatchMapping(path = "/{dataSourceId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity>> updateDataSourceMultipart( + @PathVariable Integer dataSourceId, + @RequestPart("payload") @Valid reqBodyForUpdateDataSource body, // JSON 파트 + @RequestPart(value = "image", required = false) MultipartFile image, // 파일 파트 + @AuthenticationPrincipal CustomUserDetails user + ) { + boolean anyPresent = + (body.title() != null && body.title().isPresent()) || + (body.summary() != null && body.summary().isPresent()) || + (body.sourceUrl() != null && body.sourceUrl().isPresent()) || + (body.imageUrl() != null && body.imageUrl().isPresent()) || + (body.source() != null && body.source().isPresent()) || + (body.tags() != null && body.tags().isPresent()) || + (body.category() != null && body.category().isPresent()) || + (image != null && !image.isEmpty()); + if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다."); + + var baseCmd = DataSourceService.UpdateCmd.builder() + .title(body.title()) + .summary(body.summary()) + .source(body.source()) + .sourceUrl(body.sourceUrl()) + .imageUrl(body.imageUrl()) + .category(body.category()) + .tags(body.tags()) + .build(); + + var outcome = personalApp.updateWithImage( + user.getMember().getId(), dataSourceId, baseCmd, image + ); + + Map data = new HashMap<>(); + data.put("dataSourceId", outcome.dataSourceId()); + data.put("imageUrl", outcome.imageUrl()); + + return ResponseEntity.ok(new RsData<>("200", "자료가 수정됐습니다.", data)); + } + + // ===== 검색 ===== + @Operation(summary = "자료 검색", description = "내 PersonalArchive 안에 자료들을 검색합니다.") + @GetMapping("") + public ResponseEntity>> search( + @RequestParam(required = false) String title, + @RequestParam(required = false) String summary, + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer folderId, + @RequestParam(required = false) String folderName, + @RequestParam(required = false, defaultValue = "true") Boolean isActive, + @PageableDefault(size = 8, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal CustomUserDetails user + ) { + Category categoryEnum = category != null ? Category.from(category) : null; + var cond = DataSourceSearchCondition.builder() + .title(title).summary(summary).category(categoryEnum).folderId(folderId) + .folderName(folderName).isActive(isActive).keyword(keyword).build(); + + Page page = personalApp.search(user.getMember().getId(), cond, pageable); + String sorted = pageable.getSort().toString().replace(": ", ","); + + var pageInfo = new PageInfo( + page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages(), + page.isFirst(), page.isLast(), sorted + ); + var body = new SearchResponse<>(page.getContent(), pageInfo); + + return ResponseEntity.ok(new RsData<>("200", "복수개의 자료가 조회됐습니다.", body)); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java new file mode 100644 index 00000000..e6c28f50 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java @@ -0,0 +1,27 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.controller; + +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; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; + +import java.util.ArrayList; + +@RestController +@RequestMapping("api/v1") +@RequiredArgsConstructor +public class CrawlerTestController { + private final CrawlerManagerService crawlerManagerService; + private final DataProcessorService dataProcessorService; + private final TagRepository tagRepository; + + @GetMapping("/crawl") + public DataSourceDto crawl(@RequestParam String url) throws Exception { + return dataProcessorService.process(url, new ArrayList<>()); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/CrawlerResult.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/CrawlerResult.java new file mode 100644 index 00000000..c65a760b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/CrawlerResult.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.dto; + +public record CrawlerResult( + CrawlerType type, // SPECIFIC or UNSPECIFIC + T data +) { + public enum CrawlerType { + SPECIFIC, UNSPECIFIC + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java new file mode 100644 index 00000000..db524006 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java @@ -0,0 +1,22 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.dto; + +import java.time.LocalDate; + +public record SpecificSiteDto( + String title, // 제목 + LocalDate dataCreatedDate, // 작성일자 + String content, // ai한테 줘야할 내용 + String imageUrl, // 썸네일 이미지 url + String source // 출처 +) { + @Override + public String toString() { + return "SpecificSiteDto {\n" + + " title='" + title + "',\n" + + " dataCreatedDate=" + dataCreatedDate + ",\n" + + " content='" + content + "',\n" + + " imageUrl='" + imageUrl + "',\n" + + " source='" + source + "'\n" + + "}"; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/UnspecificSiteDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/UnspecificSiteDto.java new file mode 100644 index 00000000..0f4200e0 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/UnspecificSiteDto.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.dto; + +public record UnspecificSiteDto( + String rawHtml // 불특정 사이트의 html 전문 +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java new file mode 100644 index 00000000..d100093e --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java @@ -0,0 +1,18 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import org.jsoup.nodes.Document; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; + +import java.time.LocalDate; + +public interface Crawler { + + // 작성 일자가 null일 경우 기본값 설정 + // LocalDate.EPOCH(1970-01-01 - 시간이 없는 값 표현할 때 사용되는 관용적 기준점) + // 이 값이 사용되면 작성 일자가 없는 것으로 간주 + LocalDate DEFAULT_DATE = LocalDate.EPOCH; + + boolean supports(String domain); + CrawlerResult extract(Document doc); + LocalDate transLocalDate(String rawDate); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java new file mode 100644 index 00000000..4b9a472f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java @@ -0,0 +1,24 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import lombok.RequiredArgsConstructor; +import org.jsoup.nodes.Document; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CrawlerManagerService { + private final List crawlers; + + public CrawlerResult extractContent(String url, Document doc) { + for (Crawler crawler : crawlers) { + if (crawler.supports(url)) { + return crawler.extract(doc); + } + } + + return null; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java new file mode 100644 index 00000000..95d9cf67 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java @@ -0,0 +1,57 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import org.jsoup.nodes.Document; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.UnspecificSiteDto; + +import java.time.LocalDate; + +@Component +@Order(Ordered.LOWEST_PRECEDENCE) // 모든 URL 대응 (우선순위 맨 뒤) +public class GenericCrawler implements Crawler { + @Override + public boolean supports(String url) { + return true; + } + + @Override + public CrawlerResult extract(Document doc) { + // img 태그 + doc.select("img[src]").forEach(el -> + el.attr("src", el.absUrl("src")) + ); + + // meta 태그 (Open Graph, Twitter Card 등) + doc.select("meta[content]").forEach(meta -> { + String absUrl = meta.absUrl("content"); + if (!absUrl.isEmpty() && !absUrl.equals(meta.attr("content"))) { + meta.attr("content", absUrl); + } + }); + + // 본문만 가져오기 (HTML) + String cleanHtml = doc.body().html() + .replaceAll("]*>.*?", "") + .replaceAll("]*>.*?", "") + // 주석 제거 + .replaceAll("", "") + // 연속된 공백 제거 + .replaceAll("\\s+", " ") + // 불필요한 속성 제거 + .replaceAll("(class|id|style|onclick|onload)=\"[^\"]*\"", "") + .trim(); + + return new CrawlerResult<>( + CrawlerResult.CrawlerType.UNSPECIFIC, + new UnspecificSiteDto(cleanHtml) + ); + } + + @Override + public LocalDate transLocalDate(String rawDate) { + return null; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java new file mode 100644 index 00000000..aa9f920d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java @@ -0,0 +1,140 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import org.jsoup.Connection; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; +import org.tuna.zoopzoop.backend.domain.datasource.exception.ServiceException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class NaverBlogCrawler implements Crawler { + private static final SupportedDomain DOMAIN = SupportedDomain.NAVERBLOG; + private static final DateTimeFormatter NAVERBLOG_FORMATTER = + DateTimeFormatter.ofPattern("yyyy. M. d. HH:mm"); + + @Override + public boolean supports(String domain) { + return domain.contains(DOMAIN.getDomain()); + } + + @Override + public CrawlerResult extract(Document doc) { + /* + 블로그 본문은