diff --git a/.env.example b/.env.example index 8d7d5ba..00533c5 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ SPRING_PROFILES_ACTIVE= DB_URL= DB_USERNAME= DB_PASSWORD= +MongoDB_URI= # AWS AWS_ACCESS_KEY= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3a159f0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: Run Tests and Upload Coverage + +on: + push: + branches: [ main, develop ] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + env: + SPRING_PROFILES_ACTIVE: test + AWS_S3_ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY }} + AWS_S3_SECRET_KEY: ${{ secrets.AWS_S3_SECRET_KEY }} + AWS_S3_REGION: ${{ secrets.AWS_S3_REGION }} + AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run Tests and Coverage + run: ./gradlew clean test jacocoTestReport + + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: | + monew-api/build/reports/jacoco/test/jacocoTestReport.xml + monew-batch/build/reports/jacoco/test/jacocoTestReport.xml + fail_ci_if_error: true \ No newline at end of file diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..9e959d1 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,107 @@ +# ==================================== +# Dockerfile for API Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +# 빌드 인자 선언 (docker-compose.prod.yml에서 전달) +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +WORKDIR /app + +# 루트 프로젝트 설정 파일 복사 +#COPY settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 존재 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/build.gradle +COPY monew-batch/build.gradle monew-batch/build.gradle +COPY monew-monitor/build.gradle monew-monitor/build.gradle + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# API 모듈 소스 코드 복사 +COPY monew-api/ monew-api/ + +# API 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-api:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-api/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +# 빌드 인자 다시 선언 (runtime stage에서 사용) +ARG BUILD_DATE +ARG VCS_REF +ARG VERSION + +WORKDIR /app + +# 이미지 메타데이터 레이블 추가 +LABEL org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.title="Monew API" \ + org.opencontainers.image.description="Monew API Server" + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 로그 디렉토리 생성 및 권한 설정 +RUN mkdir -p /app/.logs && chown -R spring:spring /app/.logs + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# 헬스체크 설정 +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + +# JVM 옵션 설정 (환경변수로 오버라이드 가능) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -XX:+OptimizeStringConcat \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8080 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.batch b/Dockerfile.batch new file mode 100644 index 0000000..9184668 --- /dev/null +++ b/Dockerfile.batch @@ -0,0 +1,87 @@ +# ==================================== +# Dockerfile for Batch Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +#COPY monew-batch/build.gradle settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew* ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ + +# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# 전체 소스 코드 복사 +#COPY monew-batch . +COPY monew-api/ monew-api/ +COPY monew-batch/ monew-batch/ + +# Batch 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-batch:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-batch/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 로그 디렉토리 생성 및 권한 설정 +RUN mkdir -p /app/.logs && chown -R spring:spring /app/.logs + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# JVM 옵션 설정 (배치 작업용 최적화) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -XX:MaxGCPauseMillis=200 \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8081 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.monitor b/Dockerfile.monitor new file mode 100644 index 0000000..34679de --- /dev/null +++ b/Dockerfile.monitor @@ -0,0 +1,87 @@ +# ==================================== +# Dockerfile for Monitor Module +# ==================================== + +# Stage 1: Build +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +#COPY monew-monitor/build.gradle settings.gradle ./ +#COPY build.gradle ./ +#COPY gradlew* ./ +#COPY gradle gradle/ + +COPY settings.gradle ./ +COPY build.gradle ./ +COPY gradlew ./ +COPY gradle gradle/ +COPY monew-monitor/build.gradle monew-monitor/ + +# 2) CRLF 제거 + 실행권한 + 래퍼 존재/버전 확인 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew && \ + ls -al && ls -al gradle/wrapper && head -n1 gradlew && \ + ./gradlew --version + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN ./gradlew dependencies --no-daemon || true + +# 전체 소스 코드 복사 +#COPY monew-monitor . +COPY monew-monitor/ monew-monitor/ + +# Monitor 모듈만 빌드 (테스트 제외) +RUN ./gradlew :monew-monitor:bootJar -x test --no-daemon + +# JAR 파일 위치 확인 및 복사 +RUN mkdir -p /app/build && \ + find monew-monitor/build/libs -name "*.jar" -not -name "*-plain.jar" -exec cp {} /app/build/app.jar \; + +# Stage 2: Runtime +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# 보안을 위한 non-root 사용자 생성 +RUN addgroup -S spring && adduser -S spring -G spring + +# 타임존 설정 +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +# 빌드 스테이지에서 JAR 파일 복사 +COPY --from=builder /app/build/app.jar app.jar + +# 소유권 변경 +RUN chown spring:spring app.jar + +# 사용자 변경 +USER spring:spring + +# 헬스체크 설정 +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1 + +# JVM 옵션 설정 (모니터링 경량화) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=70.0 \ + -XX:InitialRAMPercentage=40.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +# 애플리케이션 포트 +EXPOSE 8082 + +# 애플리케이션 실행 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/Dockerfile.multi b/Dockerfile.multi new file mode 100644 index 0000000..9aa5176 --- /dev/null +++ b/Dockerfile.multi @@ -0,0 +1,120 @@ +# ==================================== +# Multi-Module Multi-Stage Dockerfile +# 한 번에 모든 모듈을 빌드하는 통합 Dockerfile +# ==================================== + +# Stage 1: Build All Modules +FROM gradle:8.5-jdk17-alpine AS builder + +WORKDIR /app + +# Gradle wrapper 및 설정 파일 복사 +COPY build.gradle settings.gradle ./ +COPY gradlew* ./ +COPY gradle gradle/ + +# CRLF 방지 + 실행권한 +RUN sed -i 's/\r$//' gradlew && chmod +x gradlew + +# 모든 서브모듈의 build.gradle 복사 (의존성 캐싱) +COPY monew-api/build.gradle monew-api/ +COPY monew-batch/build.gradle monew-batch/ +COPY monew-monitor/build.gradle monew-monitor/ + +# Gradle wrapper 실행 권한 +RUN chmod +x gradlew || true + +# 의존성 다운로드 (캐시 레이어) +RUN gradle dependencies --no-daemon || true + +# 전체 소스 코드 복사 +COPY . . + +# 모든 모듈 빌드 (테스트 제외) +RUN gradle build -x test --no-daemon + +# Stage 2: API Runtime +FROM eclipse-temurin:17-jre-alpine AS api + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-api/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:InitialRAMPercentage=50.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8080 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# Stage 3: Batch Runtime +FROM eclipse-temurin:17-jre-alpine AS batch + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-batch/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:+UseG1GC \ + -XX:MaxGCPauseMillis=200 \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8081 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] + +# Stage 4: Monitor Runtime +FROM eclipse-temurin:17-jre-alpine AS monitor + +WORKDIR /app + +RUN addgroup -S spring && adduser -S spring -G spring + +RUN apk add --no-cache tzdata wget && \ + cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone && \ + apk del tzdata + +COPY --from=builder /app/monew-monitor/build/libs/*[!plain].jar app.jar + +RUN chown spring:spring app.jar +USER spring:spring + +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8082/actuator/health || exit 1 + +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=70.0 \ + -XX:+UseG1GC \ + -Djava.security.egd=file:/dev/./urandom" + +EXPOSE 8082 + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index ba4ab45..8ef75be 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,229 @@ -# monew-backend -Spring Boot · JPA · Batch · PostgreSQL · MongoDB · AWS S3 기반 뉴스 통합 및 백업·복구 백엔드 시스템 +# 모뉴 (MONEW) - 마음대로 골라보는 모든 뉴스 + +monew_for_readme + +### 관련 자료 + +> 협업 문서: [Notion 팀 문서 바로가기](https://www.notion.so/2-29228815f6f280ca8005d12bc9670225?pvs=21) + +
+ +## 📘 프로젝트 소개 + +**MONEW**는 사용자의 관심사에 맞춰 뉴스를 자동으로 수집하고, 댓글·좋아요·알림 등 **소셜 기능을 통해 뉴스 경험을 확장하는 개인화 뉴스 플랫폼**입니다. + +* **프로젝트 기간:** 2025.10.17 ~ 2025.11.07 +* **기획 의도:** 넘쳐나는 뉴스 속에서 ‘나에게 맞는 뉴스’만 보고 싶은 사용자의 니즈 해결 +* **핵심 목표:** + + * 관심사 기반 뉴스 큐레이션 및 자동 수집 + * 사용자 활동(댓글, 좋아요, 알림) 통합 관리 + * 스케줄러 기반 기사 동기화 및 S3 백업 복구 기능 + * Prometheus + Grafana 기반 실시간 관찰성 확보 + +--- + +### 🎥 시연 영상 +https://github.com/user-attachments/assets/e1d18b5b-4470-41db-b318-1a91ae83fef4 + +
+ +## 🛠 기술 스택 + +| **구분** | **사용 기술** | +| :-------------------------------- | :-------------------------------------------------------------------------------------------------- | +| **Language & Core** | Java 17 | +| **Framework / Runtime** | Spring Boot 3.x, Spring Batch | +| **Database & ORM** | PostgreSQL, MongoDB, H2, Spring Data JPA, QueryDSL | +| **Build & Dependency Management** | Gradle | +| **Infra & DevOps** | AWS (ECS Fargate, ECR, RDS, S3), Docker, GitHub Actions | +| **Monitoring & Metrics** | Spring Actuator, Prometheus, Grafana | +| **API & Docs** | Spring REST Docs, Swagger (OpenAPI 3) | +| **Utilities & Others** | Lombok, MapStruct, Jakarta Validation, Logback (MDC Logging), BCryptPasswordEncoder, JUnit, Postman | +| **Collaboration Tools** | GitHub, Discord, Figma, Notion | + +
+ +## 💡 팀원 소개 및 주요 구현 기능 + +### 🧾 임재혁 + +* NAVER, 조선일보, 한경, 연합뉴스 API 기반 뉴스 자동 수집 +* Spring Batch 기반 수집 → 정제 → 저장 3단계 Job 구성 +* 기사 수집, 백업, 삭제 및 메트릭 수집 로직 구현 +* AWS S3 기반 JSON 백업/복구 기능 구축 +* Jacoco, Codecov 기반 CI 환경 구성 및 커버리지 측정 자동화 + +> GitHub: [🔗 JaehyeokLim](https://github.com/JaehyeokLim) + +
+ +### 🔒 김찬혁 + +* 댓글 CRUD 및 좋아요 기능 구현 +* Docker & AWS ECS 배포 환경 구성 +* GitHub Actions 통한 자동 배포(CI/CD) 파이프라인 구축 +* Prometheus + Grafana 기반 모니터링 환경 구성 + +> GitHub: [🔗 chanhyeok0201](https://github.com/chanhyeok0201) + +
+ +### 🧩 강문영 + +* 알림 CRUD 및 이벤트 기반 알림 시스템 구현 +* 스케줄링 기반 알림 삭제 로직 구현 +* JobExecutionListener를 사용하여 구독 뉴스 기사 수집 알림 기능 구현 + +> GitHub: [🔗 truuuely](https://github.com/truuuely) + +
+ +### 🎓 이예림 + +* 관심사 CRUD 및 구독 로직 구현 +* TDD 기반 테스트 코드 작성 및 검증 체계 구축 + +> GitHub: [🔗 yeahlimm](https://github.com/yeahlimm) + +
+ +### ⏰ 정영진 + +* 사용자 활동 내역(댓글, 좋아요, 뉴스 열람) 통합 조회 기능 구현 +* PostgreSQL → MongoDB 캐시 구조로 전환하여 조회 성능 개선 +* Docker & AWS ECS 배포 환경 구성 +* GitHub Actions 통한 자동 배포(CI/CD) 파이프라인 구축 +* Prometheus + Grafana 기반 모니터링 환경 구성 + +> GitHub: [🔗 userjin2123](https://github.com/userjin2123) + +
+ +### 👤 최도한 + +* 회원가입, 로그인, 탈퇴 기능 구현 +* BCrypt 기반 비밀번호 암호화 처리 +* 사용자 배치 및 메트릭 수집 로직 구성 + +> GitHub: [🔗 DoHanChoi](https://github.com/DoHanChoi) + +
+ +### ERD + +Monew (2) 1 + +
+ +## ⚙️ 시스템 아키텍처 + +**데이터 성격에 따라 저장소를 분리하고, 클라우드 기반 자동 배포 및 모니터링 환경 구축** +스크린샷 2025-11-12 오후 3 35 30 + +
+ +### 📁 파일 구조 + +```plaintext +monew-backend/ +├── .github/ +│ └── workflows/ +│ ├── deploy-api.yml +│ ├── deploy-batch.yml +│ └── deploy-monitor.yml +│ +├── .gradle/ +├── .idea/ +├── build/ +├── gradle/ +├── logs/ +│ +├── monew-api/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/monew/monew_api/ +│ │ │ │ ├── article/ +│ │ │ │ ├── comments/ +│ │ │ │ ├── common/ +│ │ │ │ ├── interest/ +│ │ │ │ ├── notification/ +│ │ │ │ ├── subscribe/ +│ │ │ │ ├── user/ +│ │ │ │ ├── useractivity/ +│ │ │ │ └── MonewApiApplication.java +│ │ │ └── resources/ +│ │ │ ├── db/ +│ │ │ │ ├── data/ +│ │ │ │ └── schema.sql +│ │ │ ├── static/ +│ │ │ │ ├── api/ +│ │ │ │ ├── assets/ +│ │ │ │ └── index.html +│ │ │ ├── application.yml +│ │ │ ├── application-dev.yml +│ │ │ ├── application-prod.yml +│ │ │ └── logback-spring.xml +│ │ └── test/ +│ │ ├── java/com/monew/monew_api/ +│ │ │ ├── Comment/ +│ │ │ └── Notification/ +│ │ └── resources/application-test.yml +│ └── build.gradle +│ +├── monew-batch/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/monew/monew_batch/ +│ │ │ │ ├── article/ +│ │ │ │ │ ├── config/ +│ │ │ │ │ ├── dto/ +│ │ │ │ │ ├── enums/ +│ │ │ │ │ ├── job/ +│ │ │ │ │ ├── matric/ +│ │ │ │ │ ├── properties/ +│ │ │ │ │ ├── repository/ +│ │ │ │ │ ├── scheduler/ +│ │ │ │ │ └── service/ +│ │ │ │ ├── common/ +│ │ │ │ ├── notification/ +│ │ │ │ ├── user/ +│ │ │ │ └── MonewBatchApplication.java +│ │ │ └── resources/ +│ │ │ ├── application.yml +│ │ │ ├── application-dev.yml +│ │ │ ├── application-prod.yml +│ │ │ └── schema-batch.sql +│ │ └── test/ +│ │ ├── java/com/monew/monew_batch/s3/AWS3Test.java +│ │ └── resources/application-test.yml +│ └── build.gradle +│ +├── monew-monitor/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/monew/monew_monitor/ +│ │ │ │ └── MonewMonitorApplication.java +│ │ │ └── resources/ +│ │ │ ├── application.yml +│ │ │ └── prometheus.yml +│ │ └── test/ +│ │ └── java/com/monew/monew_monitor/ +│ └── build.gradle +│ +├── .dockerignore +├── .env +├── .env.example +├── .gitignore +├── build.gradle +├── docker-compose.prod.yml +├── Dockerfile.api +├── Dockerfile.batch +├── Dockerfile.monitor +├── Dockerfile.multi +├── gradlew +├── gradlew.bat +├── README.md +└── settings.gradle +``` + diff --git a/build.gradle b/build.gradle index 7d24310..576d6aa 100644 --- a/build.gradle +++ b/build.gradle @@ -15,12 +15,33 @@ java { subprojects { apply plugin: 'java' + apply plugin: 'jacoco' apply plugin: 'io.spring.dependency-management' repositories { mavenCentral() } + jacoco { + toolVersion = "0.8.10" + } + + test { + useJUnitPlatform() + finalizedBy jacocoTestReport // 테스트 후 커버리지 자동 생성 + systemProperty 'spring.profiles.active', 'test' // H2 프로필 강제 적용 + } + + jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = false + html.required = true + html.outputLocation = layout.buildDirectory.dir("jacocoHtml") + } + } + configurations { compileOnly { extendsFrom annotationProcessor @@ -32,10 +53,8 @@ subprojects { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'io.micrometer:micrometer-core' implementation 'org.springframework.boot:spring-boot-starter-actuator' - } - - tasks.named('test') { - useJUnitPlatform() + runtimeOnly "io.micrometer:micrometer-registry-prometheus" } } \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bfb0964 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,166 @@ +version: '3.8' + +services: + # API Application (Production) + api: + build: + context: . + dockerfile: Dockerfile.api + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-api:${VERSION:-latest} + container_name: monew-api-prod + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + MongoDB_URI: ${MongoDB_URI} + AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY} + AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY} + AWS_S3_REGION: ${AWS_S3_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + ports: + - "${API_PORT:-8080}:8080" + networks: + - monew-network + restart: always + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + deploy: + resources: + limits: + cpus: '2' + memory: 1536M + reservations: + cpus: '0.5' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Batch Application (Production) + batch: + build: + context: . + dockerfile: Dockerfile.batch + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-batch:${VERSION:-latest} + container_name: monew-batch-prod + environment: + SPRING_PROFILES_ACTIVE: prod + DB_URL: ${DB_URL} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + AWS_S3_ACCESS_KEY: ${AWS_S3_ACCESS_KEY} + AWS_S3_SECRET_KEY: ${AWS_S3_SECRET_KEY} + AWS_S3_REGION: ${AWS_S3_REGION} + AWS_S3_BUCKET: ${AWS_S3_BUCKET} + NAVER_CLIENT_ID: ${NAVER_CLIENT_ID} + NAVER_CLIENT_SECRET: ${NAVER_CLIENT_SECRET} + MONEW_API_URL: ${MONEW_API_URL} + JAVA_OPTS: "-Xmx1g -Xms512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + ports: + - "${BATCH_PORT:-8081}:8081" + depends_on: + - api + networks: + - monew-network + restart: always + deploy: + resources: + limits: + cpus: '2' + memory: 1536M + reservations: + cpus: '0.5' + memory: 512M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + # Monitor Application (Production) + monitor: + build: + context: . + dockerfile: Dockerfile.monitor + args: + - BUILD_DATE=${BUILD_DATE} + - VCS_REF=${VCS_REF} + - VERSION=${VERSION:-latest} + image: monew-monitor:${VERSION:-latest} + container_name: monew-monitor-prod + environment: + SPRING_PROFILES_ACTIVE: prod + JAVA_OPTS: "-Xmx512m -Xms256m -XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0" + ports: + - "${MONITOR_PORT:-8082}:8082" + depends_on: + - api + - batch + networks: + - monew-network + restart: always + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8082/actuator/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + cpus: '1' + memory: 768M + reservations: + cpus: '0.25' + memory: 256M + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Prometheus (Metrics Collector) + prometheus: + image: prom/prometheus:latest + container_name: monew-prometheus + volumes: + - ./monew-monitor/src/main/resources/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + networks: + - monew-network + restart: always + + # Grafana (Visualization) + grafana: + image: grafana/grafana:latest + container_name: monew-grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + ports: + - "3000:3000" + depends_on: + - prometheus + networks: + - monew-network + restart: always + +networks: + monew-network: + driver: bridge \ No newline at end of file diff --git a/monew-api/build.gradle b/monew-api/build.gradle index 270ca6e..b84ba3a 100644 --- a/monew-api/build.gradle +++ b/monew-api/build.gradle @@ -6,13 +6,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' -// implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + implementation 'software.amazon.awssdk:s3:2.31.7' runtimeOnly 'org.postgresql:postgresql' -// runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.h2database:h2' implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + implementation 'org.apache.commons:commons-text:1.10.0' // 유사도 계산용 } \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java index 3e6016a..9c1f9f7 100644 --- a/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java +++ b/monew-api/src/main/java/com/monew/monew_api/MonewApiApplication.java @@ -2,11 +2,18 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; + +import java.util.TimeZone; @SpringBootApplication +@EnableJpaAuditing +@EnableAsync public class MonewApiApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(MonewApiApplication.class, args); } diff --git a/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java new file mode 100644 index 0000000..2128c2a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/controller/ArticleController.java @@ -0,0 +1,118 @@ +package com.monew.monew_api.article.controller; + +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.ArticleSearchRequest; +import com.monew.monew_api.article.dto.ArticleViewDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; +import com.monew.monew_api.article.service.ArticleService; +import com.monew.monew_api.article.service.NewsRestoreService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/articles") +public class ArticleController { + + private final ArticleService articleService; + private final NewsRestoreService newsRestoreService; + + /** + * 기사 조회 기록 등록 + */ + @PostMapping("/{articleId}/article-views") + public ResponseEntity viewArticle( + @PathVariable Long articleId, + @RequestHeader("Monew-Request-User-ID") Long userId + ) { + log.info("[API 요청] POST /api/articles/{}/article-views - 기사 조회 기록 요청, 사용자 ID: {}", articleId, userId); + ArticleViewDto dto = articleService.recordArticleView(articleId, userId); + log.info("[API 응답] POST /api/articles/{}/article-views - 조회 기록 성공, 조회 기록 ID: {}", articleId, dto.getId()); + return ResponseEntity.status(HttpStatus.OK).body(dto); + } + + /** + * 기사 목록 조회 (검색/필터/페이징 포함) + */ + @GetMapping + public ResponseEntity> getArticles( + @Validated @ModelAttribute ArticleSearchRequest request, + @RequestHeader("Monew-Request-User-ID") Long userId + ) { + log.info("[API 요청] GET /api/articles - 기사 목록 조회 요청, 사용자 ID: {}, 키워드: {}, 관심사 ID: {}, 커서: {}, After: {}", + userId, request.getKeyword(), request.getInterestId(), request.getCursor(), request.getAfter()); + CursorPageResponseArticleDto dto = articleService.getArticles(request, userId); + log.info("[API 응답] GET /api/articles - 조회 성공, 반환된 기사 수: {}", dto.getContent().size()); + return ResponseEntity.ok(dto); + } + + /** + * 단일 기사 상세 조회 + */ + @GetMapping("/{articleId}") + public ResponseEntity getArticleById( + @PathVariable Long articleId, + @RequestHeader("Monew-Request-User-ID") Long userId + ) { + log.info("[API 요청] GET /api/articles/{} - 기사 상세 조회 요청, 사용자 ID: {}", articleId, userId); + ArticleDto dto = articleService.findArticle(articleId, userId); + + if (!dto.isViewedByMe()) { + articleService.recordArticleView(articleId, userId); + } + + log.info("[API 응답] GET /api/articles/{} - 기사 상세 조회 성공", articleId); + return ResponseEntity.status(HttpStatus.OK).body(dto); + } + + /** + * 기사 출처 목록 조회 + */ + @GetMapping("/sources") + public ResponseEntity> getSources() { + log.info("[API 요청] GET /api/articles/sources - 뉴스 출처 목록 조회 요청"); + List sources = articleService.getAllSources(); + log.info("[API 응답] GET /api/articles/sources - 뉴스 출처 목록 조회 성공, 개수: {}", sources.size()); + return ResponseEntity.status(HttpStatus.OK).body(sources); + } + + /** + * 기사 논리 삭제 + */ + @DeleteMapping("/{articleId}") + public ResponseEntity deleteArticle(@PathVariable Long articleId) { + log.info("[API 요청] DELETE /api/articles/{} - 기사 논리 삭제 요청", articleId); + articleService.softDeleteArticle(articleId); + log.info("[API 응답] DELETE /api/articles/{} - 기사 논리 삭제 성공", articleId); + return ResponseEntity.noContent().build(); + } + + /** + * 기사 영구 삭제 + */ + @DeleteMapping("/{articleId}/hard") + public ResponseEntity hardDeleteArticle(@PathVariable Long articleId) { + log.info("[API 요청] DELETE /api/articles/{}/hard - 기사 영구 삭제 요청", articleId); + articleService.hardDeleteArticle(articleId); + log.info("[API 응답] DELETE /api/articles/{}/hard - 기사 영구 삭제 성공", articleId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/restore") + public ResponseEntity restoreBackup( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to + ) { + newsRestoreService.restoreArticles(from, to); + return ResponseEntity.ok("복원 완료"); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java new file mode 100644 index 0000000..85ce1ba --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleBackupData.java @@ -0,0 +1,67 @@ +package com.monew.monew_api.article.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.monew.monew_api.article.entity.Article; +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 뉴스 백업 데이터 구조 + * - S3 저장용 DTO + * - 기사 정보 + 연결된 키워드 목록 포함 + */ +@Getter +@Setter +@NoArgsConstructor +public class ArticleBackupData { + + private LocalDateTime backupDate; + private List articles; + + @Getter + @Setter + @NoArgsConstructor + public static class ArticleData { + + private String source; + private String sourceUrl; + private String title; + private LocalDateTime publishDate; + private String summary; + + @JsonProperty("keywords") + private List keywords; + + /** + * QueryProjection 기반 생성자 + * - string_agg 결과 문자열을 List으로 변환 + */ + @QueryProjection + public ArticleData(String source, String sourceUrl, String title, + LocalDateTime publishDate, String summary, String keywordsRaw) { + this.source = source; + this.sourceUrl = sourceUrl; + this.title = title; + this.publishDate = publishDate; + this.summary = summary; + this.keywords = Arrays.stream(Optional.ofNullable(keywordsRaw).orElse("").split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .collect(Collectors.toList()); + } + + /** Entity 변환용 헬퍼 */ + public Article toEntity() { + return new Article(source, sourceUrl, title, publishDate, summary); + } + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java new file mode 100644 index 0000000..284a31c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleDto.java @@ -0,0 +1,44 @@ +package com.monew.monew_api.article.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 단일 기사 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +public class ArticleDto { + + private Long id; // 기사 ID + private String source; // 출처 + private String sourceUrl; // 원본 URL + private String title; // 제목 + private LocalDateTime publishDate; // 발행일 + private String summary; // 요약 + private int commentCount; // 댓글 수 + private int viewCount; // 조회 수 + private boolean viewedByMe; // 내가 조회했는지 여부 + + @QueryProjection + public ArticleDto( + Long id, String source, String sourceUrl, + String title, LocalDateTime publishDate, String summary, + int commentCount, int viewCount, boolean viewedByMe + ) { + this.id = id; + this.source = source; + this.sourceUrl = sourceUrl; + this.title = title; + this.publishDate = publishDate; + this.summary = summary; + this.commentCount = commentCount; + this.viewCount = viewCount; + this.viewedByMe = viewedByMe; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java new file mode 100644 index 0000000..525c1c1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleSearchRequest.java @@ -0,0 +1,51 @@ +package com.monew.monew_api.article.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ArticleSearchRequest { + + @Size(max = 50, message = "검색어(keyword)는 최대 50자까지 입력할 수 있습니다.") + private String keyword; + + private Long interestId; + + private List sourceIn = List.of("Naver"); + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime publishDateFrom = LocalDateTime.now().minusDays(7); + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime publishDateTo = LocalDateTime.now(); + + @Pattern(regexp = "^(publishDate|viewCount|commentCount)$", + message = "정렬 기준(orderBy)은 publishDate, viewCount, commentCount 중 하나여야 합니다.") + private String orderBy = "publishDate"; + + @Pattern(regexp = "^(ASC|DESC)$", + message = "정렬 방향(direction)은 ASC 또는 DESC만 가능합니다.") + private String direction = "DESC"; + + private String cursor; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime after; + + @Min(value = 1, message = "limit은 1 이상이어야 합니다.") + @Max(value = 50, message = "limit은 최대 50까지만 가능합니다.") + private int limit = 10; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java new file mode 100644 index 0000000..8f7069c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/ArticleViewDto.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.article.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 기사 조회 기록 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleViewDto { + + private Long id; // 조회 기록 ID + private Long viewedBy; // 조회한 사용자 ID + private LocalDateTime createdAt; // 조회 시각 + private Long articleId; // 기사 ID + private String source; // 기사 출처 + private String sourceUrl; // 기사 원본 URL + private String articleTitle; // 기사 제목 + private LocalDateTime articlePublishedDate; // 기사 발행일 + private String articleSummary; // 기사 요약 + private int articleCommentCount; // 댓글 수 + private int articleViewCount; // 조회 수 +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java b/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java new file mode 100644 index 0000000..1471503 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/dto/CursorPageResponseArticleDto.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.article.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 커서 기반 페이지 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CursorPageResponseArticleDto { + + private List content; // 페이지 데이터 + private String nextCursor; // 다음 커서 값 + private LocalDateTime nextAfter; // 커서 기준 다음 시각 + private int size; // 요청한 페이지 크기 + private long totalElements; // 전체 데이터 수 + private boolean hasNext; // 다음 페이지 여부 +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java new file mode 100644 index 0000000..9d90d36 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/Article.java @@ -0,0 +1,78 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.common.exception.article.ArticleNotFoundException; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 뉴스 기사 테이블 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "articles") +public class Article extends BaseIdEntity { + + @Column(nullable = false, length = 20) + private String source; + + @Column(name = "source_url", nullable = false, length = 500, unique = true) + private String sourceUrl; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "publish_date", nullable = false) + private LocalDateTime publishDate; + + @Column(nullable = false, length = 200) + private String summary; + + @Column(name = "comment_count", nullable = false) + private int commentCount = 0; + + @Column(name = "view_count", nullable = false) + private int viewCount = 0; + + @Column(name = "is_deleted", nullable = false) + private boolean isDeleted = false; + + @OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestArticles = new ArrayList<>(); + + public Article(String source, String sourceUrl, String title, LocalDateTime publishDate, String summary) { + this.source = source; + this.sourceUrl = sourceUrl; + this.title = title; + this.publishDate = publishDate; + this.summary = summary; + } + + public void softDelete() { + if (this.isDeleted) { + throw new ArticleNotFoundException(); + } + this.isDeleted = true; + } + + public void increaseCommentCount() { + this.commentCount++; + } + + public void decreaseCommentCount() { + if (this.commentCount > 0) { + this.commentCount--; + } + } + + public void increaseViewCount() { + this.viewCount++; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java new file mode 100644 index 0000000..c17d2e2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/ArticleView.java @@ -0,0 +1,32 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseCreatedEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 뉴스 기사 조회 테이블 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Entity +@Table( + name = "article_views", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "article_id"}), + indexes = { + @Index(name = "ix_article_views_user", columnList = "user_id"), + @Index(name = "ix_article_views_article", columnList = "article_id") + } +) +public class ArticleView extends BaseCreatedEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "article_id", nullable = false) + private Long articleId; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java new file mode 100644 index 0000000..73d4b60 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticleKeyword.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.interest.entity.Keyword; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "interest_articles_keywords", + uniqueConstraints = @UniqueConstraint( + name = "uq_interest_articles_keywords", + columnNames = {"interest_article_id", "keyword_id"} + ) +) +public class InterestArticleKeyword extends BaseIdEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_article_id", nullable = false) + private InterestArticles interestArticle; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java new file mode 100644 index 0000000..f13db04 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/entity/InterestArticles.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.article.entity; + +import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.interest.entity.Interest; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * 기사 - 관심사 연결 테이블 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "interest_articles", + uniqueConstraints = @UniqueConstraint(columnNames = {"article_id", "interest_id"}) +) +public class InterestArticles extends BaseIdEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; + + @OneToMany(mappedBy = "interestArticle", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestArticleKeywords = new ArrayList<>(); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java new file mode 100644 index 0000000..f18bcdc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/event/ArticleViewedEvent.java @@ -0,0 +1,61 @@ +package com.monew.monew_api.article.event; + +import java.time.LocalDateTime; + +/** + * 기사 조회 이벤트 + * 사용자가 기사를 조회했을 때 발행 + * Update 전략 사용 + * @param viewId + * @param userId + * @param createdAt + * @param articleId + * @param source + * @param sourceUrl + * @param articleTitle + * @param articlePublishedDate + * @param articleSummary + * @param articleCommentCount + * @param articleViewCount + * @param occurredAt + */ +public record ArticleViewedEvent( + Long viewId, + Long userId, + LocalDateTime createdAt, + Long articleId, + String source, + String sourceUrl, + String articleTitle, + LocalDateTime articlePublishedDate, + String articleSummary, + Integer articleCommentCount, + Integer articleViewCount, + LocalDateTime occurredAt +) { + public static ArticleViewedEvent of( + Long id, + Long userId, + LocalDateTime createdAt, + Long articleId, + String source, + String sourceUrl, + String articleTitle, + LocalDateTime articlePublishedDate, + String articleSummary, + Integer articleCommentCount, + Integer articleViewCount + ) { + return new ArticleViewedEvent( + id, userId, createdAt, articleId, + source, sourceUrl, articleTitle, + articlePublishedDate, articleSummary, + articleCommentCount, articleViewCount, + LocalDateTime.now() + ); + } + + public Integer getDelta() { + return +1; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java new file mode 100644 index 0000000..3b0bda2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleJdbcRepository.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.Article; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ArticleJdbcRepository { + + private final JdbcTemplate jdbcTemplate; + + public boolean insertIgnore(Article article) { + String sql = """ + INSERT INTO articles (source, source_url, title, summary, publish_date, comment_count, view_count, is_deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (source_url) DO NOTHING + """; + + int rows = jdbcTemplate.update(sql, + article.getSource(), + article.getSourceUrl(), + article.getTitle(), + article.getSummary(), + article.getPublishDate(), + article.getCommentCount(), + article.getViewCount(), + article.isDeleted()); + + return rows > 0; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java new file mode 100644 index 0000000..5a296b0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepository.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ArticleQueryRepository { + + CursorPageResponseArticleDto searchArticles( + String keyword, Long interestId, List sourceIn, + LocalDateTime publishDateFrom, LocalDateTime publishDateTo, + String orderBy, String direction, + String cursor, LocalDateTime after, int limit, Long userId + ); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java new file mode 100644 index 0000000..3602c2d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleQueryRepositoryImpl.java @@ -0,0 +1,174 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; +import com.monew.monew_api.article.dto.QArticleDto; +import com.monew.monew_api.article.entity.Article; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.monew.monew_api.article.entity.QArticle.article; +import static com.monew.monew_api.article.entity.QArticleView.articleView; +import static com.monew.monew_api.article.entity.QInterestArticles.interestArticles; + +@Slf4j +@RequiredArgsConstructor +public class ArticleQueryRepositoryImpl implements ArticleQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public CursorPageResponseArticleDto searchArticles( + String keyword, Long interestId, List sourceIn, + LocalDateTime publishDateFrom, LocalDateTime publishDateTo, + String orderBy, String direction, + String cursor, LocalDateTime after, int limit, Long userId + ) { + List articles = queryFactory + .select(new QArticleDto( + article.id, + article.source, + article.sourceUrl, + article.title, + article.publishDate, + article.summary, + article.commentCount, + article.viewCount, + JPAExpressions + .selectOne() + .from(articleView) + .where( + articleView.articleId.eq(article.id) + .and(articleView.userId.eq(userId)) + ) + .exists() + )) + .from(article) + .where( + article.isDeleted.isFalse(), + keywordContains(keyword), + interestEq(interestId), + sourceIn(sourceIn), + publishDateBetween(publishDateFrom, publishDateTo), + cursorCondition(cursor, orderBy, direction) + ) + .orderBy(order(orderBy, direction)) + .limit(limit + 1) + .fetch(); + + boolean hasNext = articles.size() > limit; + if (hasNext) articles.remove(limit); + + ArticleDto last = hasNext ? articles.get(articles.size() - 1) : null; + + return CursorPageResponseArticleDto.builder() + .content(articles) + .nextCursor(last != null ? String.valueOf(last.getId()) : null) + .nextAfter(null) + .size(limit) + .hasNext(hasNext) + .build(); + } + + private BooleanExpression keywordContains(String keyword) { + if (keyword == null || keyword.isBlank()) return null; + return article.title.containsIgnoreCase(keyword) + .or(article.summary.containsIgnoreCase(keyword)); + } + + // 추후 읽기 성능이 중요해진다 생각되면 join으로 변경 가능 + private BooleanExpression interestEq(Long interestId) { + if (interestId == null) return null; + return article.id.in( + JPAExpressions.select(interestArticles.article.id) + .from(interestArticles) + .where(interestArticles.interest.id.eq(interestId)) + ); + } + + private BooleanExpression sourceIn(List sourceIn) { + if (sourceIn == null || sourceIn.isEmpty()) return null; + return article.source.in(sourceIn); + } + + private BooleanExpression publishDateBetween(LocalDateTime from, LocalDateTime to) { + if (from == null || to == null) return null; + return article.publishDate.between(from, to); + } + + // Java 14 이상에서 도입된 Switch Expression 문법 도입 + private OrderSpecifier[] order(String orderBy, String direction) { + boolean asc = "ASC".equalsIgnoreCase(direction); + + return switch (orderBy) { + case "commentCount" -> asc + ? new OrderSpecifier[]{article.commentCount.asc(), article.id.asc()} + : new OrderSpecifier[]{article.commentCount.desc(), article.id.desc()}; + case "viewCount" -> asc + ? new OrderSpecifier[]{article.viewCount.asc(), article.id.asc()} + : new OrderSpecifier[]{article.viewCount.desc(), article.id.desc()}; + default -> asc + ? new OrderSpecifier[]{article.publishDate.asc(), article.id.asc()} + : new OrderSpecifier[]{article.publishDate.desc(), article.id.desc()}; + }; + } + + // after는 사실상 무쓸모, 즉 정렬 기준을 사용해야함. + private BooleanExpression cursorCondition( + String cursor, String orderBy, String direction) { + + if (cursor == null) return null; + + boolean desc = "DESC".equalsIgnoreCase(direction); + Long cursorId = Long.valueOf(cursor); + + Article cursorArticle = queryFactory + .selectFrom(article) + .where(article.id.eq(cursorId)) + .fetchOne(); + + if (cursorArticle == null) return null; + + return switch (orderBy) { + case "commentCount" -> { + int afterComment = cursorArticle.getCommentCount(); + yield desc + ? article.commentCount.lt(afterComment) + .or(article.commentCount.eq(afterComment) + .and(article.id.lt(cursorId))) + : article.commentCount.gt(afterComment) + .or(article.commentCount.eq(afterComment) + .and(article.id.gt(cursorId))); + } + + case "viewCount" -> { + int afterView = cursorArticle.getViewCount(); + yield desc + ? article.viewCount.lt(afterView) + .or(article.viewCount.eq(afterView) + .and(article.id.lt(cursorId))) + : article.viewCount.gt(afterView) + .or(article.viewCount.eq(afterView) + .and(article.id.gt(cursorId))); + } + + default -> { + LocalDateTime afterDate = cursorArticle.getPublishDate(); + yield desc + ? article.publishDate.lt(afterDate) + .or(article.publishDate.eq(afterDate) + .and(article.id.lt(cursorId))) + : article.publishDate.gt(afterDate) + .or(article.publishDate.eq(afterDate) + .and(article.id.gt(cursorId))); + } + }; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java new file mode 100644 index 0000000..0281d99 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleRepository.java @@ -0,0 +1,39 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.Article; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface ArticleRepository extends JpaRepository, ArticleQueryRepository { + + /** 논리 삭제되지 않은 기사 단건 조회 */ + Optional
findByIdAndIsDeletedFalse(Long id); + + /** 기사 출처(source) 중복 없이 조회 */ + @Query("SELECT DISTINCT a.source FROM Article a WHERE a.isDeleted = false") + List findDistinctSources(); + + /** 기사 URL로 중복 여부 확인 (뉴스 중복 방지용) */ + Optional
findBySourceUrl(String sourceUrl); + + /** 여러 기사 논리 삭제 (isDeleted = true) */ + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Article a + SET a.isDeleted = true + WHERE a.id IN :articleIds + """) + void markAsDeleted(@Param("articleIds") List articleIds); + + /** 논리 삭제된 기사 전체 조회 (스케줄러 등에서 사용) */ + List
findAllByIsDeletedTrue(); + + @Query("SELECT a.sourceUrl FROM Article a") + Set findAllSourceUrls(); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java new file mode 100644 index 0000000..509a766 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/ArticleViewRepository.java @@ -0,0 +1,9 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.ArticleView; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleViewRepository extends JpaRepository { + + boolean existsByUserIdAndArticleId(Long userId, Long articleId); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java new file mode 100644 index 0000000..d32ef07 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticleKeywordRepository.java @@ -0,0 +1,50 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.InterestArticleKeyword; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface InterestArticleKeywordRepository extends JpaRepository { + + /** + * 특정 키워드들이 연결된 기사 ID 목록 조회 + */ + @Query(""" + SELECT DISTINCT iak.interestArticle.article.id + FROM InterestArticleKeyword iak + WHERE iak.keyword.id IN :keywordIds + """) + List findArticleIdsByKeywordIds(@Param("keywordIds") List keywordIds); + + /** + * 주어진 키워드가 아닌 다른 키워드나 관심사에서도 + * 동일한 기사가 사용 중인지 확인 + */ + @Query(""" + SELECT DISTINCT iak.interestArticle.article.id + FROM InterestArticleKeyword iak + WHERE iak.interestArticle.article.id IN :articleIds + AND (iak.interestArticle.interest.id <> :interestId + OR iak.keyword.id NOT IN :keywordIds) + """) + List findArticlesUsedElsewhere( + @Param("articleIds") List articleIds, + @Param("keywordIds") List keywordIds, + @Param("interestId") Long interestId + ); + + @Modifying + @Query(value = """ + INSERT INTO interest_articles_keywords (interest_article_id, keyword_id) + VALUES (:interestArticleId, :keywordId) + ON CONFLICT (interest_article_id, keyword_id) DO NOTHING + """, nativeQuery = true) + int insertIgnore( + @Param("interestArticleId") Long interestArticleId, + @Param("keywordId") Long keywordId + ); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java new file mode 100644 index 0000000..15811d5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/repository/InterestArticlesRepository.java @@ -0,0 +1,55 @@ +package com.monew.monew_api.article.repository; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.entity.InterestArticles; +import com.monew.monew_api.interest.entity.Interest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface InterestArticlesRepository extends JpaRepository { + + /** 특정 관심사(interestId)에 연결된 모든 기사 ID 조회 */ + @Query(""" + SELECT ia.article.id + FROM InterestArticles ia + WHERE ia.interest.id = :interestId + """) + List findArticleIdsByInterestId(@Param("interestId") Long interestId); + + /** + * 주어진 기사들(articleIds)이 + * 현재 관심사(interestId)를 제외한 다른 관심사에서도 사용 중인지 확인. + * (즉, “공유된 기사”를 식별하기 위한 쿼리) + */ + @Query(""" + SELECT DISTINCT ia.article.id + FROM InterestArticles ia + WHERE ia.article.id IN :articleIds + AND ia.interest.id <> :interestId + """) + List findArticleIdsUsedByOtherInterests( + @Param("articleIds") List articleIds, + @Param("interestId") Long interestId + ); + + /** 특정 기사와 관심사 간의 연결이 이미 존재하는지 확인 */ + Optional findByArticleAndInterest(Article article, Interest interest); + + @Modifying + @Query( + value = """ + INSERT INTO interest_articles (interest_id, article_id, created_at, updated_at) + VALUES (:interestId, :articleId, NOW(), NOW()) + ON CONFLICT (interest_id, article_id) DO NOTHING + """, + nativeQuery = true + ) + int insertIgnore( + @Param("interestId") Long interestId, + @Param("articleId") Long articleId); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java new file mode 100644 index 0000000..61223c6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/ArticleService.java @@ -0,0 +1,197 @@ +package com.monew.monew_api.article.service; + +import com.monew.monew_api.article.dto.ArticleDto; +import com.monew.monew_api.article.dto.ArticleSearchRequest; +import com.monew.monew_api.article.dto.ArticleViewDto; +import com.monew.monew_api.article.dto.CursorPageResponseArticleDto; +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.entity.ArticleView; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.ArticleViewRepository; +import com.monew.monew_api.common.exception.article.ArticleNotFoundException; +import com.monew.monew_api.article.event.ArticleViewedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ArticleService { + + private final ArticleRepository articleRepository; + private final ArticleViewRepository articleViewRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 기사 조회 기록 등록 + */ + @Transactional + public ArticleViewDto recordArticleView(Long articleId, Long userId) { + log.info("[기사 조회 기록 시도] 기사 ID: {}, 사용자 ID: {}", articleId, userId); + + if (articleViewRepository.existsByUserIdAndArticleId(userId, articleId)) { + log.warn("[조회 기록 실패] 이미 조회한 기사입니다. 사용자 ID: {}, 기사 ID: {}", userId, articleId); + + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(ArticleNotFoundException::new); + + return ArticleViewDto.builder() + .id(null) + .viewedBy(userId) + .createdAt(LocalDateTime.now()) + .articleId(articleId) + .source(article.getSource()) + .sourceUrl(article.getSourceUrl()) + .articleTitle(article.getTitle()) + .articlePublishedDate(article.getPublishDate()) + .articleSummary(article.getSummary()) + .articleCommentCount(article.getCommentCount()) + .articleViewCount(article.getViewCount()) + .build(); + } + + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(() -> { + log.warn("[조회 기록 실패] 존재하지 않는 기사: {}", articleId); + return new ArticleNotFoundException(); + }); + + ArticleView articleView = new ArticleView(userId, articleId); + ArticleView saved = articleViewRepository.save(articleView); + article.increaseViewCount(); + eventPublisher.publishEvent( + ArticleViewedEvent.of( + saved.getId(), + saved.getUserId(), + saved.getCreatedAt(), + saved.getArticleId(), + article.getSource(), + article.getSourceUrl(), + article.getTitle(), + article.getPublishDate(), + article.getSummary(), + article.getCommentCount(), + article.getViewCount())); + log.info("[조회 기록 성공] 기사 ID: {}, 사용자 ID: {}", articleId, userId); + + return ArticleViewDto.builder() + .id(saved.getId()) + .viewedBy(userId) + .createdAt(saved.getCreatedAt()) + .articleId(articleId) + .source(article.getSource()) + .sourceUrl(article.getSourceUrl()) + .articleTitle(article.getTitle()) + .articlePublishedDate(article.getPublishDate()) + .articleSummary(article.getSummary()) + .articleCommentCount(article.getCommentCount()) + .articleViewCount(article.getViewCount() + 1) + .build(); + } + + /** + * 기사 목록 조회 (검색/필터/페이징 포함) + */ + public CursorPageResponseArticleDto getArticles(ArticleSearchRequest request, Long userId) { + String keyword = request.getKeyword(); + Long interestId = request.getInterestId(); + + if ((keyword == null || keyword.isBlank()) && interestId == null) { + interestId = 1L; + } else if (keyword != null && !keyword.isBlank() && interestId != null) { + keyword = null; + } + + log.info("[기사 목록 조회] 사용자 ID: {}, 키워드: {}, 관심사 ID: {}", userId, keyword, interestId); + + CursorPageResponseArticleDto result = articleRepository.searchArticles( + keyword, interestId, request.getSourceIn(), + request.getPublishDateFrom(), request.getPublishDateTo(), + request.getOrderBy(), request.getDirection(), + request.getCursor(), request.getAfter(), request.getLimit(), userId + ); + + log.info("[기사 목록 조회 완료] 조회된 기사 수: {}, 커서: {}, After: {}", + result.getContent().size(), result.getNextCursor(), result.getNextAfter()); + return result; + } + + /** + * 단일 기사 상세 조회 + */ + public ArticleDto findArticle(Long articleId, Long userId) { + log.info("[기사 상세 조회 시도] 기사 ID: {}, 사용자 ID: {}", articleId, userId); + + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(() -> { + log.warn("[기사 상세 조회 실패] 존재하지 않는 기사: {}", articleId); + return new ArticleNotFoundException(); + }); + + boolean viewedByMe = articleViewRepository.existsByUserIdAndArticleId(userId, articleId); + log.debug("[기사 상세 조회 성공] 기사 ID: {}, 사용자 ID: {}, 조회 여부: {}", articleId, userId, viewedByMe); + + return ArticleDto.builder() + .id(article.getId()) + .source(article.getSource()) + .sourceUrl(article.getSourceUrl()) + .title(article.getTitle()) + .publishDate(article.getPublishDate()) + .summary(article.getSummary()) + .viewCount(article.getViewCount()) + .viewedByMe(viewedByMe) + .build(); + } + + /** + * 전체 뉴스 소스 목록 조회 + */ + public List getAllSources() { + log.info("[뉴스 출처 목록 조회]"); + List sources = articleRepository.findDistinctSources(); + + sources.sort((a, b) -> { + if (a.equalsIgnoreCase("Naver")) return -1; + if (b.equalsIgnoreCase("Naver")) return 1; + return a.compareToIgnoreCase(b); + }); + + log.debug("[뉴스 출처 조회 완료] 출처 개수: {}, 정렬 결과: {}", sources.size(), sources); + return sources; + } + + /** + * 기사 논리 삭제 + */ + @Transactional + public void softDeleteArticle(Long articleId) { + log.info("[기사 논리 삭제 시도] 기사 ID: {}", articleId); + + Article article = articleRepository.findByIdAndIsDeletedFalse(articleId) + .orElseThrow(() -> { + log.warn("[논리 삭제 실패] 존재하지 않는 기사: {}", articleId); + return new ArticleNotFoundException(); + }); + + article.softDelete(); + log.info("[논리 삭제 성공] 기사 ID: {}", articleId); + } + + /** + * 기사 영구 삭제 + */ + @Transactional + public void hardDeleteArticle(Long articleId) { + log.info("[기사 영구 삭제 시도] 기사 ID: {}", articleId); + + articleRepository.deleteById(articleId); + log.warn("[기사 영구 삭제 완료] 기사 ID: {}", articleId); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java new file mode 100644 index 0000000..a9c6284 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/article/service/NewsRestoreService.java @@ -0,0 +1,170 @@ +package com.monew.monew_api.article.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.article.dto.ArticleBackupData; +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.repository.*; +import com.monew.monew_api.common.entity.BaseIdEntity; +import com.monew.monew_api.common.exception.article.ArticleNotFoundException; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.interest.repository.KeywordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.S3Object; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsRestoreService { + + private final S3Client s3Client; + private final ObjectMapper objectMapper; + private final ArticleRepository articleRepository; + private final ArticleJdbcRepository articleJdbcRepository; + private final KeywordRepository keywordRepository; + private final InterestRepository interestRepository; + private final InterestArticlesRepository interestArticlesRepository; + private final InterestArticleKeywordRepository interestArticleKeywordRepository; + + @Value("${aws.bucket}") + private String bucketName; + + private static final String PREFIX = "backup/article_backup_"; + private static final DateTimeFormatter FILE_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"); + + /** 메인 진입점 */ + @Transactional + public void restoreArticles(LocalDateTime from, LocalDateTime to) { + long start = System.currentTimeMillis(); + log.info("🗃 복원 시작: {} ~ {}", from, to); + + try { + // 1. S3에서 파일 목록 가져오기 + List fileKeys = getBackupFileKeys(from, to); + if (fileKeys.isEmpty()) return; + + // 2. 여러 백업 파일 병합 + List mergedArticles = mergeBackupData(fileKeys); + if (mergedArticles.isEmpty()) return; + + // 3. 이미 존재하는 기사 제외 + List newArticles = filterExistingArticles(mergedArticles); + if (newArticles.isEmpty()) return; + + log.info("📰 신규 기사 {}건 복원 시도", newArticles.size()); + + // 4. 기사 단위 복원 + int restored = 0, skipped = 0; + + for (ArticleBackupData.ArticleData data : newArticles) { + boolean success = restoreSingleArticleExact(data); + if (success) restored++; + else skipped++; + } + + log.info("✅ 복원 완료 | 성공: {}건, 스킵: {}건", restored, skipped); + + } finally { + long end = System.currentTimeMillis(); + log.info("⏰ 복원 종료: 총 {}초 소요", (end - start) / 1000.0); + } + } + + /** 지정된 기간의 S3 백업 파일 목록 조회 */ + private List getBackupFileKeys(LocalDateTime from, LocalDateTime to) { + List keys = s3Client.listObjectsV2(b -> b.bucket(bucketName).prefix("backup/")) + .contents().stream() + .map(S3Object::key) + .filter(k -> k.startsWith(PREFIX)) + .filter(k -> { + LocalDateTime date = parseDateFromKey(k); + return !date.isBefore(from) && !date.isAfter(to); + }) + .toList(); + + if (keys.isEmpty()) log.info("📂 복원할 백업 파일이 없습니다."); + return keys; + } + + /** 파일명에서 날짜 추출 */ + private LocalDateTime parseDateFromKey(String key) { + try { + String datePart = key.replace(PREFIX, "").replace(".json", ""); + return LocalDateTime.parse(datePart, FILE_DATE_FORMAT); + } catch (Exception e) { + return LocalDateTime.MIN; + } + } + + /** 여러 백업 파일 병합 */ + private List mergeBackupData(List keys) { + Map merged = new LinkedHashMap<>(); + + for (String key : keys) { + try { + String json = s3Client.getObjectAsBytes(b -> b.bucket(bucketName).key(key)).asUtf8String(); + ArticleBackupData backup = objectMapper.readValue(json, ArticleBackupData.class); + backup.getArticles().forEach(a -> merged.putIfAbsent(a.getSourceUrl(), a)); + } catch (Exception e) { + log.error("⚠️ 백업 파일 로드 실패: {}", key, e); + } + } + + if (merged.isEmpty()) log.info("📄 병합된 복원 대상이 없습니다."); + return new ArrayList<>(merged.values()); + } + + /** 이미 존재하는 기사 제외 */ + private List filterExistingArticles(List articles) { + Set existingUrls = articleRepository.findAllSourceUrls(); + return articles.stream() + .filter(a -> !existingUrls.contains(a.getSourceUrl())) + .toList(); + } + + /** 기사 복원 (Writer 시점과 동일하되 insertIgnore 적용) */ + private boolean restoreSingleArticleExact(ArticleBackupData.ArticleData data) { + try { + boolean inserted = articleJdbcRepository.insertIgnore(data.toEntity()); + if (!inserted) return false; + + // insertIgnore은 영속성 컨텍스으에 반영안됌 -> id로 조회 못함 + Article article = articleRepository.findBySourceUrl(data.getSourceUrl()) + .orElseThrow(ArticleNotFoundException::new); + + List keywords = data.getKeywords(); + for (String keywordName : keywords) { + keywordRepository.findByKeyword(keywordName).ifPresent(keyword -> { + List interests = interestRepository.findAllByKeyword(keyword); + for (Interest interest : interests) { + int result = interestArticlesRepository.insertIgnore(interest.getId(), article.getId()); + if (result > 0) { + log.info("🔗 [{}] 관심사-기사 연결 완료: {}", interest.getName(), article.getTitle()); + } + + interestArticleKeywordRepository.insertIgnore( + interestArticlesRepository.findByArticleAndInterest(article, interest) + .map(BaseIdEntity::getId) + .orElseThrow(), + keyword.getId() + ); + } + }); + } + return true; + } catch (Exception e) { + log.error("⚠️ 기사 [{}] 복원 실패: {}", data.getTitle(), e.getMessage()); + return false; + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java new file mode 100644 index 0000000..9a632e0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/controller/CommentController.java @@ -0,0 +1,122 @@ +package com.monew.monew_api.comments.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.monew.monew_api.comments.dto.CommentDto; +import com.monew.monew_api.comments.dto.CommentLikeDto; +import com.monew.monew_api.comments.dto.CommentRegisterRequest; +import com.monew.monew_api.comments.dto.CommentSearchRequest; +import com.monew.monew_api.comments.dto.CommentUpdateRequest; +import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; +import com.monew.monew_api.comments.service.CommentService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/comments") +@RequiredArgsConstructor +@Validated +public class CommentController { + + private static final String REQUEST_HEADER_USER_ID = "MoNew-Request-User-ID"; + private final CommentService commentService; + + // 댓글 조회 + @GetMapping + public ResponseEntity findAll( + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @ModelAttribute CommentSearchRequest request + ) { + log.info("[CommentController] GET /api/comments - userId={}, request={}", userId, request); + CursorPageResponseCommentDto response = commentService.findAll(userId, request); + return ResponseEntity.ok(response); + } + + // 댓글 작성 + @PostMapping + public ResponseEntity register( + @Valid @RequestBody CommentRegisterRequest request + ) { + log.info("[CommentController] POST /api/comments - register request={}", request); + CommentDto dto = commentService.register(request); + log.info("[CommentController] POST /api/comments - created commentId={}", dto.id()); + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } + + // 댓글 수정 + @PatchMapping("/{commentId}") + public ResponseEntity update( + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request + ) { + log.info("[CommentController] PATCH /api/comments/{} - userId={}, request={}", commentId, userId, request); + CommentDto dto = commentService.update(userId, commentId, request); + log.info("[CommentController] PATCH /api/comments/{} - updated", commentId); + return ResponseEntity.ok(dto); + } + + // 댓글 좋아요 + @PostMapping("/{commentId}/comment-likes") + public ResponseEntity like( + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @PathVariable Long commentId + ) { + log.info("[CommentController] POST /api/comments/{}/comment-likes - like request, userId={}" + , commentId, userId); + CommentLikeDto dto = commentService.like(userId, commentId); + log.info("[CommentController] POST /api/comments/{}/comment-likes - like success, likeId={}" + , commentId, dto.id()); + return ResponseEntity.ok(dto); + } + + // 댓글 좋아요 삭제 + @DeleteMapping("/{commentId}/comment-likes") + public ResponseEntity dislike( + @RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @PathVariable Long commentId + ) { + log.info("[CommentController] DELETE /api/comments/{}/comment-likes - dislike request, userId={}" + , commentId, userId); + commentService.dislike(userId, commentId); + log.info("[CommentController] DELETE /api/comments/{}/comment-likes - dislike success" + , commentId); + return ResponseEntity.noContent().build(); + } + + // 댓글 논리 삭제 + @DeleteMapping("/{commentId}") + public ResponseEntity delete( + @PathVariable Long commentId + ) { + log.info("[CommentController] DELETE /api/comments/{} - soft delete request", commentId); + commentService.delete(commentId); + log.info("[CommentController] DELETE /api/comments/{} - soft delete success", commentId); + return ResponseEntity.noContent().build(); + } + + // 댓글 물리 삭제 + @DeleteMapping("/{commentId}/hard") + public ResponseEntity hardDelete( + @PathVariable Long commentId) { + log.info("[CommentController] DELETE /api/comments/{}/hard - hard delete request", commentId); + commentService.hardDelete(commentId); + log.info("[CommentController] DELETE /api/comments/{}/hard - hard delete success", commentId); + return ResponseEntity.noContent().build(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java new file mode 100644 index 0000000..5a37d4c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentDto.java @@ -0,0 +1,32 @@ +package com.monew.monew_api.comments.dto; + +import com.monew.monew_api.comments.entity.Comment; +import com.querydsl.core.annotations.QueryProjection; + +public record CommentDto( + Long id, + Long userId, + Long articleId, + String userNickname, + String content, + int likeCount, + boolean likedByMe, + String createdAt +) { + @QueryProjection // QCommentDto를 생성 + public CommentDto { + } + public static CommentDto from(Comment comment, boolean likedByMe) { + return new CommentDto( + comment.getId(), + comment.getUser().getId(), + comment.getArticle().getId(), + comment.getUser().getNickname(), + comment.getContent(), + comment.getLikeCount(), + likedByMe, + comment.getCreatedAt().toString() + ); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java new file mode 100644 index 0000000..abf9b20 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentLikeDto.java @@ -0,0 +1,34 @@ +package com.monew.monew_api.comments.dto; + +import java.time.LocalDateTime; + +import com.monew.monew_api.comments.entity.CommentLike; + +public record CommentLikeDto( + Long id, + Long commentId, + Long articleId, + Long likedBy, + Long commentUserId, + String commentUserNickname, + String commentContent, + int commentLikeCount, + String commentCreatedAt, + String createdAt +) { + + public static CommentLikeDto from(CommentLike like) { + return new CommentLikeDto( + like.getId(), + like.getComment().getId(), + like.getComment().getArticleId(), + like.getUser().getId(), + like.getComment().getUserId(), + like.getComment().getUser().getNickname(), + like.getComment().getContent(), + like.getComment().getLikeCount(), + like.getComment().getCreatedAt().toString(), + like.getCreatedAt().toString() + ); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java new file mode 100644 index 0000000..3a0b76a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentRegisterRequest.java @@ -0,0 +1,18 @@ +package com.monew.monew_api.comments.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CommentRegisterRequest( + @NotNull(message = "기사 ID는 필수입니다.") + Long articleId, + + @NotNull(message = "유저 ID는 필수입니다.") + Long userId, + + @NotBlank(message = "댓글 내용을 입력해주세요.") + @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") + String content +) { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java new file mode 100644 index 0000000..de1374f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentSearchRequest.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.comments.dto; + +import java.time.LocalDateTime; + +import org.springframework.format.annotation.DateTimeFormat; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CommentSearchRequest { + + private Long articleId; + + @Pattern(regexp = "createdAt|likeCount", message = "orderBy는 'createdAt' 또는 'likeCount'만 가능합니다.") + private String orderBy = "createdAt"; + + private String direction = "DESC"; + + private String cursor; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private LocalDateTime after; + + @Min(value = 1, message = "limit은 1 이상이어야 합니다.") + @Max(value = 50, message = "limit은 최대 50까지만 가능합니다.") + private int limit = 10; + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..744ba53 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CommentUpdateRequest.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.comments.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + @NotBlank(message = "댓글 내용을 입력해주세요.") + @Size(max = 500, message = "댓글은 최대 500자까지 작성 가능합니다.") + String content +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java new file mode 100644 index 0000000..8216b1b --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/dto/CursorPageResponseCommentDto.java @@ -0,0 +1,14 @@ +package com.monew.monew_api.comments.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +public record CursorPageResponseCommentDto( + List content, + String nextCursor, + ZonedDateTime nextAfter, + int size, + long totalElements, + boolean hasNext +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java new file mode 100644 index 0000000..5a8889e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/Comment.java @@ -0,0 +1,89 @@ +package com.monew.monew_api.comments.entity; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.user.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "comments", + indexes = { + @Index(name = "ix_comments_user", columnList = "user_id"), + @Index(name = "ix_comments_article", columnList = "article_id") + } +) +@SQLDelete(sql = "UPDATE comments SET is_deleted = true, updated_at = now() WHERE id = ?") +@Where(clause = "is_deleted = false") +public class Comment extends BaseTimeEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @Size(max = 500) + @NotBlank + @Column(name = "content", nullable = false, length = 500) + private String content; + + @Column(name = "is_deleted", nullable = false) + private boolean deleted = false; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + private Comment(User user, Article article, String content) { + this.user = user; + this.article = article; + this.content = content; + } + + public static Comment of(User user, Article article, String content) { + return new Comment(user, article, content); + } + + public void updateContent(String content) { + this.content = content; + } + + public void increaseLike() { + this.likeCount++; + } + + public void decreaseLike() { + if (this.likeCount > 0) this.likeCount--; + } + + public boolean isOwnedBy(Long userId) { + return this.user != null && this.user.getId().equals(userId); + } + + public Long getUserId() { + return user != null ? user.getId() : null; + } + + public Long getArticleId() { + return article != null ? article.getId() : null; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java new file mode 100644 index 0000000..4312e7c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/entity/CommentLike.java @@ -0,0 +1,57 @@ +package com.monew.monew_api.comments.entity; + +import com.monew.monew_api.common.entity.BaseCreatedEntity; +import com.monew.monew_api.user.User; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "comment_likes", + uniqueConstraints = { + @UniqueConstraint(name = "uq_comment_likes", columnNames = {"user_id", "comment_id"}) + }, + indexes = { + @Index(name = "ix_comment_likes_user", columnList = "user_id"), + @Index(name = "ix_comment_likes_comment", columnList = "comment_id") + } + +) +public class CommentLike extends BaseCreatedEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="user_id", nullable=false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="comment_id", nullable=false) + private Comment comment; + + private CommentLike(User user, Comment comment) { + this.user = user; + this.comment = comment; + } + + public static CommentLike of(User user, Comment comment) { + return new CommentLike(user, comment); + } + + public boolean isByUser(Long userId) { + return user != null && user.getId().equals(userId); + } + + public boolean isForComment(Long commentId) { + return comment != null && comment.getId().equals(commentId); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java new file mode 100644 index 0000000..bfe89bc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentContentEditedEvent.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +public record CommentContentEditedEvent( + Long commentId, + String newContent, + LocalDateTime occurredAt +) { + public static CommentContentEditedEvent of(Long commentId, String newContent) { + return new CommentContentEditedEvent(commentId, newContent, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java new file mode 100644 index 0000000..4705aa3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentCreatedEvent.java @@ -0,0 +1,58 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 작성 이벤트 + * 사용자가 기사에 댓글을 작성했을 때 발행 + * 기사의 commentCount +1 + * Update 전략 사용 + * @param commentId + * @param articleId + * @param articleTitle + * @param userId + * @param userNickname + * @param content + * @param likeCount + * @param createdAt + * @param occurredAt + */ +public record CommentCreatedEvent( + Long commentId, + Long articleId, + String articleTitle, + Long userId, + String userNickname, + String content, + Integer likeCount, + LocalDateTime createdAt, + LocalDateTime occurredAt +) { + + public static CommentCreatedEvent of( + Long commentId, + Long articleId, + String articleTitle, + Long userId, + String userNickname, + String content, + Integer likeCount, + LocalDateTime createdAt + ) { + return new CommentCreatedEvent( + commentId, + articleId, + articleTitle, + userId, + userNickname, + content, + likeCount, + createdAt, + LocalDateTime.now() + ); + } + + public Integer getDelta() { + return 1; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java new file mode 100644 index 0000000..260a045 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentDeletedEvent.java @@ -0,0 +1,18 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 삭제 이벤트 + * 댓글이 삭제되었을 때 발행 + * @param commentId + * @param occurredAt + */ +public record CommentDeletedEvent( + Long commentId, + LocalDateTime occurredAt +) { + public static CommentDeletedEvent of(Long commentId) { + return new CommentDeletedEvent(commentId, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java new file mode 100644 index 0000000..1fd8f3a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentLikedEvent.java @@ -0,0 +1,59 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 좋아요/취소 이벤트 + * 사용자가 댓글에 좋아요를 누르거나 취소했을 때 발행 + * Update 전략 사용 + * @param likeId + * @param likeCreatedAt + * @param commentId + * @param articleId + * @param articleTitle + * @param commentAuthorId + * @param commentUserNickname + * @param commentContent + * @param commentLikeCount + * @param commentCreatedAt + * @param likedByUserId + * @param likerNickname + * @param occurredAt + */ +public record CommentLikedEvent( + Long likeId, + LocalDateTime likeCreatedAt, + Long commentId, + Long articleId, + String articleTitle, + Long commentAuthorId, + String commentUserNickname, + String commentContent, + Integer commentLikeCount, + LocalDateTime commentCreatedAt, + Long likedByUserId, + String likerNickname, + LocalDateTime occurredAt +) { + public static CommentLikedEvent of( + Long likeId, + LocalDateTime likeCreatedAt, + Long commentId, + Long articleId, + String articleTitle, + Long commentAuthorId, + String commentUserNickname, + String commentContent, + Integer commentLikeCount, + LocalDateTime commentCreatedAt, + Long likedByUserId, + String likerNickname + ) { + return new CommentLikedEvent( + likeId, likeCreatedAt, commentId, articleId, articleTitle, + commentAuthorId, commentUserNickname, commentContent, + commentLikeCount, commentCreatedAt, likedByUserId, likerNickname, + LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java new file mode 100644 index 0000000..9d0a731 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/event/CommentUnlikedEvent.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.comments.event; + +import java.time.LocalDateTime; + +/** + * 댓글 좋아요 취소 이벤트 + * 사용자가 댓글 좋아요를 취소했을 때 발행 + * @param commentId + * @param likedByUserId + * @param occurredAt + */ +public record CommentUnlikedEvent( + Long commentId, + Long likedByUserId, + LocalDateTime occurredAt +) { + public static CommentUnlikedEvent of(Long commentId, Long likedByUserId) { + return new CommentUnlikedEvent(commentId, likedByUserId, LocalDateTime.now()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java new file mode 100644 index 0000000..544a1e1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentLikeRepository.java @@ -0,0 +1,18 @@ +package com.monew.monew_api.comments.repository; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.monew.monew_api.comments.entity.CommentLike; + +public interface CommentLikeRepository extends JpaRepository { + + // 좋아요 중복 확인 + boolean existsByComment_IdAndUser_Id(Long commentId, Long userId); + + // 자신 좋아요 취소 + void deleteByComment_IdAndUser_Id(Long commentId, Long userId); + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java new file mode 100644 index 0000000..6c7fb9a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepository.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.comments.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.monew.monew_api.comments.entity.Comment; + +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + + // 좋아요 취소 + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Comment c + set c.likeCount = case when c.likeCount > 0 then c.likeCount - 1 else 0 end + where c.id = :id + """) + int decLikeCount(@Param("id") Long id); + + // 댓글 물리 삭제 + @Modifying + @Query(value = "DELETE FROM comments WHERE id = :id AND is_deleted = true", nativeQuery = true) + int hardDeleteById(@Param("id") Long id); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java new file mode 100644 index 0000000..2a507f8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/CommentRepositoryCustom.java @@ -0,0 +1,18 @@ +package com.monew.monew_api.comments.repository; + +import java.time.LocalDateTime; + +import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; + +public interface CommentRepositoryCustom { + + // 댓글 조회 + CursorPageResponseCommentDto searchComments( + Long articleId, + String orderBy, + String cursor, + LocalDateTime after, + int limit, + Long userId + ); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java new file mode 100644 index 0000000..61c23dc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/repository/impl/CommentRepositoryImpl.java @@ -0,0 +1,191 @@ +package com.monew.monew_api.comments.repository.impl; + +import static com.monew.monew_api.comments.entity.QComment.*; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.monew.monew_api.comments.dto.CommentDto; +import com.monew.monew_api.comments.dto.CursorPageResponseCommentDto; +import com.monew.monew_api.comments.dto.QCommentDto; +import com.monew.monew_api.comments.entity.QComment; +import com.monew.monew_api.comments.entity.QCommentLike; +import com.monew.monew_api.comments.repository.CommentRepositoryCustom; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public CursorPageResponseCommentDto searchComments( + Long articleId, String orderBy, String cursor, + LocalDateTime after, int limit, Long userId) { + + QCommentLike cl = QCommentLike.commentLike; + + // DTO로 바로 조회 (서브쿼리로 좋아요 여부 포함) + List comments = jpaQueryFactory + .select(new QCommentDto( + comment.id, + comment.user.id, + comment.article.id, + comment.user.nickname, + comment.content, + comment.likeCount, + JPAExpressions // 좋아요 여부 확인하는 서브쿼리 + .selectOne() + .from(cl) + .where( + cl.comment.id.eq(comment.id) + .and(cl.user.id.eq(userId)) + ) + .exists(), + comment.createdAt.stringValue() + )) + .from(comment) + .where( + articleIdEq(articleId), + cursorCondition(orderBy, cursor, after) + ) + .orderBy(orderSpecifiers(comment, orderBy)) + .limit(limit + 1L) + .fetch(); + + log.info("[조회 결과] 총 {}개 댓글 조회됨", comments.size()); + + // hasNext 계산 + boolean hasNext = comments.size() > limit; + if (hasNext) { + comments.remove(limit); + } + + // nextCursor, nextAfter 생성 + String nextCursor = null; + ZonedDateTime nextAfter = null; + + if (hasNext && !comments.isEmpty()) { + CommentDto last = comments.get(comments.size() - 1); + + if ("likeCount".equalsIgnoreCase(orderBy)) { + nextCursor = last.likeCount() + ":" + last.id(); + } else { + nextCursor = String.valueOf(last.id()); + } + + // after는 입력받은 값을 그대로 유지 (시간 필터 고정) + nextAfter = after != null ? after.atZone(ZoneId.systemDefault()) : null; + + } + + return new CursorPageResponseCommentDto( + comments, + nextCursor, + nextAfter, + comments.size(), + -1L, + hasNext + ); + } + + // 커서 조건 분배(최신순, 인기순) + private BooleanExpression cursorCondition(String orderBy, String cursor, LocalDateTime after) { + if ("likeCount".equalsIgnoreCase(orderBy)) { + return buildLikeCountCursor(cursor, after); + } else { + return buildCreatedAtCursor(cursor, after); + } + } + + // createdAt 기준 커서 조건 (최신순) + private BooleanExpression buildCreatedAtCursor( + String cursor, LocalDateTime after + ) { + Long cursorId = parseLongCursor(cursor); + // 둘 다 없으면 조건 없음 + if (cursorId == null && after == null) { + return null; + } + + // cursor만 있을 때 + if (cursorId != null && after == null) { + return comment.id.lt(cursorId); + } + + // after만 있을 때 + if (cursorId == null) { + return comment.createdAt.lt(after); + } + + // 둘 다 있을 때 + return comment.createdAt.lt(after) + .or( + comment.createdAt.eq(after) + .and(comment.id.lt(cursorId)) + ); + } + + // likeCount 기준 커서 조건 + private BooleanExpression buildLikeCountCursor(String cursor, LocalDateTime after) { + + if (cursor == null || cursor.isBlank()) { + return null; + } + + String[] parts = cursor.split(":"); + if (parts.length != 2) { + return null; + } + + try { + Integer likeCount = Integer.parseInt(parts[0]); + Long id = Long.parseLong(parts[1]); + + return comment.likeCount.lt(likeCount) + .or( + comment.likeCount.eq(likeCount) + .and(comment.id.lt(id)) + ); + } catch (NumberFormatException e) { + return null; + } + } + + // Long 타입 커서 파싱 + private Long parseLongCursor(String cursor) { + if (cursor == null || cursor.isBlank()) { + return null; + } + try { + return Long.parseLong(cursor); + } catch (NumberFormatException e) { + return null; + } + } + + // 게시글 필터 조건 + private BooleanExpression articleIdEq(Long articleId) { + return articleId == null ? null : comment.article.id.eq(articleId); + } + + // 정렬 컬럼 선택 + private OrderSpecifier[] orderSpecifiers(QComment c, String orderBy) { + if ("likeCount".equalsIgnoreCase(orderBy)) { + return new OrderSpecifier[] {c.likeCount.desc(), c.id.desc()}; + } + return new OrderSpecifier[] {c.createdAt.desc(), c.id.desc()}; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java new file mode 100644 index 0000000..db96bc2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/comments/service/CommentService.java @@ -0,0 +1,201 @@ +package com.monew.monew_api.comments.service; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.comments.dto.*; +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.comments.event.*; +import com.monew.monew_api.comments.repository.CommentLikeRepository; +import com.monew.monew_api.comments.repository.CommentRepository; +import com.monew.monew_api.common.exception.comment.*; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final UserRepository userRepository; + private final ArticleRepository articleRepository; + private final ApplicationEventPublisher eventPublisher; + + // 댓글 작성 + @Transactional + public CommentDto register(CommentRegisterRequest request) { + log.info("[COMMENT][CREATE][START] userId={}, articleId={}", request.userId(), request.articleId()); + User user = getUserById(request.userId()); + Article article = getArticleById(request.articleId()); + + log.info("[COMMENT_COUNT] 댓글 작성 전 카운트: {}", article.getCommentCount()); + Comment saved = commentRepository.save(Comment.of(user, article, request.content())); + log.info("[COMMENT][CREATE] userId={}, articleId={}, commentId={}", + user.getId(), article.getId(), saved.getId()); + + article.increaseCommentCount(); + articleRepository.save(article); + + eventPublisher.publishEvent( + CommentCreatedEvent.of( + saved.getId(), + saved.getArticleId(), + article.getTitle(), + saved.getUserId(), + user.getNickname(), + saved.getContent(), + saved.getLikeCount(), + saved.getCreatedAt()) + ); + + log.info("[COMMENT_COUNT] 댓글 작성 후 카운트: {}", article.getCommentCount()); + return CommentDto.from(saved, false); + } + + // 댓글 수정 + @Transactional + public CommentDto update(Long userId, Long commentId, CommentUpdateRequest request) { + log.info("[COMMENT][UPDATE][START] userId={}, commentId={}", userId, commentId); + Comment comment = getCommentById(commentId); + validateOwnership(comment, userId); + + comment.updateContent(request.content()); + log.info("[COMMENT][UPDATE] userId={}, commentId={}, contentLength={}", + userId, commentId, request.content().length()); + + boolean likedByMe = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); + + eventPublisher.publishEvent(CommentContentEditedEvent.of(commentId, request.content())); + + return CommentDto.from(comment, likedByMe); + } + + // 댓글 좋아요 + @Transactional + public CommentLikeDto like(Long userId, Long commentId) { + log.info("[COMMENT][LIKE] 좋아요 요청 시작 - userId={}, commentId={}", userId, commentId); + User user = getUserById(userId); + Comment comment = getCommentById(commentId); + log.info("[COMMENT][LIKE] 엔티티 조회 완료 - user={}, comment={}", user.getId(), comment.getId()); + CommentLike saved = commentLikeRepository.save(CommentLike.of(user, comment)); + + eventPublisher.publishEvent( + CommentLikedEvent.of( + saved.getId(), + saved.getCreatedAt(), + commentId, + comment.getArticle().getId(), + comment.getArticle().getTitle(), + comment.getUserId(), + comment.getUser().getNickname(), + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt(), + user.getId(), + user.getNickname())); + + comment.increaseLike(); + log.info("[COMMENT][LIKE] userId={}, commentId={}", userId, commentId); + return CommentLikeDto.from(saved); + } + + // 댓글 좋아요 삭제 + @Transactional + public void dislike(Long userId, Long commentId) { + log.info("[COMMENT][DISLIKE][START] userId={}, commentId={}", userId, commentId); + boolean liked = commentLikeRepository.existsByComment_IdAndUser_Id(commentId, userId); + if (!liked) + throw new CommentNotLikedException(); + + commentLikeRepository.deleteByComment_IdAndUser_Id(commentId, userId); + commentRepository.decLikeCount(commentId); + + eventPublisher.publishEvent(CommentUnlikedEvent.of(commentId, userId)); + + log.info("[COMMENT][DISLIKE] userId={}, commentId={}", userId, commentId); + } + + // 댓글 논리 삭제 + @Transactional + public void delete(Long commentId) { + log.info("[COMMENT][DELETE][START] commentId={}", commentId); + Comment comment = getCommentById(commentId); + + Article article = comment.getArticle(); + log.info("[COMMENT_COUNT] 댓글 삭제 전 카운트: {}", article.getCommentCount()); + + commentRepository.delete(comment); + + article.decreaseCommentCount(); + articleRepository.save(article); + eventPublisher.publishEvent(CommentDeletedEvent.of(commentId)); + log.info("[COMMENT_COUNT] 댓글 삭제 후 카운트: {}", article.getCommentCount()); + log.info("[COMMENT][DELETE] commentId={}", commentId); + } + + // 댓글 물리 삭제 + @Transactional + public void hardDelete(Long commentId) { + log.info("[COMMENT][HARD_DELETE][START] commentId={}", commentId); + int deletedCount = commentRepository.hardDeleteById(commentId); + // 0 = 실패, 1 = 성공 + if (deletedCount == 0) { + throw new CommentNotFoundException(); + } + log.info("[COMMENT][HARD_DELETE] commentId={}, deletedCount={}", commentId, deletedCount); + } + + // 댓글 전체 조회 + public CursorPageResponseCommentDto findAll( + Long userId, CommentSearchRequest request + ) { + log.info("[COMMENT][FIND_ALL][START] userId={}, articleId={}, orderBy={}, cursor={}, after={}, limit={}", + userId, + request.getArticleId(), + request.getOrderBy(), + request.getCursor(), + request.getAfter(), + request.getLimit() + ); + + return commentRepository.searchComments( + request.getArticleId(), + request.getOrderBy(), + request.getCursor(), + request.getAfter(), + request.getLimit(), + userId + ); + } + + // === 내부 유틸 === + + // 작성자 확인 + private void validateOwnership(Comment comment, Long userId) { + if (!comment.isOwnedBy(userId)) + throw new CommentForbiddenException(); + } + + // commentId 확인 + private Comment getCommentById(Long commentId) { + return commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new); + } + + // userId 확인 + private User getUserById(Long userId) { + return userRepository.findById(userId).orElseThrow(CommentUserNotFoundException::new); + } + + // articleId 확인 + private Article getArticleById(Long articleId) { + return articleRepository.findById(articleId).orElseThrow(CommentArticleNotFoundException::new); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java new file mode 100644 index 0000000..7dbdc05 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/AWSConfig.java @@ -0,0 +1,36 @@ +package com.monew.monew_api.common.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Getter +@Configuration +public class AWSConfig { + @Value("${aws.accessKeyId}") + private String accessKey; + + @Value("${aws.secretKey}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Value("${aws.bucket}") + private String bucket; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java new file mode 100644 index 0000000..ca009b2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java new file mode 100644 index 0000000..01c4a3f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/SecurityConfig.java @@ -0,0 +1,41 @@ +package com.monew.monew_api.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + /** + * 개발 환경용 PasswordEncoder - 평문 비밀번호 사용 + * 테스트 및 개발 편의성을 위해 비밀번호를 암호화하지 않음 + */ + @Bean + @Profile("dev") + public PasswordEncoder devPasswordEncoder() { + return new PasswordEncoder() { + @Override + public String encode(CharSequence rawPassword) { + return rawPassword.toString(); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return rawPassword.toString().equals(encodedPassword); + } + }; + } + + /** + * 프로덕션 환경용 PasswordEncoder - BCrypt 암호화 사용 + * 실제 배포 환경에서 안전한 비밀번호 저장 + */ + @Bean + @Profile("prod") + public PasswordEncoder prodPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java new file mode 100644 index 0000000..4ec4ac6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/SwaggerConfig.java @@ -0,0 +1,28 @@ +package com.monew.monew_api.common.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("Monew API 문서") + .description("Monew 프로젝트의 REST API 명세서입니다.") + .version("v1.0.0") + .license(new License().name("MIT License"))) + .servers(List.of( + new Server().url("http://localhost:8080").description("개발 서버"), + new Server().url("https://api.monew.com").description("운영 서버") + )); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java new file mode 100644 index 0000000..397c98b --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/config/WebConfig.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.common.config; + +import com.monew.monew_api.common.interceptor.MDCLoggingInterceptor; +import com.monew.monew_api.common.interceptor.UserIdHeaderInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final MDCLoggingInterceptor mdcLoggingInterceptor; + private final UserIdHeaderInterceptor userIdHeaderInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(mdcLoggingInterceptor) + .addPathPatterns("/**"); + + registry.addInterceptor(userIdHeaderInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/api/users/login", "/api/users"); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java b/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java new file mode 100644 index 0000000..9424881 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/dto/CursorPageResponse.java @@ -0,0 +1,14 @@ +package com.monew.monew_api.common.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record CursorPageResponse( + List content, + String nextCursor, + LocalDateTime nextAfter, + int size, + long totalElements, + boolean hasNext +) { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java new file mode 100644 index 0000000..9d5c107 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseCreatedEntity.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseCreatedEntity extends BaseIdEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java new file mode 100644 index 0000000..a3e8494 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseIdEntity.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.common.entity; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +public abstract class BaseIdEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java new file mode 100644 index 0000000..d1d7cbb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/entity/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity extends BaseIdEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java new file mode 100644 index 0000000..6c9c1bc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/BaseException.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.common.exception; + +import lombok.Getter; + +import java.time.Instant; +import java.util.Map; + +@Getter +public class BaseException extends RuntimeException { + + private final Instant timestamp; + private final ErrorCode errorCode; + private final Map details; + + public BaseException(ErrorCode errorCode) { + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = Map.of(); + } + + public BaseException(ErrorCode errorCode, Map details) { + this.timestamp = Instant.now(); + this.errorCode = errorCode; + this.details = details; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java new file mode 100644 index 0000000..26f3d82 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorCode.java @@ -0,0 +1,49 @@ +package com.monew.monew_api.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + + // 사용자 - USER + USER_EMAIL_DUPLICATED(HttpStatus.CONFLICT.value(), "이미 존재하는 이메일입니다."), + USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED.value(), "이메일 또는 비밀번호가 일치하지 않습니다."), + USER_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "수정 또는 삭제 권한이 없습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "사용자 정보를 찾을 수 없습니다."), + + // 관심사 - INTEREST + INTEREST_DUPLICATED(HttpStatus.CONFLICT.value(), "유사한 관심사가 이미 존재합니다."), + INTEREST_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "관심사 정보를 찾을 수 없습니다."), + + //관심사 구독 - SUBSCRIBE + SUBSCRIBE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "구독 정보를 찾을 수 없습니다."), + SUBSCRIBE_DUPLICATE(HttpStatus.CONFLICT.value(), "이미 구독 중입니다."), + + // 뉴스 기사 - ARTICLE + ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "뉴스 기사 정보를 찾을 수 없습니다."), + ARTICLE_ALREADY_VIEWED(HttpStatus.CONFLICT.value(), "이미 조회한 기사입니다."), + + // 댓글 - COMMENT + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 정보를 찾을 수 없습니다."), + COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN.value(), "댓글 수정 또는 삭제 권한이 없습니다."), + COMMENT_ALREADY_LIKED(HttpStatus.CONFLICT.value(), "이미 좋아요한 댓글입니다."), + COMMENT_NOT_LIKED(HttpStatus.BAD_REQUEST.value(), "좋아요하지 않은 댓글은 취소할 수 없습니다."), + COMMENT_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글 작성자를 찾을 수 없습니다."), + COMMENT_ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "댓글이 연결된 기사를 찾을 수 없습니다."), + COMMENT_INVALID_USER_ID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 사용자 ID 형식입니다."), + COMMENT_INVALID_ARTICLE_ID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 기사 ID 형식입니다."), + + // 알림 - NOTIFICATION + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "알림 정보를 찾을 수 없습니다."), + NOTIFICATION_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "해당 알림에 접근할 권한이 없습니다."), + NOTIFICATION_ALREADY_CONFIRMED(HttpStatus.CONFLICT.value(), "이미 확인된 알림입니다."); + + private final int status; + private final String message; + + ErrorCode(int status, String message) { + this.status = status; + this.message = message; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java new file mode 100644 index 0000000..c0bcdce --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/ErrorResponse.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.common.exception; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Getter +@Builder +public class ErrorResponse { + private final Instant timestamp; + private final String code; + private final String message; + private final Map details; + private final String exceptionType; + private final int status; + + public static ErrorResponse of(BaseException e, String path) { + ErrorCode errorCode = e.getErrorCode(); + + Map mergedDetails = new HashMap<>(e.getDetails()); + mergedDetails.put("path", path); + + return ErrorResponse.builder() + .timestamp(e.getTimestamp()) + .status(errorCode.getStatus()) + .code(errorCode.name()) + .message(errorCode.getMessage()) + .details(mergedDetails) + .exceptionType(e.getClass().getSimpleName()) + .build(); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..00b7400 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,77 @@ +package com.monew.monew_api.common.exception; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e, HttpServletRequest request) { + log.warn("[비즈니스 예외 발생] 코드: {}, 메시지: {}, 요청 URI: {}", + e.getErrorCode().name(), + e.getMessage(), + request.getRequestURI()); + + return ResponseEntity + .status(e.getErrorCode().getStatus()) + .body(ErrorResponse.of(e, request.getRequestURI())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException e, HttpServletRequest request) { + Map fieldErrors = new HashMap<>(); + for (FieldError fieldError : e.getBindingResult().getFieldErrors()) { + fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + log.warn("[입력값 검증 실패] 요청 URI: {}, 에러 필드 수: {}, 상세: {}", + request.getRequestURI(), + fieldErrors.size(), + fieldErrors); + + ErrorResponse response = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(400) + .code("VALIDATION_ERROR") + .message("요청 데이터의 유효성 검증에 실패했습니다.") + .details(Map.of( + "path", request.getRequestURI(), + "errors", fieldErrors + )) + .exceptionType(e.getClass().getSimpleName()) + .build(); + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpectedException(Exception e, HttpServletRequest request) { + log.error("[서버 내부 오류] 예외 타입: {}, 메시지: {}, URI: {}", + e.getClass().getSimpleName(), + e.getMessage(), + request.getRequestURI(), + e); + + ErrorResponse response = ErrorResponse.builder() + .timestamp(Instant.now()) + .status(500) + .code("INTERNAL_SERVER_ERROR") + .message("서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") + .details(Map.of("path", request.getRequestURI())) + .exceptionType(e.getClass().getSimpleName()) + .build(); + + return ResponseEntity.status(500).body(response); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java new file mode 100644 index 0000000..62d0cd4 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleAlreadyViewedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.article; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class ArticleAlreadyViewedException extends BaseException { + + public ArticleAlreadyViewedException() { + super(ErrorCode.ARTICLE_ALREADY_VIEWED); + } + + public ArticleAlreadyViewedException(Map details) { + super(ErrorCode.ARTICLE_ALREADY_VIEWED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java new file mode 100644 index 0000000..b6b1ee0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/article/ArticleNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.article; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class ArticleNotFoundException extends BaseException { + + public ArticleNotFoundException() { + super(ErrorCode.ARTICLE_NOT_FOUND); + } + + public ArticleNotFoundException(Map details) { + super(ErrorCode.ARTICLE_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java new file mode 100644 index 0000000..5574141 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentAlreadyLikedException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentAlreadyLikedException extends BaseException { + public CommentAlreadyLikedException() { + super(ErrorCode.COMMENT_ALREADY_LIKED); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java new file mode 100644 index 0000000..86ef31c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentArticleNotFoundException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentArticleNotFoundException extends BaseException { + public CommentArticleNotFoundException() { + super(ErrorCode.COMMENT_ARTICLE_NOT_FOUND); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java new file mode 100644 index 0000000..f08b222 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentForbiddenException.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentForbiddenException extends BaseException { + public CommentForbiddenException() { + super(ErrorCode.COMMENT_FORBIDDEN); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java new file mode 100644 index 0000000..eda5feb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidArticleIdException.java @@ -0,0 +1,12 @@ +package com.monew.monew_api.common.exception.comment; + +import java.util.Map; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentInvalidArticleIdException extends BaseException { + public CommentInvalidArticleIdException(String invalidValue) { + super(ErrorCode.COMMENT_INVALID_ARTICLE_ID, Map.of("articleId", invalidValue)); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java new file mode 100644 index 0000000..0b5e303 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentInvalidUserIdException.java @@ -0,0 +1,14 @@ +package com.monew.monew_api.common.exception.comment; + +import java.util.Map; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentInvalidUserIdException extends BaseException { + + public CommentInvalidUserIdException(String invalidValue) { + super(ErrorCode.COMMENT_INVALID_USER_ID, Map.of("userId", invalidValue)); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java new file mode 100644 index 0000000..a6527d7 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotFoundException.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class CommentNotFoundException extends BaseException { + + public CommentNotFoundException() { + super(ErrorCode.COMMENT_NOT_FOUND); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java new file mode 100644 index 0000000..38202cf --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentNotLikedException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentNotLikedException extends BaseException { + public CommentNotLikedException() { + super(ErrorCode.COMMENT_NOT_LIKED); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java new file mode 100644 index 0000000..ea481c3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/comment/CommentUserNotFoundException.java @@ -0,0 +1,10 @@ +package com.monew.monew_api.common.exception.comment; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +public class CommentUserNotFoundException extends BaseException { + public CommentUserNotFoundException() { + super(ErrorCode.COMMENT_USER_NOT_FOUND); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java new file mode 100644 index 0000000..6db377f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestDuplicatedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.interest; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class InterestDuplicatedException extends BaseException { + + public InterestDuplicatedException() { + super(ErrorCode.INTEREST_DUPLICATED); + } + + public InterestDuplicatedException(Map details) { + super(ErrorCode.INTEREST_DUPLICATED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java new file mode 100644 index 0000000..a8e4aeb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/interest/InterestNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.interest; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class InterestNotFoundException extends BaseException { + + public InterestNotFoundException() { + super(ErrorCode.INTEREST_NOT_FOUND); + } + + public InterestNotFoundException(Map details) { + super(ErrorCode.INTEREST_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java new file mode 100644 index 0000000..1ec3406 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAccessDeniedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.notification; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class NotificationAccessDeniedException extends BaseException { + + public NotificationAccessDeniedException() { + super(ErrorCode.NOTIFICATION_ACCESS_DENIED); + } + + public NotificationAccessDeniedException(Map details) { + super(ErrorCode.NOTIFICATION_ACCESS_DENIED, details); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java new file mode 100644 index 0000000..d361c2a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationAlreadyConfirmedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.notification; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class NotificationAlreadyConfirmedException extends BaseException { + + public NotificationAlreadyConfirmedException() { + super(ErrorCode.NOTIFICATION_ALREADY_CONFIRMED); + } + + public NotificationAlreadyConfirmedException(Map details) { + super(ErrorCode.NOTIFICATION_ALREADY_CONFIRMED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java new file mode 100644 index 0000000..954c995 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/notification/NotificationNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.notification; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class NotificationNotFoundException extends BaseException { + + public NotificationNotFoundException() { + super(ErrorCode.NOTIFICATION_NOT_FOUND); + } + + public NotificationNotFoundException(Map details) { + super(ErrorCode.NOTIFICATION_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java new file mode 100644 index 0000000..6d50633 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeDuplicateException.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.common.exception.subscribe; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; +import java.util.Map; + +public class SubscribeDuplicateException extends BaseException { + + public SubscribeDuplicateException() { + super(ErrorCode.SUBSCRIBE_DUPLICATE); + } + + public SubscribeDuplicateException(Map details) { + super(ErrorCode.SUBSCRIBE_DUPLICATE, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java new file mode 100644 index 0000000..47882d8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/subscribe/SubscribeNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.subscribe; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; +import java.util.Map; + +public class SubscribeNotFoundException extends BaseException { + + public SubscribeNotFoundException() { + super(ErrorCode.SUBSCRIBE_NOT_FOUND); + } + + public SubscribeNotFoundException(Map details) { + super(ErrorCode.SUBSCRIBE_NOT_FOUND, details); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java new file mode 100644 index 0000000..9df967d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserEmailDuplicateException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserEmailDuplicateException extends BaseException { + + public UserEmailDuplicateException() { + super(ErrorCode.USER_EMAIL_DUPLICATED); + } + + public UserEmailDuplicateException(Map details) { + super(ErrorCode.USER_EMAIL_DUPLICATED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java new file mode 100644 index 0000000..795a4c8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserForbiddenException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserForbiddenException extends BaseException { + + public UserForbiddenException() { + super(ErrorCode.USER_FORBIDDEN); + } + + public UserForbiddenException(Map details) { + super(ErrorCode.USER_FORBIDDEN, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java new file mode 100644 index 0000000..29d3064 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserNotFoundException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserNotFoundException extends BaseException { + + public UserNotFoundException() { + super(ErrorCode.USER_NOT_FOUND); + } + + public UserNotFoundException(Map details) { + super(ErrorCode.USER_NOT_FOUND, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java new file mode 100644 index 0000000..e18eee8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/exception/user/UserUnauthorizedException.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.common.exception.user; + +import com.monew.monew_api.common.exception.BaseException; +import com.monew.monew_api.common.exception.ErrorCode; + +import java.util.Map; + +public class UserUnauthorizedException extends BaseException { + + public UserUnauthorizedException() { + super(ErrorCode.USER_UNAUTHORIZED); + } + + public UserUnauthorizedException(Map details) { + super(ErrorCode.USER_UNAUTHORIZED, details); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java new file mode 100644 index 0000000..86d5afd --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/MDCLoggingInterceptor.java @@ -0,0 +1,47 @@ +package com.monew.monew_api.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +@Component +public class MDCLoggingInterceptor implements HandlerInterceptor { + + private static final String REQUEST_ID = "requestId"; + private static final String CLIENT_IP = "clientIp"; + private static final String REQUEST_METHOD = "requestMethod"; + private static final String REQUEST_URI = "requestUri"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String requestId = UUID.randomUUID().toString().substring(0, 8); + String clientIp = extractClientIp(request); + + MDC.put(REQUEST_ID, requestId); + MDC.put(CLIENT_IP, clientIp); + MDC.put(REQUEST_METHOD, request.getMethod()); + MDC.put(REQUEST_URI, request.getRequestURI()); + + response.addHeader("X-Request-ID", requestId); + response.addHeader("X-Client-IP", clientIp); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.clear(); + } + + private String extractClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isBlank()) { + return ip.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java new file mode 100644 index 0000000..b4e3d05 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/common/interceptor/UserIdHeaderInterceptor.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Slf4j +@Component +public class UserIdHeaderInterceptor implements HandlerInterceptor { + + private static final String USER_ID_HEADER = "MoNew-Request-User-ID"; + private static final String MDC_USER_ID_KEY = "userId"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String userId = request.getHeader(USER_ID_HEADER); + + if (userId != null && !userId.isBlank()) { + MDC.put(MDC_USER_ID_KEY, userId); + log.info("[사용자 헤더 감지] {} 헤더 값: {}, URI: {}", USER_ID_HEADER, userId, request.getRequestURI()); + } else { + log.info("[사용자 헤더 없음] {} 헤더가 요청에 포함되지 않음, URI: {}", USER_ID_HEADER, request.getRequestURI()); + } + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.remove(MDC_USER_ID_KEY); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java new file mode 100644 index 0000000..d2da3d0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/controller/InterestController.java @@ -0,0 +1,81 @@ +package com.monew.monew_api.interest.controller; + +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.service.InterestService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/interests") +@RequiredArgsConstructor +@Slf4j +public class InterestController { + + private final InterestService interestService; + + @PostMapping + public ResponseEntity createInterest( + @RequestBody @Valid InterestRegisterRequest request + ) { + log.info("[API 요청] POST/api/interests/ - 관심사 등록 요청 : {}", request); + InterestDto response = interestService.createInterest(request); + log.info("[API 응답] POST/api/interests/ - 관심사 등록 응답 : {}", response); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + + @GetMapping + public ResponseEntity getInterests( + @RequestHeader("Monew-Request-User-Id") Long userId, + @ParameterObject @ModelAttribute CursorPageRequestInterestDto request + ) { + log.info("[API 요청] GET/api/interests/ - 관심사 조회 요청 : {}", request); + CursorPageResponseInterestDto response = interestService.getInterests(userId, request); + log.info("[API 응답] GET/api/interests/ - 관심사 조회 응답 : {}", response); + return ResponseEntity.ok(response); + } + + + @DeleteMapping("/{interestId}") + public ResponseEntity deleteInterest( + @PathVariable Long interestId + ) { + log.info("[API 요청] DELETE/api/interests/{} - 관심사 삭제 요청", interestId); + interestService.deleteInterest(interestId); + log.info("[API 응답] DELETE/api/interests/{} - 관심사 삭제 응답", interestId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + + @PatchMapping("/{interestId}") + public ResponseEntity updateInterestKeywords( + @PathVariable Long interestId, + @RequestBody @Valid InterestUpdateRequest request + ) { + log.info("[API 요청] PATCH/api/interests/{} - 관심사 키워드 수정 요청 : {}", interestId, request); + InterestDto response = interestService + .updateInterestKeywords(request, interestId); + log.info("[API 응답] PATCH/api/interests/{} - 관심사 키워드 수정 응답 : {}", interestId, response); + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java new file mode 100644 index 0000000..85b95af --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/InterestOrderBy.java @@ -0,0 +1,5 @@ +package com.monew.monew_api.interest.dto; + +public enum InterestOrderBy { + name, subscriberCount +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java new file mode 100644 index 0000000..807aeda --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/CursorPageRequestInterestDto.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.interest.dto.request; + +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.querydsl.core.types.Order; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import org.springdoc.core.annotations.ParameterObject; + +@ParameterObject +public record CursorPageRequestInterestDto( + + String keyword, // 검색어(관심사, 키워드) + @NotNull InterestOrderBy orderBy, + @NotNull Order direction, // 정렬 방향 (ASC, DESC) + String cursor, // 커서 값 + LocalDateTime after, // + @NotNull Integer limit // 커서 페이지 크기 +){ +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java new file mode 100644 index 0000000..877dc68 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestRegisterRequest.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.interest.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record InterestRegisterRequest( + + @JsonProperty("name") + @Size(min = 1, max = 50) + String name, + + @Size(min = 1, max = 10) + List keywords +) { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java new file mode 100644 index 0000000..acd5898 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/request/InterestUpdateRequest.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.interest.dto.request; + +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.Builder; + + +public record InterestUpdateRequest( + @Size(min = 1, max = 10) + List keywords +) { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java new file mode 100644 index 0000000..d5bf8e2 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/CursorPageResponseInterestDto.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.interest.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +public record CursorPageResponseInterestDto( + List content, // 실제 데이터 목록 + String nextCursor, // 다음 페이지 요청 위한 커서 + LocalDateTime nextAfter, // 커서 기준 시점 + int size, // 한페이지에 담긴 데이터 개수 + Long totalElements, // 전체 데이터 개수 + boolean hasNext // 다음 페이지 존재 여부 + +) { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java new file mode 100644 index 0000000..7edeec3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/dto/response/InterestDto.java @@ -0,0 +1,13 @@ +package com.monew.monew_api.interest.dto.response; + +import java.util.List; + +public record InterestDto( + Long id, + String name, + List keywords, + Long subscriberCount, + boolean subscribedByMe +) { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java new file mode 100644 index 0000000..f053d3d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Interest.java @@ -0,0 +1,57 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import java.util.HashSet; +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "interests") +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Interest extends BaseTimeEntity { + + @Column(length = 100, nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private int subscriberCount = 0; + + @OneToMany(mappedBy = "interest", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("createdAt ASC") + private Set keywords = new HashSet<>(); + + private Interest(String name, int subscriberCount) { + this.name = name; + this.subscriberCount = subscriberCount; + } + + public static Interest create(String name) { + return new Interest(name, 0); + } + + public InterestKeyword addKeyword(Keyword keyword) { + InterestKeyword interestKeyword = InterestKeyword.create(this, keyword); + this.keywords.add(interestKeyword); + return interestKeyword; + } + + public void addSubscriberCount(){ + this.subscriberCount++; + + } + + public void cancelSubscriberCount(){ + if(this.subscriberCount > 0){} + this.subscriberCount--; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java new file mode 100644 index 0000000..466dd95 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/InterestKeyword.java @@ -0,0 +1,35 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "interest_keywords") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class InterestKeyword extends BaseTimeEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id", nullable = false) + private Keyword keyword; + + public static InterestKeyword create(Interest interest, Keyword keyword) { + return new InterestKeyword(interest, keyword); + } + +} + + + diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java new file mode 100644 index 0000000..2305036 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/entity/Keyword.java @@ -0,0 +1,22 @@ +package com.monew.monew_api.interest.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "keywords") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Keyword extends BaseTimeEntity { + + @Column(name = "keyword", length = 50, nullable = false, unique = true) + private String keyword; + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java new file mode 100644 index 0000000..ccc2b06 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestDeletedEvent.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.interest.event; + +import java.time.LocalDateTime; + +/** + * Interest 삭제 이벤트 + * @param interestId + * @param occurredAt + */ +public record InterestDeletedEvent( + Long interestId, + LocalDateTime occurredAt +) { + public static InterestDeletedEvent of(Long interestId) { + return new InterestDeletedEvent(interestId, LocalDateTime.now()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java new file mode 100644 index 0000000..02a84e8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/event/InterestUpdatedEvent.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.interest.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Interest 정보 변경 이벤트 + * Interest 정보를 수정했을 때 발행 + * @param interestId + * @param newKeywords + * @param occurredAt + */ +public record InterestUpdatedEvent( + Long interestId, + List newKeywords, + LocalDateTime occurredAt +) { + public static InterestUpdatedEvent of(Long interestId, List newKeywords) { + return new InterestUpdatedEvent(interestId, newKeywords, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java new file mode 100644 index 0000000..0b523ee --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/mapper/InterestMapper.java @@ -0,0 +1,34 @@ +package com.monew.monew_api.interest.mapper; + +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.InterestKeyword; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + + +@Mapper(componentModel = "spring") +public interface InterestMapper { + + @Mapping(source = "interest.name", target = "name") + @Mapping(source = "keywords", target = "keywords") + @Mapping(source = "subscribedByMe", target = "subscribedByMe") + InterestDto toDto(Interest interest, List keywords, Boolean subscribedByMe); + + @Mapping(target = "subscriberCount", source = "subscriberCount") + InterestDto toInterestDto(Interest interest, List keywords, Boolean subscribedByMe, + int subscriberCount); + + // 커스텀 매핑 메서드 (Set -> List) + default List map(Set interestKeywords) { + if (interestKeywords == null) { + return Collections.emptyList(); + } + return interestKeywords.stream() + .map(ik -> ik.getKeyword().getKeyword()) + .toList(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java new file mode 100644 index 0000000..ba24328 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepository.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.interest.repository; + +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface InterestRepository extends JpaRepository, InterestRepositoryCustom { + + @Query(""" + SELECT DISTINCT i + FROM Interest i + JOIN FETCH i.keywords ik + JOIN FETCH ik.keyword k + WHERE k = :keyword + """) + List findAllByKeyword(@Param("keyword") Keyword keyword); + + /** + * 특정 관심사와 해당 관심사에 연결된 키워드들을 함께 조회 + * author : 정영진 + * 캐시 데이터 업데이트에 필요 + */ + @EntityGraph(attributePaths = {"keywords", "keywords.keyword"}) + Optional findById(Long id); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java new file mode 100644 index 0000000..4ff5852 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustom.java @@ -0,0 +1,22 @@ +package com.monew.monew_api.interest.repository; + +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.entity.Interest; +import com.querydsl.core.types.Order; +import java.time.LocalDateTime; +import org.springframework.data.domain.Slice; + +public interface InterestRepositoryCustom { + + Slice findAll( + String keyword, + InterestOrderBy sortBy, + Order direction, + String cursor, + LocalDateTime after, + int limit + ); + + Long countFilteredTotalElements(String keyword); +} + diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java new file mode 100644 index 0000000..eedc14b --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/InterestRepositoryCustomImpl.java @@ -0,0 +1,140 @@ +package com.monew.monew_api.interest.repository; + +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; +import static com.monew.monew_api.interest.entity.QKeyword.keyword1; +import static com.monew.monew_api.interest.entity.QInterest.interest; + +@Repository +@RequiredArgsConstructor +public class InterestRepositoryCustomImpl implements InterestRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Slice findAll( + String searchKeyword, + InterestOrderBy orderBy, + Order direction, + String cursor, + LocalDateTime after, + int limit + ) { + BooleanBuilder builder = new BooleanBuilder(); + + if (searchKeyword != null && !searchKeyword.isBlank()) { + builder.and( + interest.name.containsIgnoreCase(searchKeyword) + .or(keyword1.keyword.containsIgnoreCase(searchKeyword)) + ); + } + + if (after != null) { + builder.and(interest.createdAt.gt(after)); + } + + Order ord = (direction == null || direction == Order.DESC) ? Order.DESC : Order.ASC; + boolean desc = (ord == Order.DESC); + + Long cursorId = null; + String cursorName = null; + Integer cursorCnt = null; + + OrderSpecifier[] orderSpec = new OrderSpecifier[]{};; + + switch (orderBy) { + case name -> { + cursorName = (cursor == null || cursor.isBlank()) ? null : cursor; + + orderSpec = new OrderSpecifier[]{ + new OrderSpecifier<>(ord, interest.name) + }; + + if (cursorName != null) { + builder.and(desc ? interest.name.lt(cursorName) : interest.name.gt(cursorName)); + } + } + + case subscriberCount -> { + cursorId = (cursor == null || cursor.isBlank()) ? null : Long.valueOf(cursor); + + if (cursorId != null) { + Interest base = queryFactory.selectFrom(interest) + .where(interest.id.eq(cursorId)) + .fetchOne(); + if (base != null) + cursorCnt = base.getSubscriberCount(); + } + + orderSpec = new OrderSpecifier[]{ + new OrderSpecifier<>(ord, interest.subscriberCount), + new OrderSpecifier<>(ord, interest.id) + }; + + if (cursorId != null && cursorCnt != null) { + BooleanExpression cut = desc + ? interest.subscriberCount.lt(cursorCnt) + .or(interest.subscriberCount.eq(cursorCnt).and(interest.id.lt(cursorId))) + : interest.subscriberCount.gt(cursorCnt) + .or(interest.subscriberCount.eq(cursorCnt).and(interest.id.gt(cursorId))); + builder.and(cut); + } + } + } + + List rowsPlusOne = queryFactory + .select(interest) + .from(interest) + .leftJoin(interest.keywords, interestKeyword) + .leftJoin(interestKeyword.keyword, keyword1) + .where(builder) + .groupBy(interest.id) + .orderBy(orderSpec) + .limit(limit + 1) + .fetch(); + + boolean hasNext = rowsPlusOne.size() > limit; + List content = hasNext ? rowsPlusOne.subList(0, limit) : rowsPlusOne; + + return new SliceImpl<>(content, PageRequest.of(0, limit), hasNext); + } + + @Override + public Long countFilteredTotalElements(String keyword) { + JPAQuery query = queryFactory + .select(interest.countDistinct()) + .from(interest); + + BooleanBuilder builder = new BooleanBuilder(); + + // keyword가 있을 때만 조인 + if (keyword != null && !keyword.isBlank()) { + query + .leftJoin(interest.keywords, interestKeyword) + .leftJoin(interestKeyword.keyword, keyword1); + builder.and( + interest.name.containsIgnoreCase(keyword) + .or(keyword1.keyword.containsIgnoreCase(keyword)) + ); + } + Long count = query.where(builder).fetchOne(); + return (count != null) ? count : 0L; + } +} + + diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java new file mode 100644 index 0000000..4138e33 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/repository/KeywordRepository.java @@ -0,0 +1,24 @@ +package com.monew.monew_api.interest.repository; + + +import com.monew.monew_api.interest.entity.Keyword; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface KeywordRepository extends JpaRepository { + + Optional findByKeyword(String keyword); + + List findAllByKeywordIn(Collection keywords); + + @Query("SELECT k FROM Keyword k " + + "WHERE k IN :keywords AND NOT EXISTS (" + + "SELECT 1 FROM InterestKeyword ik WHERE ik.keyword = k" + + ")" + ) + List findOrphanKeywordsIn(@Param("keywords") Collection keywords); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java new file mode 100644 index 0000000..2258be6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestService.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.interest.service; + +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; + +public interface InterestService { + + InterestDto createInterest(InterestRegisterRequest request); + + CursorPageResponseInterestDto getInterests(Long userId, + CursorPageRequestInterestDto cursorRequest); + + InterestDto updateInterestKeywords(InterestUpdateRequest request, Long interestId); + + void deleteInterest(Long interestId); + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java new file mode 100644 index 0000000..7fd6c91 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/interest/service/InterestServiceImpl.java @@ -0,0 +1,304 @@ +package com.monew.monew_api.interest.service; + +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; +import com.monew.monew_api.common.exception.interest.InterestDuplicatedException; +import com.monew.monew_api.common.exception.interest.InterestNotFoundException; +import com.monew.monew_api.user.repository.UserRepository; +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.interest.event.InterestUpdatedEvent; +import com.monew.monew_api.interest.dto.InterestOrderBy; +import com.monew.monew_api.interest.dto.request.CursorPageRequestInterestDto; +import com.monew.monew_api.interest.dto.request.InterestRegisterRequest; +import com.monew.monew_api.interest.dto.request.InterestUpdateRequest; +import com.monew.monew_api.interest.dto.response.CursorPageResponseInterestDto; +import com.monew.monew_api.interest.dto.response.InterestDto; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.InterestKeyword; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_api.interest.mapper.InterestMapper; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.interest.repository.KeywordRepository; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import com.monew.monew_api.subscribe.repository.SubscribeRepository.InterestCountProjection; +import com.querydsl.core.types.Order; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.text.similarity.LevenshteinDistance; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class InterestServiceImpl implements InterestService { + + private final InterestRepository interestRepository; + private final KeywordRepository keywordRepository; + private final SubscribeRepository subscribeRepository; + + private final ArticleRepository articleRepository; + private final InterestArticlesRepository interestArticlesRepository; + private final InterestArticleKeywordRepository interestArticleKeywordRepository; + + private final InterestMapper interestMapper; + private final ApplicationEventPublisher eventPublisher; + + @Override + @Transactional + public InterestDto createInterest(InterestRegisterRequest request) { + + String interestName = request.name(); + + // 유사도 검사 + String similarName = findSimilarInterestName(interestName); + if (similarName != null) { + Map details = new HashMap<>(); + details.put("name", similarName); + log.warn("유사한 관심사 이름: {}", similarName); + throw new InterestDuplicatedException(details); + } + + Interest interest = Interest.create(interestName); + + // 키워드 저장 + Set keywordSet = new HashSet<>(request.keywords()); + for (String keyword : keywordSet) { + Keyword getKeyword = keywordRepository.findByKeyword(keyword) + .orElseGet(() -> keywordRepository.save(new Keyword(keyword))); + interest.addKeyword(getKeyword); + } + + Interest savedInterest = interestRepository.save(interest); + + List keywords = savedInterest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .collect(Collectors.toList()); + + return interestMapper.toDto(savedInterest, keywords, false); + } + + @Override + @Transactional(readOnly = true) + public CursorPageResponseInterestDto getInterests(Long userId, + CursorPageRequestInterestDto request) { + + final String keyword = (request.keyword() == null || request.keyword().isBlank()) + ? null : request.keyword(); + final InterestOrderBy orderBy = + (request.orderBy() == null) ? InterestOrderBy.name : request.orderBy(); + final Order direction = (request.direction() == null) ? Order.ASC : request.direction(); + final String cursor = request.cursor(); + final LocalDateTime after = request.after(); + final int limit = request.limit(); + + Slice slices = interestRepository.findAll( + keyword, orderBy, direction, cursor, after, limit); + log.info("REQ userId={}, keyword={}, orderBy={}, direction={}, cursor={}, after={}, limit={}", + userId, keyword, orderBy, direction, cursor, after, limit); + + List interests = slices.getContent(); + + // 관심사 Id 수집 + Set interestIds = interests.stream().map(Interest::getId).collect(Collectors.toSet()); + // 내가 구독중인 관심사 ID + Set subscribedIds = subscribeRepository.findSubscribedByInterestIds(userId, + interestIds); + // dto 채우기 + List interestDtos = new ArrayList<>(interests.size()); + for (Interest interest : interests) { + List keywords = interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .toList(); + int subscriberCount = interest.getSubscriberCount(); + boolean subscribedByMe = subscribedIds.contains(interest.getId()); + InterestDto dto = interestMapper.toInterestDto(interest, keywords, subscribedByMe, + subscriberCount); + + log.info("DBG dto id={}, name={}, subscriberCount={} subscribedByMe={}", + dto.id(), dto.name(), dto.subscriberCount(), dto.subscribedByMe()); + interestDtos.add(dto); + } + + long totalElements = interestRepository.countFilteredTotalElements(keyword); + boolean hasNext = slices.hasNext(); + + String nextCursor = null; + LocalDateTime nextAfter = null; + if (hasNext) { + Interest last = interests.get(interests.size() - 1); + + if (request.orderBy() == InterestOrderBy.name) { + nextCursor = last.getName(); + } else if (request.orderBy() == InterestOrderBy.subscriberCount) { + nextCursor = String.valueOf(last.getId()); + } + nextAfter = last.getCreatedAt(); + } + + return new CursorPageResponseInterestDto(interestDtos, nextCursor, nextAfter, + slices.getSize(), totalElements, hasNext); + } + + @Override + @Transactional + public InterestDto updateInterestKeywords( + InterestUpdateRequest request, Long interestId) { + + Interest interest = interestRepository.findById(interestId) + .orElseThrow(InterestNotFoundException::new); + + updateKeywords(interest, request.keywords()); + + List keywords = interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .collect(Collectors.toList()); + + // 키워드 수정 이벤트 발행 + eventPublisher.publishEvent(InterestUpdatedEvent.of(interest.getId(), keywords)); + + log.info("interestId = {}, 관심사 키워드 수정 완료 : {}", interestId, keywords); + return interestMapper.toDto(interest, keywords, false); + } + + @Override + @Transactional + public void deleteInterest(Long interestId) { + Interest interest = interestRepository.findById(interestId) + .orElseThrow(InterestNotFoundException::new); + + List articleIds = interestArticlesRepository.findArticleIdsByInterestId(interestId); + log.info("관심사({})와 연결된 기사 수: {}", interest.getName(), articleIds.size()); + + if (articleIds.isEmpty()) { + interestRepository.delete(interest); + return; + } + + List usedElsewhere = + interestArticlesRepository.findArticleIdsUsedByOtherInterests(articleIds, interestId); + + List toDelete = articleIds.stream() + .filter(id -> !usedElsewhere.contains(id)) + .toList(); + + int deletedCount = toDelete.size(); + int undeletedCount = usedElsewhere.size(); + + if (!toDelete.isEmpty()) { + articleRepository.markAsDeleted(toDelete); + log.info("논리 삭제된 기사 수: {}", deletedCount); + } + + log.info("삭제 제외된 기사 수(다른 관심사에서 사용 중): {}", undeletedCount); + + interestRepository.delete(interest); + eventPublisher.publishEvent(InterestDeletedEvent.of(interest.getId())); + + log.info("관심사 삭제 완료: {}", interest.getName()); + } + + + private String findSimilarInterestName(String newInterestName) { + for (Interest existingInterest : interestRepository.findAll()) { + double similarity = calculateSimilarity(existingInterest.getName(), newInterestName); + if (similarity >= 0.8) { + return existingInterest.getName(); + } + } + return null; + } + + + private double calculateSimilarity(String name1, String name2) { + if (name1 == null || name2 == null) { + return 0.0; + } + LevenshteinDistance levenshtein = LevenshteinDistance.getDefaultInstance(); + int distance = levenshtein.apply(name1, name2); + int maxLength = Math.max(name1.length(), name2.length()); + return 1.0 - ((double) distance / maxLength); + } + + + private void updateKeywords( + Interest interest, @Size(min = 1, max = 10) List requestKeywords) { + + Map savedKeywords = interest.getKeywords().stream() + .collect(Collectors.toMap( + ik -> ik.getKeyword().getKeyword(), + ik -> ik)); + + Set requestKeywordSet = new HashSet<>(requestKeywords); + + List existingKeywords = keywordRepository.findAllByKeywordIn(requestKeywordSet); + Map existingKeywordMap = existingKeywords.stream() + .collect(Collectors.toMap(Keyword::getKeyword, k -> k)); + + for (String keyword : requestKeywordSet) { + if (!savedKeywords.containsKey(keyword)) { + Keyword getKeyword = existingKeywordMap.getOrDefault(keyword, new Keyword(keyword)); + if (getKeyword.getId() == null) { + getKeyword = keywordRepository.save(getKeyword); + } + interest.addKeyword(getKeyword); + } else { + savedKeywords.remove(keyword); + } + } + removeOrphanKeywords(interest, savedKeywords); + } + + + private void removeOrphanKeywords(Interest interest, Map toRemove) { + if (toRemove.isEmpty()) { + return; + } + + List removedKeywords = toRemove.values().stream() + .map(InterestKeyword::getKeyword) + .toList(); + + interest.getKeywords().removeAll(toRemove.values()); + + List removedKeywordIds = removedKeywords.stream() + .map(Keyword::getId) + .toList(); + + List relatedArticleIds = + interestArticleKeywordRepository.findArticleIdsByKeywordIds(removedKeywordIds); + log.info("고아 키워드 관련 기사 수: {}", relatedArticleIds.size()); + + if (!relatedArticleIds.isEmpty()) { + List usedElsewhere = interestArticleKeywordRepository.findArticlesUsedElsewhere( + relatedArticleIds, removedKeywordIds, interest.getId()); + + List toDelete = relatedArticleIds.stream() + .filter(id -> !usedElsewhere.contains(id)) + .toList(); + + if (!toDelete.isEmpty()) { + articleRepository.markAsDeleted(toDelete); + log.info("논리 삭제된 기사 수: {}", toDelete.size()); + } + + log.info("삭제 제외된 기사 수(다른 관심사/키워드 사용 중): {}", usedElsewhere.size()); + } + + List orphanKeywords = keywordRepository.findOrphanKeywordsIn(removedKeywords); + keywordRepository.deleteAll(orphanKeywords); + log.info("고아 키워드 삭제 완료: {}", orphanKeywords.size()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java new file mode 100644 index 0000000..c36bb24 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationController.java @@ -0,0 +1,48 @@ +package com.monew.monew_api.notification.controller; + +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; +import com.monew.monew_api.notification.service.NotificationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + private static final String REQUEST_HEADER_USER_ID = "MoNew-Request-User-ID"; + private final NotificationService notificationService; + + @GetMapping + public ResponseEntity> getNotifications(@RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @ModelAttribute @Valid NotificationCursorPageRequest cursorPageRequest) { + log.info("[API 요청] GET /api/notifications - 전체 조회, 사용자 ID: {}", userId); + CursorPageResponse notifications = notificationService.getNonConfirmedNotifications(userId, cursorPageRequest); + log.info("[API 응답] GET /api/notifications - 전체 조회 성공, 사용자 ID: {}, 알림 개수: {}", userId, notifications.size()); + + return ResponseEntity.ok(notifications); + } + + @PatchMapping("/{notificationId}") + public ResponseEntity confirmOne(@RequestHeader(REQUEST_HEADER_USER_ID) Long userId, + @PathVariable("notificationId") Long notificationId) { + log.info("[API 요청] PATCH /api/notifications/{} - 알림 단건 확인, 사용자 ID: {}", notificationId, userId); + notificationService.setOneConfirmed(userId, notificationId); + log.info("[API 응답] PATCH /api/notifications/{} - 알림 단건 확인 성공, 사용자 ID: {}", notificationId, userId); + return ResponseEntity.ok().build(); + } + + @PatchMapping + public ResponseEntity confirmAll(@RequestHeader("Monew-Request-User-ID") Long userId) { + log.info("[API 요청] PATCH /api/notifications - 알림 전체 확인, 사용자 ID: {}", userId); + notificationService.setAllConfirmed(userId); + log.info("[API 응답] PATCH /api/notifications - 알림 전체 확인 성공, 사용자 ID: {}", userId); + + return ResponseEntity.ok().build(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java new file mode 100644 index 0000000..8272693 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/controller/NotificationInternalController.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.notification.controller; + +import com.monew.monew_api.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class NotificationInternalController { + + private final NotificationService notificationService; + + /** + * 배치 서버가 관심사 관련 기사 등록 알림 생성을 요청하는 엔드포인트 + * 추후 MQ로 전환 예정 + * @param newLinkCountsByInterestId 관심사별 등록된 기사 갯수 + */ + @PostMapping("/api/internal/notifications/articles-registered") + public void createInterestNotification(@RequestBody Map newLinkCountsByInterestId) { + notificationService.createInterestRegisteredNotification(newLinkCountsByInterestId); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java b/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java new file mode 100644 index 0000000..64e5d18 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/dto/request/NotificationCursorPageRequest.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.notification.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +public record NotificationCursorPageRequest( + String cursor, + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime after, + + @NotNull(message = "조회할 개수는 필수값입니다.") + @Min(value = 1, message = "조회 개수는 1 이상이어야 합니다.") + @Max(value = 50, message = "조회 개수는 50을 초과할 수 없습니다.") + Integer limit +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java b/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java new file mode 100644 index 0000000..8e2fdd9 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/dto/response/NotificationDto.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.notification.dto.response; + +import com.monew.monew_api.notification.enums.ResourceType; + +import java.time.LocalDateTime; + +public record NotificationDto( + Long id, + LocalDateTime createdAt, + LocalDateTime updatedAt, + boolean confirmed, + Long userId, + String content, + ResourceType resourceType, + Long resourceId +) { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java new file mode 100644 index 0000000..1692621 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/entity/Notification.java @@ -0,0 +1,44 @@ +package com.monew.monew_api.notification.entity; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import com.monew.monew_api.user.User; +import com.monew.monew_api.notification.enums.ResourceType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notifications") +@Entity +public class Notification extends BaseTimeEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false, length = 100) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ResourceType resourceType; + + @Column(nullable = false) + private Long resourceId; + + @Column(nullable = false) + private boolean confirmed; + + public Notification(User user, String content, ResourceType resourceType, Long resourceId) { + this.user = user; + this.content = content; + this.resourceType = resourceType; + this.resourceId = resourceId; + } + + public void confirm() { + this.confirmed = true; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java b/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java new file mode 100644 index 0000000..ee5a875 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/enums/ResourceType.java @@ -0,0 +1,5 @@ +package com.monew.monew_api.notification.enums; + +public enum ResourceType { + interest, comment +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java new file mode 100644 index 0000000..633b869 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/eventlistener/NotificationEventListener.java @@ -0,0 +1,27 @@ +package com.monew.monew_api.notification.eventlistener; + +import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final NotificationService notificationService; + + @Async + @EventListener(CommentLikedEvent.class) + public void handleCommentLiked(CommentLikedEvent event) { + try { + notificationService.createCommentLikeNotification(event); + } catch (Exception e) { + log.error("좋아요 알림 생성 실패: {}", event.commentId(), e); + } + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..b030959 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepository.java @@ -0,0 +1,22 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.notification.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; + +public interface NotificationRepository extends JpaRepository, NotificationRepositoryCustom { + + @Modifying(clearAutomatically = true) + @Query(""" + UPDATE Notification n SET n.confirmed = true, n.updatedAt = CURRENT_TIMESTAMP + WHERE n.user.id = :userId AND n.confirmed = false + """) + int confirmAllByUserId(Long userId); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Notification n WHERE n.confirmed = true AND n.updatedAt < :oneWeekAgo") + int deleteAllOldConfirmed(LocalDateTime oneWeekAgo); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java new file mode 100644 index 0000000..0a0196d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; + +public interface NotificationRepositoryCustom { + CursorPageResponse findAllNonConfirmedNotifications(Long id, NotificationCursorPageRequest cursorPageRequest); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java new file mode 100644 index 0000000..ada59bb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/repository/NotificationRepositoryCustomImpl.java @@ -0,0 +1,78 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.monew.monew_api.notification.entity.QNotification.notification; + +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryCustomImpl implements NotificationRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public CursorPageResponse findAllNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) { + List results = queryFactory + .select(Projections.constructor(NotificationDto.class, + notification.id, + notification.createdAt, + notification.updatedAt, + notification.confirmed, + notification.user.id, + notification.content, + notification.resourceType, + notification.resourceId)) + .from(notification) + .where( + notification.user.id.eq(userId), + notification.confirmed.isFalse(), + cursorPredicate(cursorPageRequest.cursor(), cursorPageRequest.after()) + ) + .orderBy(notification.createdAt.desc(), notification.id.asc()) + .limit(cursorPageRequest.limit() + 1) + .fetch(); + + Long totalCountTemp = queryFactory + .select(notification.count()) + .from(notification) + .where(notification.user.id.eq(userId).and(notification.confirmed.isFalse())) + .fetchOne(); + + long totalElements = totalCountTemp != null ? totalCountTemp : 0; + + if (results.size() <= cursorPageRequest.limit()) { + return new CursorPageResponse<>(results, null, null, results.size(), totalElements, false); + } + + results.remove(results.size() - 1); + NotificationDto last = results.get(results.size() - 1); + + return new CursorPageResponse<>( + results, + String.valueOf(last.id()), + last.createdAt(), + results.size(), + totalElements, + true + ); + } + + private BooleanExpression cursorPredicate(String cursor, LocalDateTime after) { + if (cursor == null || cursor.isBlank() || after == null) { + return null; + } + + return (notification.createdAt.eq(after).and(notification.id.gt(Long.parseLong(cursor)))) + .or(notification.createdAt.lt(after)); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java new file mode 100644 index 0000000..2f6e2e3 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/notification/service/NotificationService.java @@ -0,0 +1,135 @@ +package com.monew.monew_api.notification.service; + +import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.common.dto.CursorPageResponse; +import com.monew.monew_api.common.exception.notification.NotificationAccessDeniedException; +import com.monew.monew_api.common.exception.notification.NotificationAlreadyConfirmedException; +import com.monew.monew_api.common.exception.notification.NotificationNotFoundException; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.notification.dto.request.NotificationCursorPageRequest; +import com.monew.monew_api.notification.dto.response.NotificationDto; +import com.monew.monew_api.notification.entity.Notification; +import com.monew.monew_api.notification.enums.ResourceType; +import com.monew.monew_api.notification.repository.NotificationRepository; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class NotificationService { + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + private final SubscribeRepository subscribeRepository; + + @Transactional + public void createCommentLikeNotification(CommentLikedEvent event) { + User commentAuthorIdOnly = userRepository.getReferenceById(event.commentAuthorId()); + + String content = String.format("%s님이 나의 댓글을 좋아합니다.", event.likerNickname()); + + notificationRepository.save( + new Notification( + commentAuthorIdOnly, + content, + ResourceType.comment, + event.commentId())); + } + + @Transactional + public void createInterestRegisteredNotification(Map countsByInterestId) { +// Map countsByInterestId = event.newLinkCountsByInterestId(); // 기존 event 기반 처리시 사용 + Set interestIds = countsByInterestId.keySet(); + + // 관심사를 구독하고 있는 구독자 조회 + List subscriptions = subscribeRepository.findAllByInterestIds(interestIds); + if (subscriptions.isEmpty()) { + return; + } + + // 관심사별 구독자 리스트 그룹핑 + Map> usersByInterestId = subscriptions.stream() + .collect(Collectors.groupingBy( + subscribe -> subscribe.getInterest().getId(), + Collectors.mapping(Subscribe::getUser, Collectors.toList()) + )); + + // 관심사 ID - 관심사 명 + Map interestNameMap = subscriptions.stream() + .map(Subscribe::getInterest) + .distinct() + .collect(Collectors.toMap(Interest::getId, Interest::getName)); + + // 알림 생성 + List newNotifications = new ArrayList<>(); + for (Long interestId : interestIds) { + List usersToNotify = usersByInterestId.get(interestId); + int count = countsByInterestId.get(interestId); + String interestName = interestNameMap.get(interestId); + + if (usersToNotify == null || usersToNotify.isEmpty() || count == 0) { + continue; + } + + String content = String.format("%s와 관련된 기사가 %d건 등록되었습니다.", interestName, count); + for (User user : usersToNotify) { + newNotifications.add(new Notification( + user, + content, + ResourceType.interest, + interestId + )); + } + } + + if (!newNotifications.isEmpty()) { + notificationRepository.saveAll(newNotifications); + log.info("[관심사 알림 생성 성공] {}개의 알림 생성", newNotifications.size()); + } + } + + public CursorPageResponse getNonConfirmedNotifications(Long userId, NotificationCursorPageRequest cursorPageRequest) { + return notificationRepository.findAllNonConfirmedNotifications(userId, cursorPageRequest); + } + + @Transactional + public void setOneConfirmed(Long userId, Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> { + log.warn("[알림 조회 실패] 존재하지 않는 알림: {}", notificationId); + return new NotificationNotFoundException(); + }); + + if (!notification.getUser().getId().equals(userId)) { + log.warn("[알림 확인 실패] 권한 없는 사용자: {}, 알림: {}", userId, notificationId); + throw new NotificationAccessDeniedException(); + } + + if (notification.isConfirmed()) { + log.warn("[알림 중복 확인] 이미 확인된 알림: {}", notificationId); + throw new NotificationAlreadyConfirmedException(); + } + + notification.confirm(); + } + + @Transactional + public void setAllConfirmed(Long userId) { + int affectedRows = notificationRepository.confirmAllByUserId(userId); + + log.info("[알림 전체 확인] 사용자 ID: {}, 확인된 알림 개수: {}개", userId, affectedRows); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java new file mode 100644 index 0000000..c1c4170 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/controller/SubscribeController.java @@ -0,0 +1,44 @@ +package com.monew.monew_api.subscribe.controller; + +import com.monew.monew_api.subscribe.dto.SubscribeDto; +import com.monew.monew_api.subscribe.service.SubscribeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/interests") +@RequiredArgsConstructor +@Slf4j +public class SubscribeController { + + private final SubscribeService subscribeService; + + @PostMapping("/{interestId}/subscriptions") + public ResponseEntity createSubscribe( + @PathVariable Long interestId, + @RequestHeader("Monew-Request-User-ID") Long userId){ + log.info("[API 요청] POST/api/interests/{}/subscriptions - 관심사 구독 요청", interestId); + SubscribeDto subscribeDto = subscribeService.createSubscribe(interestId, userId); + log.info("[API 요청] POST/api/interests/{}/subscriptions - 관심사 구독 응답", interestId); + return ResponseEntity.status(HttpStatus.CREATED).body(subscribeDto); + } + + @DeleteMapping("/{interestId}/subscriptions") + public ResponseEntity deleteSubscribe( + @PathVariable Long interestId, + @RequestHeader("Monew-Request-User-ID") Long userId){ + log.info("[API 요청] DELETE/api/interests/{}/subscriptions - 구독 취소 요청", interestId); + subscribeService.deleteSubscribe(interestId, userId); + log.info("[API 요청] DELETE/api/interests/{}/subscriptions - 구독 취소 응답", interestId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java new file mode 100644 index 0000000..5f113c1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/dto/SubscribeDto.java @@ -0,0 +1,17 @@ +package com.monew.monew_api.subscribe.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record SubscribeDto( + Long id, + Long interestId, + String interestName, + List interestKeywords, + int interestSubscriberCount, + LocalDateTime createdAt +) { + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java new file mode 100644 index 0000000..8cc5a4f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/entity/Subscribe.java @@ -0,0 +1,34 @@ +package com.monew.monew_api.subscribe.entity; + +import com.monew.monew_api.common.entity.BaseCreatedEntity; +import com.monew.monew_api.user.User; +import com.monew.monew_api.interest.entity.Interest; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "subscribes") +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Subscribe extends BaseCreatedEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id", nullable = false) + private Interest interest; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + public static Subscribe create(Interest interest, User user) { + return new Subscribe(interest, user); + } +} + diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java new file mode 100644 index 0000000..b616832 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionAddedEvent.java @@ -0,0 +1,36 @@ +package com.monew.monew_api.subscribe.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 구독 추가 이벤트 + * @param userId + * @param subscriptionId + * @param interestId + * @param interestName + * @param interestKeywords + * @param interestSubscriberCount + * @param createdAt + * @param occurredAt + */ +public record SubscriptionAddedEvent( + Long userId, + Long subscriptionId, + Long interestId, + String interestName, + List interestKeywords, + Integer interestSubscriberCount, + LocalDateTime createdAt, + LocalDateTime occurredAt +) { + public static SubscriptionAddedEvent of( + Long userId, Long subscriptionId, Long interestId, String interestName, + List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt + ) { + return new SubscriptionAddedEvent( + userId, subscriptionId, interestId, interestName, interestKeywords, + interestSubscriberCount, createdAt, LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java new file mode 100644 index 0000000..9978c85 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/event/SubscriptionRemovedEvent.java @@ -0,0 +1,20 @@ +package com.monew.monew_api.subscribe.event; + +import java.time.LocalDateTime; + +/** + * 구독 제거 이벤트 + * @param userId + * @param subscriptionId + * @param occurredAt + */ +public record SubscriptionRemovedEvent( + Long userId, + Long subscriptionId, + Long interestId, + LocalDateTime occurredAt +) { + public static SubscriptionRemovedEvent of(Long userId, Long subscriptionId, Long interestId) { + return new SubscriptionRemovedEvent(userId, subscriptionId, interestId, LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java new file mode 100644 index 0000000..0ce3774 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/mapper/SubscribeMapper.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.subscribe.mapper; + +import com.monew.monew_api.interest.entity.InterestKeyword; +import com.monew.monew_api.subscribe.dto.SubscribeDto; +import com.monew.monew_api.subscribe.entity.Subscribe; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +@Mapper(componentModel = "spring") +public interface SubscribeMapper { + + @Mappings({ + @Mapping(source = "interest.id", target = "interestId"), + @Mapping(source = "interest.name", target = "interestName"), + @Mapping(source = "interest.keywords", target = "interestKeywords"), + @Mapping(source = "interest.subscriberCount", target = "interestSubscriberCount") + }) + SubscribeDto toSubscribeDto(Subscribe subscribe); + + default List map(Set keywords) { + if (keywords == null) return null; + return keywords.stream() + .map(ik -> ik.getKeyword().getKeyword()) + .collect(Collectors.toList()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java new file mode 100644 index 0000000..2d135d8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/repository/SubscribeRepository.java @@ -0,0 +1,46 @@ +package com.monew.monew_api.subscribe.repository; + +import com.monew.monew_api.user.User; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.subscribe.entity.Subscribe; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface SubscribeRepository extends JpaRepository { + + boolean existsByInterestAndUser(Interest interest, User user); + + @Query("SELECT s.interest.id FROM Subscribe s " + + "WHERE s.user.id = :userId AND s.interest.id IN :interestIds") + Set findSubscribedByInterestIds(@Param("userId") Long userId, + @Param("interestIds") Set interestIds); + + Optional findByInterestAndUser(Interest interest, User user); + + // 관심사별로 구독자 수 벌크 집계 + @Query("SELECT s.interest.id AS interestId, COUNT(s.id) AS count " + + "FROM Subscribe s WHERE s.interest.id IN :interestIds GROUP BY s.interest.id") + List countByInterestIds(@Param("interestIds") Set interestIds); + + @Query(""" + SELECT s FROM Subscribe s + JOIN FETCH s.user + JOIN FETCH s.interest + WHERE s.interest.id IN :interestIds + AND s.user.deletedAt IS NULL + """) + List findAllByInterestIds(Set interestIds); + + interface InterestCountProjection { + + Long getInterestId(); + Long getCount(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java new file mode 100644 index 0000000..b6b42f0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeService.java @@ -0,0 +1,11 @@ +package com.monew.monew_api.subscribe.service; + +import com.monew.monew_api.subscribe.dto.SubscribeDto; + +public interface SubscribeService { + + SubscribeDto createSubscribe(Long interestId, Long userId); + + void deleteSubscribe(Long interestId, Long userId); + +} diff --git a/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java new file mode 100644 index 0000000..aac32f1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/subscribe/service/SubscribeServiceImpl.java @@ -0,0 +1,90 @@ +package com.monew.monew_api.subscribe.service; + +import com.monew.monew_api.common.exception.interest.InterestNotFoundException; +import com.monew.monew_api.common.exception.subscribe.SubscribeDuplicateException; +import com.monew.monew_api.common.exception.subscribe.SubscribeNotFoundException; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_api.subscribe.dto.SubscribeDto; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; +import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; +import com.monew.monew_api.subscribe.mapper.SubscribeMapper; +import com.monew.monew_api.subscribe.repository.SubscribeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SubscribeServiceImpl implements SubscribeService { + + private final InterestRepository interestRepository; + private final UserRepository userRepository; + private final SubscribeRepository subscribeRepository; + private final SubscribeMapper subscribeMapper; + private final ApplicationEventPublisher eventPublisher; + + @Override + @Transactional + public SubscribeDto createSubscribe(Long interestId, Long userId) { + + Interest interest = interestRepository.findById(interestId) + .orElseThrow(InterestNotFoundException::new); + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + if(subscribeRepository.existsByInterestAndUser(interest, user)){ + throw new SubscribeDuplicateException(); + } + log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); + interest.addSubscriberCount(); + log.info("관심사 구독 후 구독자 수: {}", interest.getSubscriberCount()); + + Subscribe subscribe = Subscribe.create(interest, user); + Subscribe saved = subscribeRepository.save(subscribe); + + /* 이벤트 발행 keyword의 내용 그대로 캐시에 저장되어서 조회했습니다! */ + List keywordNames = interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .toList(); + eventPublisher.publishEvent(SubscriptionAddedEvent.of( + user.getId(), + saved.getId(), + interest.getId(), + interest.getName(), + keywordNames, + interest.getSubscriberCount(), + saved.getCreatedAt() + )); + + return subscribeMapper.toSubscribeDto(saved); + } + + @Override + @Transactional + public void deleteSubscribe(Long interestId, Long userId) { + + Interest interest = interestRepository.findById(interestId) + .orElseThrow(InterestNotFoundException::new); + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + Subscribe subscribe = subscribeRepository.findByInterestAndUser(interest,user) + .orElseThrow(SubscribeNotFoundException::new); + + subscribeRepository.delete(subscribe); + + eventPublisher.publishEvent(SubscriptionRemovedEvent.of(user.getId(), subscribe.getId(), interest.getId())); + + log.info("현재 관심사 구독자 수 : {}", interest.getSubscriberCount()); + interest.cancelSubscriberCount(); + log.info("관심사 구독 취소 후 구독자 수: {}", interest.getSubscriberCount()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/User.java b/monew-api/src/main/java/com/monew/monew_api/user/User.java new file mode 100644 index 0000000..85199a5 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/User.java @@ -0,0 +1,52 @@ +package com.monew.monew_api.user; + +import com.monew.monew_api.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseTimeEntity { + + @Column(nullable = false, unique = true, length = 255) + private String email; + + @Column(nullable = false, length = 100) + private String nickname; + + @Column(nullable = false, length = 100) + private String password; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + public User(String email, String nickname, String password) { + this.email = email; + this.nickname = nickname; + this.password = password; + } + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void updatePassword(String password) { + this.password = password; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java b/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java new file mode 100644 index 0000000..7c4bd3a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/controller/UserController.java @@ -0,0 +1,66 @@ +package com.monew.monew_api.user.controller; + +import com.monew.monew_api.user.dto.*; +import com.monew.monew_api.user.dto.UserDto; +import com.monew.monew_api.user.dto.UserLoginRequest; +import com.monew.monew_api.user.dto.UserRegisterRequest; +import com.monew.monew_api.user.dto.UserUpdateRequest; +import com.monew.monew_api.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping("/api/users") + public ResponseEntity signup(@Valid @RequestBody UserRegisterRequest request) { + log.info("[API 요청] POST /api/users - 회원가입 요청, 이메일: {}", request.getEmail()); + UserDto response = userService.signup(request); + log.info("[API 응답] POST /api/users - 회원가입 성공, 사용자 ID: {}", response.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/api/users/login") + public ResponseEntity login(@Valid @RequestBody UserLoginRequest request) { + log.info("[API 요청] POST /api/users/login - 로그인 요청, 이메일: {}", request.getEmail()); + UserDto response = userService.login(request); + log.info("[API 응답] POST /api/users/login - 로그인 성공, 사용자 ID: {}", response.getId()); + return ResponseEntity.ok() + .header("MoNew-Request-User-ID", response.getId().toString()) + .body(response); + } + + @PatchMapping("/api/users/{userId}") + public ResponseEntity updateUser( + @PathVariable Long userId, + @Valid @RequestBody UserUpdateRequest request) { + log.info("[API 요청] PATCH /api/users/{} - 사용자 정보 수정 요청", userId); + UserDto response = userService.updateUser(userId, request); + log.info("[API 응답] PATCH /api/users/{} - 사용자 정보 수정 성공", userId); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/api/users/{userId}") + public ResponseEntity softDeleteUser(@PathVariable Long userId) { + log.info("[API 요청] DELETE /api/users/{} - 사용자 삭제 요청", userId); + userService.softDeleteUser(userId); + log.info("[API 응답] DELETE /api/users/{} - 사용자 삭제 성공", userId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/api/users/{userId}/hard") + public ResponseEntity hardDeleteUser(@PathVariable Long userId) { + log.info("[API 요청] DELETE /api/users/{}/hard - 사용자 영구 삭제 요청", userId); + userService.hardDeleteUser(userId); + log.info("[API 응답] DELETE /api/users/{}/hard - 사용자 영구 삭제 성공", userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java new file mode 100644 index 0000000..d229cce --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserDto.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.user.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class UserDto { + + private Long id; + private String email; + private String nickname; + private LocalDateTime createdAt; +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java new file mode 100644 index 0000000..1dc3cb0 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserLoginRequest.java @@ -0,0 +1,23 @@ +package com.monew.monew_api.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserLoginRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; + + public UserLoginRequest(String email, String password) { + this.email = email; + this.password = password; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java new file mode 100644 index 0000000..6db35ef --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserRegisterRequest.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserRegisterRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + @Size(max = 255, message = "이메일은 255자를 초과할 수 없습니다.") + private String email; + + @NotBlank(message = "닉네임은 필수입니다.") + @Size(max = 100, message = "닉네임은 100자를 초과할 수 없습니다.") + private String nickname; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 1, max = 100, message = "비밀번호는 100자 이하여야 합니다.") + private String password; + + public UserRegisterRequest(String email, String nickname, String password) { + this.email = email; + this.nickname = nickname; + this.password = password; + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java new file mode 100644 index 0000000..6dd9bbb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/dto/UserUpdateRequest.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.user.dto; + +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UserUpdateRequest { + + @Size(max = 100, message = "닉네임은 100자를 초과할 수 없습니다.") + private String nickname; + + public UserUpdateRequest(String nickname) { + this.nickname = nickname; + } + + public boolean hasNickname() { + return nickname != null && !nickname.isBlank(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java b/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java new file mode 100644 index 0000000..ccacd5a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/repository/UserRepository.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.user.repository; + +import com.monew.monew_api.user.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + boolean existsByEmail(String email); + + Optional findByEmail(String email); + + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByEmailAndDeletedAtIsNull(String email); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java b/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java new file mode 100644 index 0000000..d70127e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/user/service/UserService.java @@ -0,0 +1,139 @@ +package com.monew.monew_api.user.service; + +import com.monew.monew_api.common.exception.user.UserEmailDuplicateException; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.common.exception.user.UserUnauthorizedException; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.dto.*; +import com.monew.monew_api.user.dto.UserDto; +import com.monew.monew_api.user.dto.UserLoginRequest; +import com.monew.monew_api.user.dto.UserRegisterRequest; +import com.monew.monew_api.user.dto.UserUpdateRequest; +import com.monew.monew_api.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserDto signup(UserRegisterRequest request) { + log.info("[회원가입 시도] 이메일: {}, 닉네임: {}", request.getEmail(), request.getNickname()); + + // 이메일 중복 체크 + if (userRepository.existsByEmail(request.getEmail())) { + log.warn("[회원가입 실패] 이메일 중복: {}", request.getEmail()); + throw new UserEmailDuplicateException(); + } + + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(request.getPassword()); + + // 사용자 생성 + User user = User.builder() + .email(request.getEmail()) + .nickname(request.getNickname()) + .password(encodedPassword) + .build(); + + User savedUser = userRepository.save(user); + log.info("[회원가입 성공] 사용자 ID: {}, 이메일: {}", savedUser.getId(), savedUser.getEmail()); + + return UserDto.builder() + .id(savedUser.getId()) + .email(savedUser.getEmail()) + .nickname(savedUser.getNickname()) + .createdAt(savedUser.getCreatedAt()) + .build(); + } + + public UserDto login(UserLoginRequest request) { + log.info("[로그인 시도] 이메일: {}", request.getEmail()); + + // 이메일로 사용자 찾기 (논리삭제되지 않은 사용자만) + User user = userRepository.findByEmailAndDeletedAtIsNull(request.getEmail()) + .orElseThrow(() -> { + log.warn("[로그인 실패] 존재하지 않는 사용자: {}", request.getEmail()); + return new UserUnauthorizedException(); + }); + + // 비밀번호 검증 + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + log.warn("[로그인 실패] 비밀번호 불일치: {}", request.getEmail()); + throw new UserUnauthorizedException(); + } + + log.info("[로그인 성공] 사용자 ID: {}, 이메일: {}", user.getId(), user.getEmail()); + + return UserDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .createdAt(user.getCreatedAt()) + .build(); + } + + @Transactional + public UserDto updateUser(Long userId, UserUpdateRequest request) { + log.info("[사용자 정보 수정 시도] 사용자 ID: {}", userId); + + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> { + log.warn("[사용자 정보 수정 실패] 사용자를 찾을 수 없음: {}", userId); + return new UserNotFoundException(); + }); + + // 닉네임 업데이트 + if (request.hasNickname()) { + log.debug("[닉네임 변경] 사용자 ID: {}, 변경 전: {}, 변경 후: {}", + userId, user.getNickname(), request.getNickname()); + user.updateNickname(request.getNickname()); + } + + log.info("[사용자 정보 수정 성공] 사용자 ID: {}", userId); + + return UserDto.builder() + .id(user.getId()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .createdAt(user.getCreatedAt()) + .build(); + } + + @Transactional + public void softDeleteUser(Long userId) { + log.info("[사용자 삭제 시도] 사용자 ID: {}", userId); + + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> { + log.warn("[사용자 삭제 실패] 사용자를 찾을 수 없음: {}", userId); + return new UserNotFoundException(); + }); + + user.softDelete(); + log.info("[사용자 삭제 성공] 사용자 ID: {}, 이메일: {}", userId, user.getEmail()); + } + + @Transactional + public void hardDeleteUser(Long userId) { + log.info("[사용자 영구 삭제 시도] 사용자 ID: {}", userId); + + User user = userRepository.findById(userId) + .orElseThrow(() -> { + log.warn("[사용자 영구 삭제 실패] 사용자를 찾을 수 없음: {}", userId); + return new UserNotFoundException(); + }); + + userRepository.delete(user); + log.warn("[사용자 영구 삭제 완료] 사용자 ID: {}, 이메일: {}", userId, user.getEmail()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java new file mode 100644 index 0000000..15529c9 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/controller/UserActivityController.java @@ -0,0 +1,44 @@ +package com.monew.monew_api.useractivity.controller; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.service.UserActivityCacheService; +import com.monew.monew_api.useractivity.service.UserActivityService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/user-activities") +@RequiredArgsConstructor +public class UserActivityController { + + private final UserActivityService userActivityService; + private final UserActivityCacheService userActivityCacheService; + + /* + userActivityCacheService. + mongoDB 사용 시 getUserActivityWithCache 메서드 + + userActivityService. + 단일 쿼리 사용시 getUserActivitySingleQuery 메서드 (네이티브 쿼리) + 여러 쿼리 사용시 getUserActivity 메서드 + */ + @GetMapping("/{userId}") + public ResponseEntity getUserActivity( + @PathVariable String userId, + @RequestHeader(value = "MoNew-Request-User-ID", required = false) Long requesterId + ) { + log.info("[활동내역 API 요청]: userId={}", userId); + +// UserActivityDto activity = userActivityService.getUserActivitySingleQuery(userId); + UserActivityDto activity = userActivityCacheService.getUserActivityWithCache(userId); + + log.info("[활동내역 API 응답]: userId={}, Subscriptions_size={}, Comments_size={}, CommentLikes_size={}, ArticleViews_size={}", + activity.getId(), activity.getSubscriptions().size(), activity.getComments().size(), + activity.getCommentLikes().size(), activity.getArticleViews().size()); + + return ResponseEntity.ok(activity); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java new file mode 100644 index 0000000..9da4f28 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/ReverseIndexDocument.java @@ -0,0 +1,78 @@ +package com.monew.monew_api.useractivity.document; + +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +/* + 역인덱스 문서 + - key : id 패턴 "도메인_{id}_행동" + - 댓글 작성자: "comment_{id}_author" -> {userIds} + - 댓글 좋아요: "comment_{id}_likes" -> {userIds} + - 기사 조회: "article_{id}_views" -> {userIds} + */ +@Document(collection = "reverse_indexes") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReverseIndexDocument { + + @Id + private String id; + + @Builder.Default + private Set userIds = new HashSet<>(); + + private LocalDateTime createdAt; + + @Indexed(name = "index_ttl", expireAfter = "1h") + private LocalDateTime updatedAt; + + /** + * 댓글 작성자 역인덱스 키 생성 + * + * @param commentId 댓글 ID + * @return "comment_{commentId}_author" + */ + public static String makeCommentAuthorKey(Long commentId) { + return "comment_" + commentId + "_author"; + } + + /** + * 댓글 좋아요 역인덱스 키 생성 + * + * @param commentId 댓글 ID + * @return "comment_{commentId}_likes" + */ + public static String makeCommentLikesKey(Long commentId) { + return "comment_" + commentId + "_likes"; + } + + /** + * 기사 조회 역인덱스 키 생성 + * + * @param articleId 기사 ID + * @return "article_{articleId}_views" + */ + public static String makeArticleViewsKey(Long articleId) { + return "article_" + articleId + "_views"; + } + + /** + * Interest 구독자 역인덱스 키 생성 + * @param interestId 관심사 ID + * @return "interest_{interestId}_subs" + */ + public static String makeInterestSubscribersKey(Long interestId) { + return "interest_" + interestId + "_subs"; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java new file mode 100644 index 0000000..ed35f32 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/document/UserActivityCacheDocument.java @@ -0,0 +1,39 @@ +package com.monew.monew_api.useractivity.document; + +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.List; + +@Document(collection = "user_activity_cache") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserActivityCacheDocument { + + @Id + private String id; + + private String email; + private String nickname; + private LocalDateTime createdAt; + + private List subscriptions; + private List comments; + private List commentLikes; + private List articleViews; + + @Indexed(name = "cache_ttl", expireAfter = "1h") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java new file mode 100644 index 0000000..93ee743 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/ArticleViewActivityDto.java @@ -0,0 +1,60 @@ +package com.monew.monew_api.useractivity.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ArticleViewActivityDto { + + @JsonProperty("id") + @JsonAlias({"view_id"}) + private String id; + + @JsonProperty("viewedBy") + @JsonAlias({"viewed_by"}) + private String viewedBy; + + @JsonProperty("createdAt") + @JsonAlias({"created_at"}) + private LocalDateTime createdAt; + + @JsonProperty("articleId") + @JsonAlias({"article_id"}) + private String articleId; + + @JsonProperty("source") + private String source; + + @JsonProperty("sourceUrl") + @JsonAlias({"source_url"}) + private String sourceUrl; + + @JsonProperty("articleTitle") + @JsonAlias({"article_title"}) + private String articleTitle; + + @JsonProperty("articlePublishedDate") + @JsonAlias({"publish_date", "article_published_date"}) + private LocalDateTime articlePublishedDate; + + @JsonProperty("articleSummary") + @JsonAlias({"summary", "article_summary"}) + private String articleSummary; + + @JsonProperty("articleCommentCount") + @JsonAlias({"comment_count", "article_comment_count"}) + private Integer articleCommentCount; + + @JsonProperty("articleViewCount") + @JsonAlias({"view_count", "article_view_count"}) + private Integer articleViewCount; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java new file mode 100644 index 0000000..f713a95 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentActivityDto.java @@ -0,0 +1,48 @@ +package com.monew.monew_api.useractivity.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CommentActivityDto { + + @JsonProperty("id") + @JsonAlias({"comment_id"}) + private String id; + + @JsonProperty("articleId") + @JsonAlias({"article_id"}) + private String articleId; + + @JsonProperty("articleTitle") + @JsonAlias({"article_title"}) + private String articleTitle; + + @JsonProperty("userId") + @JsonAlias({"user_id"}) + private String userId; + + @JsonProperty("userNickname") + @JsonAlias({"user_nickname"}) + private String userNickname; + + @JsonProperty("content") + private String content; + + @JsonProperty("likeCount") + @JsonAlias({"like_count"}) + private Integer likeCount; + + @JsonProperty("createdAt") + @JsonAlias({"created_at"}) + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java new file mode 100644 index 0000000..068cf1c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/CommentLikeActivityDto.java @@ -0,0 +1,57 @@ +package com.monew.monew_api.useractivity.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CommentLikeActivityDto { + + @JsonProperty("id") + @JsonAlias({"like_id"}) + private String id; + + @JsonProperty("createdAt") + @JsonAlias({"created_at"}) + private LocalDateTime createdAt; + + @JsonProperty("commentId") + @JsonAlias({"comment_id"}) + private String commentId; + + @JsonProperty("articleId") + @JsonAlias({"article_id"}) + private String articleId; + + @JsonProperty("articleTitle") + @JsonAlias({"article_title"}) + private String articleTitle; + + @JsonProperty("commentAuthorId") + @JsonAlias({"comment_user_id"}) + private String commentUserId; + + @JsonProperty("commentUserNickname") + @JsonAlias({"comment_user_nickname"}) + private String commentUserNickname; + + @JsonProperty("commentContent") + @JsonAlias({"comment_content"}) + private String commentContent; + + @JsonProperty("commentLikeCount") + @JsonAlias({"comment_like_count"}) + private Integer commentLikeCount; + + @JsonProperty("commentCreatedAt") + @JsonAlias({"comment_created_at"}) + private LocalDateTime commentCreatedAt; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java new file mode 100644 index 0000000..02e9955 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/SubscribesActivityDto.java @@ -0,0 +1,45 @@ +package com.monew.monew_api.useractivity.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.monew.monew_api.useractivity.json.CommaSeparatedToListDeserializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubscribesActivityDto { + + @JsonProperty("id") + @JsonAlias({"subscription_id"}) + private String id; + + @JsonProperty("createdAt") + @JsonAlias({"created_at", "subscription_created_at"}) + private LocalDateTime createdAt; + + @JsonProperty("interestId") + @JsonAlias({"interest_id"}) + private String interestId; + + @JsonProperty("interestName") + @JsonAlias({"interest_name"}) + private String interestName; + + @JsonProperty("interestSubscriberCount") + @JsonAlias({"interest_subscriber_count", "subscriber_count"}) + private Integer interestSubscriberCount; + + @JsonProperty("interestKeywords") + @JsonAlias({"interest_keywords", "keywords"}) + @JsonDeserialize(using = CommaSeparatedToListDeserializer.class) + private List interestKeywords; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java new file mode 100644 index 0000000..d46b2fd --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/dto/UserActivityDto.java @@ -0,0 +1,24 @@ +package com.monew.monew_api.useractivity.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserActivityDto { + private String id; + private String email; + private String nickname; + private LocalDateTime createdAt; + private List subscriptions; + private List comments; + private List commentLikes; + private List articleViews; +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java new file mode 100644 index 0000000..8760828 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/event/CacheSaveEvent.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.useractivity.event; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +/** + * 캐시 저장 이벤트 + * PostgreSQL 조회 후 MongoDB에 비동기 캐시 저장 + * useractivity 내부, create 전략 + * @param userId + * @param data + */ +public record CacheSaveEvent( + String userId, + UserActivityDto data +) { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java new file mode 100644 index 0000000..1212774 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/json/CommaSeparatedToListDeserializer.java @@ -0,0 +1,29 @@ +package com.monew.monew_api.useractivity.json; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/* + CommaSeparatedToListDeserializer + - 콤마(,)로 구분된 문자열을 List으로 변환하는 Jackson Deserializer + single 쿼리로 조회한 활동 내역에서, 관심사 키워드(interest_keywords) 필드가 + 콤마로 구분된 문자열로 반환되기 때문에 이를 List으로 변환하기 위해 사용 + */ +public class CommaSeparatedToListDeserializer extends JsonDeserializer> { + @Override + public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String text = p.getValueAsString(); + if (text == null || text.isEmpty()) return Collections.emptyList(); + return Arrays.stream(text.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java new file mode 100644 index 0000000..94dbf59 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/ArticleViewEventListener.java @@ -0,0 +1,56 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.article.event.ArticleViewedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 기사 조회 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleViewEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ArticleViewedEvent event) { + log.info("[Listener] 기사 조회 이벤트 수신: articleId={}, userId={}", + event.articleId(), event.userId()); + + try { + // 1. 조회수 증가 (기존 조회한 사람들) + cacheUpdateService.incrementArticleViewCount( + event.articleId(), + event.getDelta() + ); + + // 2. 조회한 사람 캐시에 추가 + 역인덱스 생성 + cacheUpdateService.addArticleView( + event.viewId(), + event.userId(), + event.createdAt(), + event.articleId(), + event.source(), + event.sourceUrl(), + event.articleTitle(), + event.articlePublishedDate(), + event.articleSummary(), + event.articleCommentCount(), + event.articleViewCount() + ); + + log.info("[Listener] 기사 조회 캐시 업데이트 완료: articleId={}", event.articleId()); + + } catch (Exception e) { + log.error("[Listener] 기사 조회 캐시 업데이트 실패: articleId={}", event.articleId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java new file mode 100644 index 0000000..b0ff91e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CacheSaveEventListener.java @@ -0,0 +1,37 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.event.CacheSaveEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 캐시 저장 이벤트 리스너 + * PostgreSQL 조회 후 MongoDB에 비동기 저장 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheSaveEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CacheSaveEvent event) { + log.info("[Listener] 캐시 저장 이벤트 수신: userId={}", event.userId()); + + try { + cacheUpdateService.saveCache( + event.userId(), + event.data() + ); + } catch (Exception e) { + log.error("[Listener] 캐시 저장 실패: userId={}", event.userId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java new file mode 100644 index 0000000..73bbcec --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentContentEditedEventListener.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentContentEditedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentContentEditedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentContentEditedEvent e) { + cacheUpdateService.updateCommentContent(e.commentId(), e.newContent()); + log.info("[Listener] CommentContentEdited handled: commentId={}", e.commentId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java new file mode 100644 index 0000000..86d3ef6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentCreateEventListener.java @@ -0,0 +1,53 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentCreatedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 작성 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentCreateEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentCreatedEvent event) { + log.info("[Listener] 댓글 작성 이벤트 수신: commentId={}, userId={}, articleId={}", + event.commentId(), event.userId(), event.articleId()); + + try { + // 1. 기사 댓글수 증가 (기존 조회한 사람들) + cacheUpdateService.incrementArticleCommentCount( + event.articleId(), + event.getDelta() + ); + + // 2. 작성자 캐시에 댓글 추가 + 역인덱스 생성 + cacheUpdateService.addComment( + event.commentId(), + event.userId(), + event.userNickname(), + event.articleId(), + event.articleTitle(), + event.content(), + event.likeCount(), + event.createdAt() + ); + + log.info("[Listener] 댓글 작성 캐시 업데이트 완료: commentId={}", event.commentId()); + + } catch (Exception e) { + log.error("[Listener] 댓글 작성 캐시 업데이트 실패: commentId={}", event.commentId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java new file mode 100644 index 0000000..51a9c15 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentDeletedEventListener.java @@ -0,0 +1,33 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentDeletedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 삭제 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentDeletedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentDeletedEvent event) { + log.info("[Listener] 댓글 삭제 이벤트 수신: commentId={}", event.commentId()); + + try { + cacheUpdateService.removeComment(event.commentId()); + } catch (Exception e) { + log.error("[Listener] 댓글 삭제 캐시 처리 실패: commentId={}", event.commentId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java new file mode 100644 index 0000000..cc88a3d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentLikedEventListener.java @@ -0,0 +1,43 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentLikedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 좋아요 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentLikedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentLikedEvent e) { + cacheUpdateService.addCommentLike( + e.likeId(), + e.likedByUserId(), + e.likeCreatedAt(), + e.commentId(), + e.articleId(), + e.articleTitle(), + e.commentAuthorId(), + e.commentUserNickname(), + e.commentContent(), + e.commentLikeCount(), + e.commentCreatedAt() + ); + cacheUpdateService.updateCommentLikeCount(e.commentId(), +1); + + log.info("[Listener] CommentLikedEvent handled: commentId={}, likedBy={}", + e.commentId(), e.likedByUserId()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java new file mode 100644 index 0000000..2cd67d6 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/CommentUnlikedEventListener.java @@ -0,0 +1,31 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.comments.event.CommentUnlikedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 댓글 좋아요 취소 이벤트 리스너 + * 사용자가 댓글 좋아요를 취소했을 때 캐시 업데이트 수행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommentUnlikedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CommentUnlikedEvent e) { + cacheUpdateService.updateCommentLikeCount(e.commentId(), -1); + cacheUpdateService.removeCommentLike(e.likedByUserId(), e.commentId()); + log.info("[Listener] CommentUnlikedEvent handled: commentId={}, likedBy={}", + e.commentId(), e.likedByUserId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java new file mode 100644 index 0000000..50c662b --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestDeletedEventListener.java @@ -0,0 +1,25 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.interest.event.InterestDeletedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class InterestDeletedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(InterestDeletedEvent e) { + cacheUpdateService.removeInterest(e.interestId()); + log.info("[Listener] InterestDeleted handled: interestId={}", e.interestId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java new file mode 100644 index 0000000..003c0fb --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/InterestUpdateEventListener.java @@ -0,0 +1,37 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.interest.event.InterestUpdatedEvent; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Interest 정보 변경 이벤트 리스너 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class InterestUpdateEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(InterestUpdatedEvent event) { + log.info("[Listener] Interest 정보 변경 이벤트 수신: interestId={}", + event.interestId()); + + try { + cacheUpdateService.updateInterestKeyword( + event.interestId(), + event.newKeywords() + ); + } catch (Exception e) { + log.error("[Listener] Interest 정보 캐시 업데이트 실패: interestId={}", event.interestId(), e); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java new file mode 100644 index 0000000..62e4f80 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionAddedEventListener.java @@ -0,0 +1,34 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.subscribe.event.SubscriptionAddedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionAddedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(SubscriptionAddedEvent e) { + cacheUpdateService.addSubscription( + e.userId(), + e.subscriptionId(), + e.interestId(), + e.interestName(), + e.interestKeywords(), + e.interestSubscriberCount(), + e.createdAt() + ); + log.info("[Listener] SubscriptionAdded handled: userId={}, subId={}, interestId={}", + e.userId(), e.subscriptionId(), e.interestId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java new file mode 100644 index 0000000..a9e13ff --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/listener/SubscriptionRemovedEventListener.java @@ -0,0 +1,26 @@ +package com.monew.monew_api.useractivity.listener; + +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.subscribe.event.SubscriptionRemovedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SubscriptionRemovedEventListener { + + private final CacheUpdateService cacheUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(SubscriptionRemovedEvent e) { + cacheUpdateService.removeSubscription(e.userId(), e.subscriptionId(), e.interestId()); + log.info("[Listener] SubscriptionRemoved handled: userId={}, subId={}", + e.userId(), e.subscriptionId()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java new file mode 100644 index 0000000..251a9ea --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityDocumentMapper.java @@ -0,0 +1,12 @@ +package com.monew.monew_api.useractivity.mapper; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface UserActivityDocumentMapper { + @Mapping(target = "updatedAt", expression = "java(java.time.LocalDateTime.now())") + UserActivityCacheDocument toDocument(UserActivityDto dto); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java new file mode 100644 index 0000000..399bc6e --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityMapper.java @@ -0,0 +1,81 @@ +package com.monew.monew_api.useractivity.mapper; + +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.user.User; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.*; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring") +public interface UserActivityMapper { + + default UserActivityDto toUserActivityDto( + User user, + List subscriptions, + List comments, + List likes, + List views + ) { + return UserActivityDto.builder() + .id(String.valueOf(user.getId())) + .email(user.getEmail()) + .nickname(user.getNickname()) + .createdAt(user.getCreatedAt()) + .subscriptions(toSubscriptionDtos(subscriptions)) + .comments(toCommentDtos(comments)) + .commentLikes(toCommentLikeDtos(likes)) + .articleViews(views) + .build(); + } + + @Mapping(target = "id", expression = "java(String.valueOf(subscription.getId()))") + @Mapping(target = "interestId", expression = "java(String.valueOf(subscription.getInterest().getId()))") + @Mapping(target = "interestName", source = "interest.name") + @Mapping(target = "interestKeywords", expression = "java(mapKeywords(subscription.getInterest()))") + @Mapping(target = "interestSubscriberCount", source = "interest.subscriberCount") + @Mapping(target = "createdAt", source = "createdAt") + SubscribesActivityDto toSubscriptionDto(Subscribe subscription); + + List toSubscriptionDtos(List subscriptions); + + @Mapping(target = "id", expression = "java(String.valueOf(comment.getId()))") + @Mapping(target = "articleId", expression = "java(String.valueOf(comment.getArticle().getId()))") + @Mapping(target = "articleTitle", source = "article.title") + @Mapping(target = "userId", expression = "java(String.valueOf(comment.getUser().getId()))") + @Mapping(target = "userNickname", source = "user.nickname") + @Mapping(target = "content", source = "content") + @Mapping(target = "likeCount", source = "likeCount") + @Mapping(target = "createdAt", source = "createdAt") + CommentActivityDto toCommentDto(Comment comment); + + List toCommentDtos(List comments); + + @Mapping(target = "id", expression = "java(String.valueOf(commentLike.getId()))") + @Mapping(target = "createdAt", source = "createdAt") + @Mapping(target = "commentId", expression = "java(String.valueOf(commentLike.getComment().getId()))") + @Mapping(target = "articleId", expression = "java(String.valueOf(commentLike.getComment().getArticle().getId()))") + @Mapping(target = "articleTitle", source = "comment.article.title") + @Mapping(target = "commentUserId", source = "comment.user.id") + @Mapping(target = "commentUserNickname", source = "comment.user.nickname") + @Mapping(target = "commentContent", source = "comment.content") + @Mapping(target = "commentLikeCount", source = "comment.likeCount") + @Mapping(target = "commentCreatedAt", source = "comment.createdAt") + CommentLikeActivityDto toCommentLikeDto(CommentLike commentLike); + + List toCommentLikeDtos(List commentLikes); + + UserActivityDto toDto(UserActivityCacheDocument document); + + default List mapKeywords(Interest interest) { + return interest.getKeywords().stream() + .map(ik -> ik.getKeyword().getKeyword()) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java new file mode 100644 index 0000000..25626d8 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/mapper/UserActivityRawMapper.java @@ -0,0 +1,70 @@ +package com.monew.monew_api.useractivity.mapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserActivityRawMapper { + + private final ObjectMapper objectMapper; + + /** + * UserActivityRaw (Record) → UserActivityDto 변환 + */ + public UserActivityDto toDto(UserActivityRaw record) { + if (record == null) { + return null; + } + + return UserActivityDto.builder() + .id(String.valueOf(record.id())) + .email(record.email()) + .nickname(record.nickname()) + .createdAt(record.createdAt()) + .subscriptions(parseJsonList( + record.subscriptions(), + new TypeReference>() {} + )) + .comments(parseJsonList( + record.comments(), + new TypeReference>() {} + )) + .commentLikes(parseJsonList( + record.likes(), + new TypeReference>() {} + )) + .articleViews(parseJsonList( + record.views(), + new TypeReference>() {} + )) + .build(); + } + + /** + * JSON String → List 파싱 + */ + private List parseJsonList(String json, TypeReference> typeRef) { + if (json == null || json.isBlank() || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + + try { + List result = objectMapper.readValue(json, typeRef); + return result != null ? result : Collections.emptyList(); + } catch (JsonProcessingException e) { + log.error("JSON 파싱 실패: {}", json, e); + return Collections.emptyList(); + } + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java new file mode 100644 index 0000000..9a92a9f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/ReverseIndexRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.monew.monew_api.useractivity.repository.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.repository.ReverseIndexCustomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@Repository +@RequiredArgsConstructor +public class ReverseIndexRepositoryImpl implements ReverseIndexCustomRepository { + + private final MongoTemplate mongoTemplate; + + @Override + public void addUser(String indexKey, String userId) { + Query q = Query.query(Criteria.where("_id").is(indexKey)); + Update u = new Update() + .addToSet("userIds", userId) + .set("updatedAt", LocalDateTime.now()); + mongoTemplate.upsert(q, u, ReverseIndexDocument.class); + } + + @Override + public void removeUser(String indexKey, String userId) { + Query q = Query.query(Criteria.where("_id").is(indexKey)); + Update u = new Update() + .pull("userIds", userId) + .set("updatedAt", LocalDateTime.now()); + mongoTemplate.updateFirst(q, u, ReverseIndexDocument.class); + } + + @Override + public Set findUserIdsByKeys(Set indexKeys) { + if (indexKeys.isEmpty()) return Collections.emptySet(); + Query q = Query.query(Criteria.where("_id").in(indexKeys)); + return mongoTemplate.find(q, ReverseIndexDocument.class) + .stream() + .flatMap(doc -> doc.getUserIds().stream()) + .collect(Collectors.toSet()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java new file mode 100644 index 0000000..ae69fbc --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityCacheRepositoryImpl.java @@ -0,0 +1,197 @@ +package com.monew.monew_api.useractivity.repository.Impl; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; +import com.monew.monew_api.useractivity.repository.UserActivityCacheCustomRepository; +import com.mongodb.BasicDBObject; +import com.mongodb.client.result.UpdateResult; +import lombok.RequiredArgsConstructor; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.springframework.data.mongodb.core.query.Criteria.where; + +@Repository +@RequiredArgsConstructor +public class UserActivityCacheRepositoryImpl implements UserActivityCacheCustomRepository { + + private final MongoTemplate mongo; + + @Override + public long incCommentLikeCount(Set userIds, String commentId, int delta) { + if (userIds.isEmpty()) return 0; + + var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); + var u1 = new Update() + .inc("comments.$.likeCount", delta) + .set("updatedAt", LocalDateTime.now()); + UpdateResult r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); + + var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); + var u2 = new Update().inc("commentLikes.$.commentLikeCount", delta) + .set("updatedAt", LocalDateTime.now()); + UpdateResult r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); + + return r1.getModifiedCount() + r2.getModifiedCount(); + } + + @Override + public long incArticleViewCount(Set userIds, String articleId, int delta) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); + var u = new Update() + .inc("articleViews.$.articleViewCount", delta) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long incArticleCommentCount(Set userIds, String articleId, int delta) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds).and("articleViews.articleId").is(articleId)); + var u = new Update() + .inc("articleViews.$.articleCommentCount", delta) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("commentLikes") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pullCommentLike(String userId, String commentId) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .pull("commentLikes", new BasicDBObject("commentId", commentId)) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushComment(String userId, CommentActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("comments") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long updateCommentContentForUsers(Set userIds, String commentId, String newContent) { + if (userIds.isEmpty()) return 0; + + var q1 = Query.query(where("_id").in(userIds).and("comments.id").is(commentId)); + var u1 = new Update() + .set("comments.$.content", newContent) + .set("updatedAt", LocalDateTime.now()); + var r1 = mongo.updateMulti(q1, u1, UserActivityCacheDocument.class); + + var q2 = Query.query(where("_id").in(userIds).and("commentLikes.commentId").is(commentId)); + var u2 = new Update() + .set("commentLikes.$[l].commentContent", newContent) + .set("updatedAt", LocalDateTime.now()); + u2.filterArray(where("l.commentId").is(commentId)); + var r2 = mongo.updateMulti(q2, u2, UserActivityCacheDocument.class); + + return r1.getModifiedCount() + r2.getModifiedCount(); + } + + + @Override + public long removeCommentEverywhere(Set userIds, String commentId) { + if (userIds.isEmpty()) return 0; + var q = Query.query(where("_id").in(userIds)); + var u = new Update() + .pull("comments", new BasicDBObject("id", commentId)) + .pull("commentLikes", new BasicDBObject("commentId", commentId)) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .push("articleViews") + .atPosition(0) + .slice(keepLatest) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long updateInterestKeywords(String interestId, List newKeywords) { + var q = Query.query(where("subscriptions.interestId").is(interestId)); + var u = new Update() + .set("subscriptions.$[it].interestKeywords", newKeywords) + .set("updatedAt", LocalDateTime.now()); + u.filterArray(where("it.interestId").is(interestId)); + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long removeInterestEverywhere(Set userIds, String interestId) { + if (userIds.isEmpty()) return 0; + + Query q = Query.query(Criteria.where("_id").in(userIds)); + Update u = new Update() + .pull("subscriptions", new BasicDBObject("interestId", interestId)) + .set("updatedAt", LocalDateTime.now()); + + return mongo.updateMulti(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } + + @Override + public long addSubscription(String userId, SubscribesActivityDto dto) { + var q = Query.query(where("_id").is(userId)); + + if (dto.getId() != null) { + var pullExisting = new Update() + .pull("subscriptions", Query.query(where("id").is(dto.getId())).getQueryObject()) + .set("updatedAt", LocalDateTime.now()); + mongo.updateFirst(q, pullExisting, UserActivityCacheDocument.class); + } + + var push = new Update() + .push("subscriptions") + .atPosition(0) + .slice(10) + .each(dto) + .set("updatedAt", LocalDateTime.now()); + + var result = mongo.updateFirst(q, push, UserActivityCacheDocument.class); + return result.getModifiedCount(); + } + + @Override + public long removeSubscription(String userId, String subscriptionId) { + var q = Query.query(where("_id").is(userId)); + var u = new Update() + .pull("subscriptions", Query.query(where("id").is(subscriptionId)).getQueryObject()) + .set("updatedAt", LocalDateTime.now()); + return mongo.updateFirst(q, u, UserActivityCacheDocument.class).getModifiedCount(); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java new file mode 100644 index 0000000..7335e30 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/Impl/UserActivityRepositoryImpl.java @@ -0,0 +1,250 @@ +package com.monew.monew_api.useractivity.repository.Impl; + +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.interest.entity.QKeyword; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.repository.UserActivityRepository; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.Tuple; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.util.List; + +import static com.monew.monew_api.article.entity.QArticle.article; +import static com.monew.monew_api.article.entity.QArticleView.articleView; +import static com.monew.monew_api.comments.entity.QComment.comment; +import static com.monew.monew_api.comments.entity.QCommentLike.commentLike; +import static com.monew.monew_api.user.QUser.user; +import static com.monew.monew_api.interest.entity.QInterest.interest; +import static com.monew.monew_api.interest.entity.QInterestKeyword.interestKeyword; +import static com.monew.monew_api.subscribe.entity.QSubscribe.subscribe; + + +@Repository +@RequiredArgsConstructor +public class UserActivityRepositoryImpl implements UserActivityRepository { + + private final JPAQueryFactory queryFactory; + private final EntityManager entityManager; + + @Override + public List findSubscriptionsByUserId(Long userId) { + return queryFactory + .selectFrom(subscribe) + .join(subscribe.interest, interest).fetchJoin() + .leftJoin(interest.keywords, interestKeyword).fetchJoin() + .leftJoin(interestKeyword.keyword, QKeyword.keyword1).fetchJoin() + .where(subscribe.user.id.eq(userId)) + .distinct() + .fetch(); + } + + @Override + public List findRecentCommentsByUserId(Long userId) { + return queryFactory + .selectFrom(comment) + .join(comment.article, article).fetchJoin() + .join(comment.user, user).fetchJoin() + .where( + comment.user.id.eq(userId), + comment.deleted.isFalse(), + article.isDeleted.isFalse(), + user.deletedAt.isNull() + ) + .orderBy(comment.createdAt.desc()) + .limit(10) + .fetch(); + } + + @Override + public List findRecentLikesByUserId(Long userId) { + return queryFactory + .selectFrom(commentLike) + .join(commentLike.comment, comment).fetchJoin() + .join(comment.article, article).fetchJoin() + .join(comment.user, user).fetchJoin() + .where( + commentLike.user.id.eq(userId), + comment.deleted.eq(false), + article.isDeleted.eq(false), + user.deletedAt.isNull() + ) + .orderBy(commentLike.createdAt.desc()) + .limit(10) + .fetch(); + } + + @Override + public List findRecentViewsByUserId(Long userId) { + return queryFactory + .select(Projections.fields( + ArticleViewActivityDto.class, + articleView.id.stringValue().as("id"), + articleView.userId.stringValue().as("viewedBy"), + articleView.createdAt.as("createdAt"), + articleView.articleId.stringValue().as("articleId"), + article.source.as("source"), + article.sourceUrl.as("sourceUrl"), + article.title.as("articleTitle"), + article.publishDate.as("articlePublishedDate"), + article.summary.as("articleSummary"), + article.commentCount.as("articleCommentCount"), + article.viewCount.as("articleViewCount") + )) + .from(articleView) + .join(article).on(article.id.eq(articleView.articleId)) + .where( + articleView.userId.eq(userId), + article.isDeleted.eq(false) + ) + .orderBy(articleView.createdAt.desc()) + .limit(10) + .fetch(); + } + + @Override + public UserActivityRaw findUserActivityRaw(Long userId) { + String sql = """ + WITH recent_subscriptions AS ( + SELECT + s.id AS subscription_id, + s.user_id, + s.created_at AS subscription_created_at, + i.id AS interest_id, + i.name AS interest_name, + i.subscriber_count, + STRING_AGG(k.keyword, ',') AS keywords + FROM subscribes s + JOIN interests i ON s.interest_id = i.id + LEFT JOIN interest_keywords ik ON i.id = ik.interest_id + LEFT JOIN keywords k ON ik.keyword_id = k.id + WHERE s.user_id = :userId + GROUP BY s.id, s.user_id, s.created_at, i.id, i.name, i.subscriber_count + ORDER BY s.created_at DESC + ), + recent_comments AS ( + SELECT + c.id, + c.article_id, + c.user_id, + c.content, + c.like_count, + c.created_at, + a.title AS article_title, + u.nickname AS user_nickname + FROM comments c + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE c.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY c.created_at DESC + LIMIT 10 + ), + recent_likes AS ( + SELECT + cl.id, + cl.user_id, + cl.created_at, + cl.comment_id, + c.content AS comment_content, + c.like_count AS comment_like_count, + c.created_at AS comment_created_at, + c.user_id AS comment_user_id, + u.nickname AS comment_user_nickname, + a.id AS article_id, + a.title AS article_title + FROM comment_likes cl + JOIN comments c ON cl.comment_id = c.id + JOIN articles a ON c.article_id = a.id + JOIN users u ON c.user_id = u.id + WHERE cl.user_id = :userId + AND c.is_deleted = false + AND a.is_deleted = false + AND u.deleted_at IS NULL + ORDER BY cl.created_at DESC + LIMIT 10 + ), + recent_views AS ( + SELECT + av.id, + av.user_id, + av.created_at, + av.article_id, + a.source, + a.source_url, + a.title AS article_title, + a.publish_date, + a.summary, + a.comment_count, + a.view_count + FROM article_views av + JOIN articles a ON av.article_id = a.id + WHERE av.user_id = :userId + AND a.is_deleted = false + ORDER BY av.created_at DESC + LIMIT 10 + ) + SELECT + u.id as id, + u.email as email, + u.nickname as nickname, + u.created_at as createdAt, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rs) ORDER BY rs.subscription_created_at DESC) + FROM recent_subscriptions rs WHERE rs.user_id = u.id), + '[]'::jsonb + )::text as subscriptions, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rc) ORDER BY rc.created_at DESC) + FROM recent_comments rc WHERE rc.user_id = u.id), + '[]'::jsonb + )::text as comments, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rl) ORDER BY rl.created_at DESC) + FROM recent_likes rl WHERE rl.user_id = u.id), + '[]'::jsonb + )::text as likes, + COALESCE( + (SELECT jsonb_agg(to_jsonb(rv) ORDER BY rv.created_at DESC) + FROM recent_views rv WHERE rv.user_id = u.id), + '[]'::jsonb + )::text as views + FROM users u + WHERE u.id = :userId + """; + + Query query = entityManager.createNativeQuery(sql, Tuple.class); + query.setParameter("userId", userId); + + @SuppressWarnings("unchecked") + List results = query.getResultList(); + + if (results.isEmpty()) { + return null; + } + + Tuple tuple = results.get(0); + + return new UserActivityRaw( + tuple.get("id", Long.class), + tuple.get("email", String.class), + tuple.get("nickname", String.class), + tuple.get("createdat", Timestamp.class).toLocalDateTime(), + tuple.get("subscriptions", String.class), + tuple.get("comments", String.class), + tuple.get("likes", String.class), + tuple.get("views", String.class) + ); + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java new file mode 100644 index 0000000..bd93e79 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexCustomRepository.java @@ -0,0 +1,21 @@ +package com.monew.monew_api.useractivity.repository; + +import java.util.Set; + +public interface ReverseIndexCustomRepository { + /** + * 특정 인덱스 키에 사용자 ID 추가 + * @param indexKey + * @param userId + */ + void addUser(String indexKey, String userId); + + /** + * 특정 인덱스 키에서 사용자 ID 제거 + * @param indexKey + * @param userId + */ + void removeUser(String indexKey, String userId); + + Set findUserIdsByKeys(Set indexKeys); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java new file mode 100644 index 0000000..8080a8d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/ReverseIndexRepository.java @@ -0,0 +1,7 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ReverseIndexRepository extends MongoRepository, ReverseIndexCustomRepository { +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java new file mode 100644 index 0000000..e837cc9 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheCustomRepository.java @@ -0,0 +1,135 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.CommentActivityDto; +import com.monew.monew_api.useractivity.dto.CommentLikeActivityDto; +import com.monew.monew_api.useractivity.dto.SubscribesActivityDto; + +import java.util.List; +import java.util.Set; + +public interface UserActivityCacheCustomRepository { + /** + * 댓글 좋아요 수 증감 + * + * @param userIds 댓글 좋아요 수를 업데이트할 사용자 ID 집합 + * @param commentId 댓글 ID + * @param delta 증감 값 (1, -1) + * @return 업데이트된 캐시 데이터 수 + */ + long incCommentLikeCount(Set userIds, String commentId, int delta); + + /** + * 기사 조회수 증감 + * + * @param userIds 기사 조회수을 업데이트할 사용자 ID 집합 + * @param articleId 기사 ID + * @param delta 증감 값 (1) + * @return 업데이트된 캐시 데이터 수 + */ + long incArticleViewCount(Set userIds, String articleId, int delta); + + /** + * 기사 댓글수 증감 + * + * @param userIds 기사 댓글수를 업데이트할 사용자 ID 집합 + * @param articleId 기사 ID + * @param delta 증감 값 (1) + * @return 업데이트된 캐시 데이터 수 + */ + long incArticleCommentCount(Set userIds, String articleId, int delta); + + /** + * 댓글 좋아요 추가 + * + * @param userId 사용자 ID + * @param dto 댓글 좋아요 활동 DTO + * @param keepLatest 유지할 최신 항목 수 + * @return 업데이트된 캐시 데이터 수 + */ + long pushCommentLike(String userId, CommentLikeActivityDto dto, int keepLatest); + + /** + * 댓글 좋아요 제거 + * + * @param userId 사용자 ID + * @param commentId 댓글 ID + * @return 업데이트된 캐시 데이터 수 + */ + long pullCommentLike(String userId, String commentId); + + /** + * 댓글 추가 + * + * @param userId 사용자 ID + * @param dto 댓글 활동 DTO + * @param keepLatest 유지할 최신 항목 수 + * @return 업데이트된 캐시 데이터 수 + */ + long pushComment(String userId, CommentActivityDto dto, int keepLatest); + + /** + * 댓글 내용 수정 + * + * @param userIds 사용자 ID 집합 + * @param commentId 댓글 ID + * @param newContent 새로운 댓글 내용 + * @return 업데이트된 캐시 데이터 수 + */ + long updateCommentContentForUsers(Set userIds, String commentId, String newContent); + + /** + * 모든 사용자에 대해 댓글 제거 + * + * @param userIds 사용자 ID 집합 + * @param commentId 댓글 ID + * @return 업데이트된 캐시 데이터 수 + */ + long removeCommentEverywhere(Set userIds, String commentId); + + /** + * 기사 조회 활동 추가 + * + * @param userId 사용자 ID + * @param dto 기사 조회 활동 DTO + * @param keepLatest 유지할 최신 항목 수 + * @return 업데이트된 캐시 데이터 수 + */ + long pushArticleView(String userId, ArticleViewActivityDto dto, int keepLatest); + + /** + * Interest 키워드 업데이트 + * + * @param interestId 관심사 ID + * @param newKeywords 새로운 키워드 리스트 + * @return 업데이트된 캐시 데이터 수 + */ + long updateInterestKeywords(String interestId, List newKeywords); + + /** + * 모든 사용자에 대해 Interest 제거 + * + * @param userIds 사용자 ID 집합 + * @param interestId 관심사 ID + * @return 업데이트된 캐시 데이터 수 + */ + long removeInterestEverywhere(Set userIds, String interestId); + + /** + * 구독 추가 + * + * @param userId 사용자 ID + * @param dto 구독 활동 DTO + * @return 업데이트된 캐시 데이터 수 + */ + long addSubscription(String userId, SubscribesActivityDto dto); + + /** + * 구독 제거 + * + * @param userId 사용자 ID + * @param subscriptionId 구독 ID + * @return 업데이트된 캐시 데이터 수 + */ + long removeSubscription(String userId, String subscriptionId); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java new file mode 100644 index 0000000..e6e2e1d --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityCacheRepository.java @@ -0,0 +1,9 @@ +package com.monew.monew_api.useractivity.repository; + +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserActivityCacheRepository extends MongoRepository, UserActivityCacheCustomRepository { +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java new file mode 100644 index 0000000..1421760 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/UserActivityRepository.java @@ -0,0 +1,65 @@ +package com.monew.monew_api.useractivity.repository; + +/* +TODO: Entity 클래스 완성 되면 import 수정 + */ + +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; + +import java.util.List; + +public interface UserActivityRepository { + /* + 활동 내역을 4개의 쿼리로 처리 + UserActivityDto { + User + findSubscriptionsByUserId() + findRecentCommentsByUserId() + findRecentLikesByUserId() + findRecentViewsByUserId() + } 형태로 구성 + */ + + /** + * 사용자의 구독 정보 조회 + * @param userId 사용자 ID + * @return 구독 정보 리스트 + */ + List findSubscriptionsByUserId(Long userId); + + /** + * 사용자의 최근 댓글 조회 + * @param userId 사용자 ID + * @return 댓글 리스트 + */ + List findRecentCommentsByUserId(Long userId); + + /** + * 사용자의 최근 댓글 좋아요 조회 + * @param userId 사용자 ID + * @return 댓글 좋아요 리스트 + */ + List findRecentLikesByUserId(Long userId); + + /** + * 사용자의 최근 기사 조회 + * @param userId 사용자 ID + * @return 기사 조회 리스트 + */ + List findRecentViewsByUserId(Long userId); + + /* + record 사용한 단일 쿼리 + */ + + /** + * 사용자 활동내역 단일 쿼리 조회 + * @param userId 사용자 ID + * @return 사용자 활동내역 프로젝션 + */ + UserActivityRaw findUserActivityRaw(Long userId); +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/projection/UserActivityRaw.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/projection/UserActivityRaw.java new file mode 100644 index 0000000..da058db --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/repository/projection/UserActivityRaw.java @@ -0,0 +1,19 @@ +package com.monew.monew_api.useractivity.repository.projection; + +import java.time.LocalDateTime; + +/** + * 사용자 활동 네이티브 쿼리 결과를 담는 불변 데이터 컨테이너 + * alias와 일치하는 필드명을 사용 + * jsonb 필드는 String으로 들어오기 때문에 Mapper에서 List 변환 필요 + */ +public record UserActivityRaw( + Long id, + String email, + String nickname, + LocalDateTime createdAt, + String subscriptions, + String comments, + String likes, + String views +) {} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java new file mode 100644 index 0000000..0399f1f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/CacheUpdateService.java @@ -0,0 +1,161 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 캐시 업데이트 서비스 인터페이스 + */ +public interface CacheUpdateService { + + /** + * 댓글 좋아요수 증가/감소 + * + * @param commentId 댓글 ID + * @param delta 증가/감소 값 + */ + void updateCommentLikeCount(Long commentId, Integer delta); + + /** + * 기사 조회수 증가 + * + * @param articleId 기사 ID + * @param delta 증가 값 + */ + void incrementArticleViewCount(Long articleId, Integer delta); + + /** + * 기사 댓글수 증가 + * + * @param articleId 기사 ID + * @param delta 증가 값 + */ + void incrementArticleCommentCount(Long articleId, Integer delta); + + /** + * Interest 정보 업데이트 + * + * @param interestId 업데이트할 Interest ID + * @param newKeywords 새로운 키워드 리스트 + */ + void updateInterestKeyword(Long interestId, List newKeywords); + + /** + * Interest 삭제 처리 + * + * @param interestId 삭제할 Interest ID + */ + void removeInterest(Long interestId); + + /** + * 구독 추가 + * + * @param userId 사용자 ID + * @param subscriptionId 구독 ID + * @param interestId 관심사 ID + * @param interestName 관심사 이름 + * @param interestKeywords 관심사 키워드 리스트 + * @param interestSubscriberCount 관심사 구독자 수 + * @param createdAt 구독 생성 일시 + */ + void addSubscription(Long userId, Long subscriptionId, Long interestId, String interestName, + List interestKeywords, Integer interestSubscriberCount, LocalDateTime createdAt); + + /** + * 구독 취소 + * + * @param userId 사용자 ID + * @param subscriptionId 구독 ID + * @param interestId 관심사 ID + */ + void removeSubscription(Long userId, Long subscriptionId, Long interestId); + + /** + * 댓글 생성 시 캐시 데이터 + 역인덱스 업데이트 + * + * @param id 댓글 아이디 + * @param userId 댓글 작성자 아이디 + * @param userNickname 댓글 작성자 닉네임 + * @param articleId 댓글이 작성된 기사 아이디 + * @param articleTitle 댓글이 작성된 기사 제목 + * @param content 댓글 내용 + * @param likeCount 댓글 좋아요 수 + * @param createdAt 댓글 작성 일시 + */ + void addComment(Long id, Long userId, String userNickname, Long articleId, String articleTitle, + String content, Integer likeCount, LocalDateTime createdAt); + + /** + * 좋아요 생성 시 캐시 데이터 + 역인덱스 업데이트 + * + * @param id 좋아요 아이디 + * @param userId 좋아요를 누른 사용자 아이디 + * @param createdAt 좋아요 생성 일시 + * @param commentId 좋아요가 눌린 댓글 아이디 + * @param articleId 좋아요가 눌린 댓글이 속한 기사 아이디 + * @param articleTitle 좋아요가 눌린 댓글이 속한 기사 제목 + * @param commentUserId 좋아요가 눌린 댓글 작성자 아이디 + * @param commentUserNickname 좋아요가 눌린 댓글 작성자 닉네임 + * @param commentContent 좋아요가 눌린 댓글 내용 + * @param commentLikeCount 좋아요가 눌린 댓글의 현재 좋아요 수 + * @param commentCreatedAt 좋아요가 눌린 댓글 작성 일시 + */ + void addCommentLike(Long id, Long userId, LocalDateTime createdAt, Long commentId, Long articleId, String articleTitle, + Long commentUserId, String commentUserNickname, String commentContent, Integer commentLikeCount, + LocalDateTime commentCreatedAt); + + /** + * 댓글 내용 수정 시 캐시 데이터 + 역인덱스 업데이트 + * + * @param commentId 댓글 ID + * @param newContent 새로운 댓글 내용 + */ + void updateCommentContent(Long commentId, String newContent); + + /** + * 기사 조회 생성 시 캐시 데이터 + 역인덱스 업데이트 + * + * @param id 기사 조회 아이디 + * @param userId 기사 조회한 사용자 아이디 + * @param createdAt 기사 조회 일시 + * @param articleId 조회된 기사 아이디 + * @param source 기사 출처 + * @param sourceUrl 기사 출처 URL + * @param articleTitle 기사 제목 + * @param articlePublishedDate 기사 게시 일시 + * @param articleSummary 기사 요약 + * @param articleCommentCount 댓글 수 + * @param articleViewCount 조회 수 + */ + void addArticleView(Long id, + Long userId, LocalDateTime createdAt, + Long articleId, String source, String sourceUrl, + String articleTitle, LocalDateTime articlePublishedDate, + String articleSummary, Integer articleCommentCount, + Integer articleViewCount); + + /** + * 좋아요 삭제 처리 + * + * @param userId 좋아요를 취소한 사용자 ID + * @param commentId 좋아요가 취소된 댓글 ID + */ + void removeCommentLike(Long userId, Long commentId); + + /** + * 댓글 삭제 처리 + * + * @param commentId 삭제할 댓글 ID + */ + void removeComment(Long commentId); + + /** + * 캐시 저장 (PostgreSQL 조회 후 비동기 저장) + * + * @param userId 사용자 ID + * @param data 사용자 활동 데이터 + */ + void saveCache(String userId, UserActivityDto data); +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java new file mode 100644 index 0000000..5d9720a --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/CacheUpdateServiceImpl.java @@ -0,0 +1,316 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.*; +import com.monew.monew_api.useractivity.mapper.UserActivityDocumentMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; +import com.monew.monew_api.useractivity.service.CacheUpdateService; +import com.monew.monew_api.useractivity.service.ReverseIndexService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** + * 캐시 업데이트 서비스 구현체 + * MongoDB 캐시를 부분 업데이트 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CacheUpdateServiceImpl implements CacheUpdateService { + + private final ReverseIndexService reverseIndexService; + private final UserActivityCacheRepository cacheRepository; + private final UserActivityDocumentMapper documentMapper; + + @Override + public void updateCommentLikeCount(Long commentId, Integer delta) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.incCommentLikeCount(userIds, commentId.toString(), delta); + log.info("[CacheUpdate] 댓글 좋아요수 업데이트: commentId={}, delta={}, users={}, modified={}", + commentId, delta, userIds.size(), modified); + } + + @Override + public void incrementArticleViewCount(Long articleId, Integer delta) { + Set viewers = reverseIndexService.getUserIds( + ReverseIndexDocument.makeArticleViewsKey(articleId) + ); + if (viewers.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); + return; + } + long modified = cacheRepository.incArticleViewCount(viewers, articleId.toString(), delta); + log.info("[CacheUpdate] 기사 조회수 업데이트: articleId={}, delta={}, users={}, modified={}", + articleId, delta, viewers.size(), modified); + } + + @Override + public void incrementArticleCommentCount(Long articleId, Integer delta) { + Set viewers = reverseIndexService.getUserIds( + ReverseIndexDocument.makeArticleViewsKey(articleId) + ); + if (viewers.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: articleId={}", articleId); + return; + } + long modified = cacheRepository.incArticleCommentCount(viewers, articleId.toString(), delta); + log.info("[CacheUpdate] 기사 댓글수 업데이트: articleId={}, delta={}, users={}, modified={}", + articleId, delta, viewers.size(), modified); + } + + + @Override + public void addComment(Long id, Long userId, String userNickname, + Long articleId, String articleTitle, String content, + Integer likeCount, LocalDateTime createdAt) { + + String uid = userId.toString(); + CommentActivityDto dto = CommentActivityDto.builder() + .id(id.toString()) + .userId(uid) + .userNickname(userNickname) + .articleId(articleId.toString()) + .articleTitle(articleTitle) + .content(content) + .likeCount(likeCount) + .createdAt(createdAt) + .build(); + + + long modified = cacheRepository.pushComment(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, commentId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeCommentAuthorKey(id), uid); + log.info("[CacheUpdate] 댓글 추가: commentId={}, userId={}, modified={}", id, uid, modified); + } + + @Override + public void addCommentLike(Long id, Long userId, LocalDateTime createdAt, + Long commentId, Long articleId, String articleTitle, + Long commentUserId, String commentUserNickname, + String commentContent, Integer commentLikeCount, + LocalDateTime commentCreatedAt) { + String uid = userId.toString(); + CommentLikeActivityDto dto = CommentLikeActivityDto.builder() + .id(id.toString()) + .createdAt(createdAt) + .commentId(commentId.toString()) + .articleId(articleId.toString()) + .articleTitle(articleTitle) + .commentUserId(commentUserId.toString()) + .commentUserNickname(commentUserNickname) + .commentContent(commentContent) + .commentLikeCount(commentLikeCount) + .commentCreatedAt(commentCreatedAt) + .build(); + + long modified = cacheRepository.pushCommentLike(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, likeId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); + log.info("[CacheUpdate] 댓글 좋아요 추가: commentId={}, userId={}, modified={}", commentId, uid, modified); + } + + @Override + public void updateCommentContent(Long commentId, String newContent) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 댓글 내용 수정 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.updateCommentContentForUsers(userIds, commentId.toString(), newContent); + log.info("[CacheUpdate] 댓글 내용 수정 반영: commentId={}, users={}, modified={}", + commentId, userIds.size(), modified); + } + + @Override + public void removeCommentLike(Long userId, Long commentId) { + String uid = userId.toString(); + long modified = cacheRepository.pullCommentLike(uid, commentId.toString()); + reverseIndexService.removeUser(ReverseIndexDocument.makeCommentLikesKey(commentId), uid); + log.info("[CacheUpdate] 댓글 좋아요 제거: commentId={}, userId={}, modified={}", commentId, uid, modified); + } + + @Override + public void addArticleView(Long id, Long userId, LocalDateTime createdAt, + Long articleId, String source, String sourceUrl, + String articleTitle, LocalDateTime articlePublishedDate, + String articleSummary, Integer articleCommentCount, + Integer articleViewCount) { + String uid = userId.toString(); + ArticleViewActivityDto dto = ArticleViewActivityDto.builder() + .id(id.toString()) + .viewedBy(uid) + .createdAt(createdAt) + .articleId(articleId.toString()) + .source(source) + .sourceUrl(sourceUrl) + .articleTitle(articleTitle) + .articlePublishedDate(articlePublishedDate) + .articleSummary(articleSummary) + .articleCommentCount(articleCommentCount) + .articleViewCount(articleViewCount) + .build(); + + long modified = cacheRepository.pushArticleView(uid, dto, 10); + if (modified == 0) { + log.warn("[CacheUpdate] 캐시 없음(만료?): userId={}, viewId={}", uid, id); + } + reverseIndexService.addUser(ReverseIndexDocument.makeArticleViewsKey(articleId), uid); + log.info("[CacheUpdate] 기사 조회 추가: articleId={}, userId={}, modified={}", articleId, uid, modified); + } + + /* + * version 이전인 경우에만 관심사 키워드 업데이트 + */ + @Override + public void updateInterestKeyword(Long interestId, List newKeywords) { + String iid = String.valueOf(interestId); + long modified = cacheRepository.updateInterestKeywords(iid, newKeywords); + log.info("[CacheUpdate] Interest 키워드 갱신(set): interestId={}, modified={}", iid, modified); + } + + @Override + public void removeInterest(Long interestId) { + String id = String.valueOf(interestId); + + Set userIds = reverseIndexService.getUserIds( + ReverseIndexDocument.makeInterestSubscribersKey(interestId) + ); + + long modified = 0; + if (!userIds.isEmpty()) { + modified = cacheRepository.removeInterestEverywhere(userIds, id); + } + + reverseIndexService.deleteIndexes(Set.of(ReverseIndexDocument.makeInterestSubscribersKey(interestId))); + + log.info("[CacheUpdate] 관심사 삭제 반영: interestId={}, users={}, modified={}", id, userIds.size(), modified); + } + + @Override + public void addSubscription(Long userId, + Long subscriptionId, + Long interestId, + String interestName, + List interestKeywords, + Integer interestSubscriberCount, + LocalDateTime createdAt) { + + String uid = String.valueOf(userId); + SubscribesActivityDto dto = SubscribesActivityDto.builder() + .id(String.valueOf(subscriptionId)) + .interestId(String.valueOf(interestId)) + .interestName(interestName) + .interestKeywords(interestKeywords) + .interestSubscriberCount(interestSubscriberCount) + .createdAt(createdAt) + .build(); + + long modified = cacheRepository.addSubscription(uid, dto); + reverseIndexService.addUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); + log.info("[CacheUpdate] 구독 추가: userId={}, subId={}, interestId={}, modified={}", + uid, subscriptionId, interestId, modified); + } + + @Override + public void removeSubscription(Long userId, Long subscriptionId, Long interestId) { + String uid = String.valueOf(userId); + long modified = cacheRepository.removeSubscription(uid, subscriptionId.toString()); + reverseIndexService.removeUser(ReverseIndexDocument.makeInterestSubscribersKey(interestId), uid); + log.info("[CacheUpdate] 구독 제거: userId={}, subId={}, modified={}", uid, subscriptionId, modified); + } + + @Override + public void removeComment(Long commentId) { + Set userIds = reverseIndexService.getUserIds(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + if (userIds.isEmpty()) { + log.debug("[CacheUpdate] 영향 사용자 없음: commentId={}", commentId); + return; + } + long modified = cacheRepository.removeCommentEverywhere(userIds, commentId.toString()); + log.info("[CacheUpdate] 댓글 삭제 캐시 반영: commentId={}, users={}, modified={}", + commentId, userIds.size(), modified); + + reverseIndexService.deleteIndexes(Set.of( + ReverseIndexDocument.makeCommentAuthorKey(commentId), + ReverseIndexDocument.makeCommentLikesKey(commentId) + )); + } + + @Override + public void saveCache(String userId, UserActivityDto data) { + log.info("[CacheUpdate] 캐시 저장 시작: userId={}", userId); + UserActivityCacheDocument doc = documentMapper.toDocument(data); + cacheRepository.save(doc); + log.debug("[CacheUpdate] 캐시 저장 완료: userId={}", userId); + + buildReverseIndexes(userId, data); + log.info("[CacheUpdate] 캐시 및 역인덱스 저장 완료: userId={}", userId); + } + + /** + * 역인덱스 초기 생성 + * @param userId 사용자 ID + * @param data 사용자 활동 내역 + */ + private void buildReverseIndexes(String userId, UserActivityDto data) { + data.getComments().forEach(comment -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeCommentAuthorKey(Long.parseLong(comment.getId())), + userId + ); + }); + + data.getCommentLikes().forEach(like -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeCommentLikesKey(Long.parseLong(like.getCommentId())), + userId + ); + }); + + data.getArticleViews().forEach(view -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeArticleViewsKey(Long.parseLong(view.getArticleId())), + userId + ); + }); + + data.getSubscriptions().forEach(sub -> { + reverseIndexService.addUser( + ReverseIndexDocument.makeInterestSubscribersKey(Long.parseLong(sub.getInterestId())), + userId + ); + }); + + log.info("[CacheUpdate] 역인덱스 생성 완료: userId={}, 댓글작성={}개, 좋아요={}개, 기사조회={}개, 구독={}개", + userId, + data.getComments().size(), + data.getCommentLikes().size(), + data.getArticleViews().size(), + data.getSubscriptions().size()); + + + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java new file mode 100644 index 0000000..f5a80ee --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/ReverseIndexServiceImpl.java @@ -0,0 +1,69 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.document.ReverseIndexDocument; +import com.monew.monew_api.useractivity.repository.ReverseIndexRepository; +import com.monew.monew_api.useractivity.service.ReverseIndexService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReverseIndexServiceImpl implements ReverseIndexService { + + private final ReverseIndexRepository reverseIndexRepository; + + /** + * 역인덱스에 사용자 추가 + * document key 형태 그대로 + * indexKey (예: "comment_123_likes") + */ + @Override + @Transactional + public void addUser(String indexKey, String userId) { + reverseIndexRepository.addUser(indexKey, userId); + log.debug("[ReverseIndex] add: key={}, user={}", indexKey, userId); + } + + /** + * 역인덱스에서 사용자 제거 + */ + public void removeUser(String indexKey, String userId) { + reverseIndexRepository.removeUser(indexKey, userId); + log.debug("[ReverseIndex] remove: key={}, user={}", indexKey, userId); + } + + /** + * 역인덱스에서 영향받는 사용자 ID 조회 + */ + @Override + public Set getUserIds(String indexKey) { + return reverseIndexRepository.findById(indexKey) + .map(ReverseIndexDocument::getUserIds) + .orElse(Collections.emptySet()); + } + + /** + * 여러 인덱스 키에서 사용자 ID 조회 + */ + @Override + public Set getUserIds(Set indexKeys) { + return reverseIndexRepository.findUserIdsByKeys(indexKeys); + } + + /** + * 역인덱스 일괄 삭제 + */ + @Override + @Transactional + public void deleteIndexes(Set indexKeys) { + reverseIndexRepository.deleteAllById(indexKeys); + log.debug("[ReverseIndex] deleteIndexes: {}개", indexKeys.size()); + } +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java new file mode 100644 index 0000000..d598b13 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityCacheServiceImpl.java @@ -0,0 +1,61 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.useractivity.event.CacheSaveEvent; +import com.monew.monew_api.useractivity.document.UserActivityCacheDocument; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.mapper.UserActivityMapper; +import com.monew.monew_api.useractivity.repository.UserActivityCacheRepository; +import com.monew.monew_api.useractivity.service.UserActivityCacheService; +import com.monew.monew_api.useractivity.service.UserActivityService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 캐시 기반 사용자 활동 조회 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserActivityCacheServiceImpl implements UserActivityCacheService { + + private final UserActivityCacheRepository cacheRepository; + private final UserActivityService userActivityService; + private final UserActivityMapper mapper; + private final ApplicationEventPublisher eventPublisher; + + /** + * 캐시 기반 사용자 활동 조회 + * 1. MongoDB 캐시 조회 + * 2. Cache Hit → 반환 + * 3. Cache Miss → PostgreSQL 조회 → 비동기 캐시 저장 + */ + @Transactional(readOnly = true) + public UserActivityDto getUserActivityWithCache(String userId) { + log.info("[UserActivityCache] 사용자 활동 조회 시작 (캐시): userId={}", userId); + + Optional cached = cacheRepository.findById(userId); + + if (cached.isPresent()) { + log.info("캐시 히트: userId={}", userId); + return mapper.toDto(cached.get()); + } + + log.info("[UserActivityCache] 캐시 미스: userId={} - PostgreSQL 조회", userId); + UserActivityDto result = userActivityService.getUserActivitySingleQuery(userId); + + try { + eventPublisher.publishEvent(new CacheSaveEvent(userId, result)); + log.info("[UserActivityCache] 캐시 저장 이벤트 발행: userId={}", userId); + } catch (Exception e) { + log.error("[UserActivityCache] 캐시 저장 이벤트 발행 실패: userId={}", userId, e); + } + + log.info("[UserActivityCache] 사용자 활동 조회 완료 (캐시): userId={}", userId); + return result; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java new file mode 100644 index 0000000..bdf0714 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/Impl/UserActivityServiceImpl.java @@ -0,0 +1,89 @@ +package com.monew.monew_api.useractivity.service.Impl; + +import com.monew.monew_api.comments.entity.Comment; +import com.monew.monew_api.comments.entity.CommentLike; +import com.monew.monew_api.common.exception.user.UserNotFoundException; +import com.monew.monew_api.user.User; +import com.monew.monew_api.user.repository.UserRepository; +import com.monew.monew_api.subscribe.entity.Subscribe; +import com.monew.monew_api.useractivity.dto.ArticleViewActivityDto; +import com.monew.monew_api.useractivity.dto.UserActivityDto; +import com.monew.monew_api.useractivity.mapper.UserActivityMapper; +import com.monew.monew_api.useractivity.mapper.UserActivityRawMapper; +import com.monew.monew_api.useractivity.repository.UserActivityRepository; +import com.monew.monew_api.useractivity.repository.projection.UserActivityRaw; +import com.monew.monew_api.useractivity.service.UserActivityService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserActivityServiceImpl implements UserActivityService { + + private final UserRepository userRepository; + private final UserActivityRepository activityRepository; + private final UserActivityMapper mapper; + private final UserActivityRawMapper rawMapper; + + @Override + @Transactional(readOnly = true) + public UserActivityDto getUserActivity(String userId) { + log.info("[UserActivity] 사용자 활동내역 조회 시작: userId={}", userId); + + Long userIdLong = Long.parseLong(userId); + + User user = userRepository.findById(userIdLong) + .orElseThrow(UserNotFoundException::new); + + List subscriptions = activityRepository.findSubscriptionsByUserId(userIdLong); + log.info("[UserActivity] 구독 정보 조회 완료: {}건", subscriptions.size()); + + List comments = activityRepository.findRecentCommentsByUserId(userIdLong); + log.info("[UserActivity] 최근 댓글 조회 완료: {}건", comments.size()); + + List likes = activityRepository.findRecentLikesByUserId(userIdLong); + log.info("[UserActivity] 최근 좋아요 조회 완료: {}건", likes.size()); + + List views = activityRepository.findRecentViewsByUserId(userIdLong); + log.info("[UserActivity] 최근 조회 기사 조회 완료: {}건", views.size()); + + UserActivityDto result = mapper.toUserActivityDto(user, subscriptions, comments, likes, views); + + log.info("[UserActivity] 사용자 활동내역 조회 완료: userId={}", userId); + return result; + } + + /** + * 추가: 단일 쿼리 방식 + */ + @Override + @Transactional(readOnly = true) + public UserActivityDto getUserActivitySingleQuery(String userId) { + log.info("[UserActivity] 사용자 활동내역 조회 시작 (단일 쿼리 - Record): userId={}", userId); + + Long userIdLong = Long.parseLong(userId); + + UserActivityRaw raw = activityRepository.findUserActivityRaw(userIdLong); + + if (raw == null) { + log.error("[UserActivity] 사용자 활동 데이터를 찾을 수 없음: userId={}", userId); + throw new UserNotFoundException(); + } + + UserActivityDto result = rawMapper.toDto(raw); + + log.info("[UserActivity] 사용자 활동내역 조회 완료 (단일 쿼리): userId={}, 구독: {}건, 댓글: {}건, 좋아요: {}건, 조회: {}건", + userId, + result.getSubscriptions().size(), + result.getComments().size(), + result.getCommentLikes().size(), + result.getArticleViews().size()); + + return result; + } +} \ No newline at end of file diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java new file mode 100644 index 0000000..ef312d1 --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/ReverseIndexService.java @@ -0,0 +1,39 @@ +package com.monew.monew_api.useractivity.service; + +import java.util.Set; + +public interface ReverseIndexService { + /** + * 특정 인덱스 키에 사용자 ID 추가 + * @param indexKey cacheDB key 값 + * @param userId 사용자 ID + */ + void addUser(String indexKey, String userId); + + /** + * 특정 인덱스 키에서 사용자 ID 제거 + * @param indexKey cacheDB key 값 + * @param userId 사용자 ID + */ + void removeUser(String indexKey, String userId); + + /** + * 특정 인덱스 키에 해당하는 모든 사용자 ID 조회 + * @param indexKey cacheDB key 값 + * @return 사용자 ID 집합 + */ + Set getUserIds(String indexKey); + + /** + * 여러 인덱스 키에 해당하는 모든 사용자 ID 조회 + * @param indexKeys cacheDB key 값 집합 + * @return 사용자 ID 집합 + */ + Set getUserIds(Set indexKeys); + + /** + * 여러 인덱스 키 삭제 + * @param indexKeys cacheDB key 값 집합 + */ + void deleteIndexes(Set indexKeys); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java new file mode 100644 index 0000000..b3a762f --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityCacheService.java @@ -0,0 +1,12 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + +public interface UserActivityCacheService { + /** + * 사용자 활동내역 조회 (캐시 적용) + * MongoDB 캐시 확인 → 없으면 PostgreSQL 조회 → 캐시 저장 + * @param userId 사용자 ID + */ + UserActivityDto getUserActivityWithCache(String userId); +} diff --git a/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java new file mode 100644 index 0000000..0f5551c --- /dev/null +++ b/monew-api/src/main/java/com/monew/monew_api/useractivity/service/UserActivityService.java @@ -0,0 +1,16 @@ +package com.monew.monew_api.useractivity.service; + +import com.monew.monew_api.useractivity.dto.UserActivityDto; + + +public interface UserActivityService { + + UserActivityDto getUserActivity(String userId); + + /** + * 사용자 활동내역 조회 (단일 쿼리) + * PostgreSQL에서 직접 조회 + * @param userId 사용자 ID + */ + UserActivityDto getUserActivitySingleQuery(String userId); +} \ No newline at end of file diff --git a/monew-api/src/main/resources/application-dev.yml b/monew-api/src/main/resources/application-dev.yml index 5cdb9d3..5bcd69b 100644 --- a/monew-api/src/main/resources/application-dev.yml +++ b/monew-api/src/main/resources/application-dev.yml @@ -3,35 +3,59 @@ server: spring: datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: ${DB_URL:} + username: ${DB_USERNAME:} + password: ${DB_PASSWORD:} driver-class-name: org.postgresql.Driver + data: + mongodb: + uri: ${MongoDB_URI:} + auto-index-creation: true + + sql: + init: + mode: always # 수동으로 제어하려면 never, 자동 실행하려면 always + schema-locations: classpath:db/schema.sql + data-locations: + - classpath:db/data/data-users.sql + - classpath:db/data/data-interests.sql + - classpath:db/data/data-articles.sql + - classpath:db/data/data-article_views.sql + - classpath:db/data/data-comments.sql + - classpath:db/data/data-notifications.sql + - classpath:db/data/data-subscribes.sql + continue-on-error: false + jpa: hibernate: - ddl-auto: update - show-sql: true + ddl-auto: validate + show-sql: false properties: hibernate: format_sql: true + jdbc: + time_zone: Asia/Seoul + + jackson: + time-zone: Asia/Seoul servlet: multipart: max-file-size: 10MB max-request-size: 10MB -logging: - level: - root: INFO - org.hibernate.SQL: DEBUG - org.springframework.web: DEBUG - management: endpoints: web: exposure: - include: health,info + include: health, info, metrics, env endpoint: health: - show-details: always \ No newline at end of file + show-details: always + +logging: + level: + root: INFO + org.hibernate.SQL: DEBUG + org.springframework.web: DEBUG \ No newline at end of file diff --git a/monew-api/src/main/resources/application-prod.yml b/monew-api/src/main/resources/application-prod.yml index 5cdb9d3..f27ca4f 100644 --- a/monew-api/src/main/resources/application-prod.yml +++ b/monew-api/src/main/resources/application-prod.yml @@ -8,30 +8,40 @@ spring: password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver + data: + mongodb: + uri: ${MongoDB_URI:} + auto-index-creation: true + jpa: hibernate: - ddl-auto: update - show-sql: true + ddl-auto: validate + show-sql: false properties: hibernate: format_sql: true + jdbc: + time_zone: Asia/Seoul + + jackson: + time-zone: Asia/Seoul servlet: multipart: max-file-size: 10MB max-request-size: 10MB -logging: - level: - root: INFO - org.hibernate.SQL: DEBUG - org.springframework.web: DEBUG - management: endpoints: web: exposure: - include: health,info + include: health, info, metrics, prometheus endpoint: health: - show-details: always \ No newline at end of file + show-details: never + +logging: + level: + root: INFO + org.hibernate.SQL: DEBUG + org.springframework.web: DEBUG \ No newline at end of file diff --git a/monew-api/src/main/resources/application.yml b/monew-api/src/main/resources/application.yml index b324e98..7bec6d5 100644 --- a/monew-api/src/main/resources/application.yml +++ b/monew-api/src/main/resources/application.yml @@ -2,6 +2,12 @@ spring: application: name: monew-api profiles: - active: dev + active: prod config: - import: optional:file:../.env[.properties],optional:file:.env[.properties] \ No newline at end of file + import: optional:file:../.env[.properties],optional:file:.env[.properties] + +aws: + accessKeyId: ${AWS_S3_ACCESS_KEY} + secretKey: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} \ No newline at end of file diff --git a/monew-api/src/main/resources/db/data/data-article_views.sql b/monew-api/src/main/resources/db/data/data-article_views.sql new file mode 100644 index 0000000..23de258 --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-article_views.sql @@ -0,0 +1,8 @@ +--조회(Article View) 도메인 +-- ========================= +INSERT INTO article_views (user_id, article_id) VALUES +(1,1), (1,2), (1,4), +(2,1), (2,2), +(3,1), (3,3), (3,5), +(4,3), +(5,1), (5,2), (5,4), (5,5); \ No newline at end of file diff --git a/monew-api/src/main/resources/db/data/data-articles.sql b/monew-api/src/main/resources/db/data/data-articles.sql new file mode 100644 index 0000000..d3f0b60 --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-articles.sql @@ -0,0 +1,36 @@ +--뉴스/아티클(Article) 도메인 +-- ========================= +INSERT INTO articles (source, source_url, title, publish_date, summary, comment_count, view_count) VALUES + ('Chosun','https://biz.chosun.com/it-science/ict/2025/10/16/FY3SOPSY65EKBLQLZALLF2GV6I/','AI 적용한 네이버 블로그, 초기 이용자 반응은', NOW() - INTERVAL '7 days','AI로 맞춤 콘텐츠 추천, 개편 한 달 반응',3,180), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/04/24/VMY3UPDHUNFK5HC6ZBSLJ6XX24/','네이버, 최신 AI 모델 오픈소스로 무료 공개', NOW() - INTERVAL '182 days','하이퍼클로바X 시드 모델 무료 제공',4,220), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/10/23/R3VTTXIJYJBSZECXSMIE6SUCYM/','벤 만 앤트로픽 공동 창업자, 한국은 가장 기대되는 AI 시장', NOW() - INTERVAL '1 days','클로드 개발사, 한국 AI 시장 높이 평가',5,240), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/10/23/4NOEW6JW3RF7VDN2HGKABN2U7U/','오픈AI, 한국 AI 리더십 위해 협력 강화 필요', NOW() - INTERVAL '1 days','소버린 AI와 글로벌 협력 듀얼 트랙 전략',4,195), + ('Chosun','https://www.chosun.com/economy/money/2025/10/23/62DIWDGSRRGKHJYIORYU3Q5VB4/','양자컴·원전주, 거품 논란 속 급락·급등 반복', NOW() - INTERVAL '1 days','AI 열풍 타고 상승했던 주식 변동성',3,150), + ('Chosun','https://www.chosun.com/economy/tech_it/2025/10/23/3OS6C7KDKJH5VCLB7DUFWO46TY/','AI로 플라스마 통제 쉬워져, 핵융합 발전 가까이', NOW() - INTERVAL '23 days','AI 기술로 핵융합 반응 정교한 제어',2,120), + ('Chosun','https://www.chosun.com/economy/economy_general/2025/10/22/IWNF7IWUM5GRXBYQV3OWYCAI7M/','기술 특례 상장 82곳 중 48곳 주가 하락', NOW() - INTERVAL '2 days','부실 심사로 뻥튀기 상장 악용 지적',3,135), + ('Naver','https://blog.naver.com/c1c1b1b1/224014877211','IT 일반, 네이버 언론사 제공 2025년 9월', NOW() - INTERVAL '33 days','구글 크롬 AI 제미나이 본격 적용',1,88), + ('Naver','https://n.news.naver.com/mnews/article/277/0005521038','SK하이닉스, 3분기 영업이익 7조 전망…HBM 수요 견조', NOW() - INTERVAL '8 days','고대역폭메모리 공급 확대로 실적 개선',4,230), + ('Naver','https://n.news.naver.com/mnews/article/421/0007885042','삼성전자, AI 반도체 수요 회복 기대감…주가 상승', NOW() - INTERVAL '5 days','메모리 가격 반등으로 실적 턴어라운드',3,190), + ('Naver','https://n.news.naver.com/mnews/article/011/0004402211','네이버 하이퍼클로바X, 기업용 AI 솔루션 확대', NOW() - INTERVAL '12 days','엔터프라이즈 시장 공략 본격화',5,270), + ('Naver','https://n.news.naver.com/mnews/article/008/0005115233','현대차, 자율주행 AI 기술 개발 박차…미국 투자 확대', NOW() - INTERVAL '6 days','소프트웨어 정의 차량 개발 가속',2,145), + ('Naver','https://n.news.naver.com/mnews/article/366/0001057889','LG에너지솔루션, 배터리 AI 품질검사 시스템 도입', NOW() - INTERVAL '9 days','불량률 20% 감소 효과 확인',3,175), + ('Naver','https://n.news.naver.com/mnews/article/018/0005911142','카카오, 생성형 AI 카카오아이 공개…톡·뮤직 연동', NOW() - INTERVAL '14 days','맞춤형 콘텐츠 큐레이션 강화',4,210), + ('Naver','https://n.news.naver.com/mnews/article/001/0015203344','금융위, AI 기반 불법금융 탐지 시스템 가동', NOW() - INTERVAL '7 days','보이스피싱·자금세탁 실시간 차단',2,130), + ('Naver','https://n.news.naver.com/mnews/article/015/0005099221','코스피 2900선 회복…외국인 반도체주 매수 지속', NOW() - INTERVAL '3 days','AI 수혜주 중심 상승세',5,290), + ('Naver','https://n.news.naver.com/mnews/article/277/0005520011','KT, AI 데이터센터 5000억 투자…2026년 완공', NOW() - INTERVAL '10 days','인천 송도에 초거대 AI 인프라 구축',3,165), + ('Naver','https://n.news.naver.com/mnews/article/052/0002200345','포스코, AI 기반 스마트공장 확대…탄소배출 10% 감축', NOW() - INTERVAL '11 days','공정 최적화로 친환경 생산 달성',2,120), + ('Naver','https://n.news.naver.com/article/001/0015666237','뉴욕증시, 고조되는 AI 거품론과 셧다운 우려 부각', NOW() - INTERVAL '18 days','시장 조정 압력 증가하며 차익실현 나타나',3,165), + ('Naver','https://n.news.naver.com/mnews/article/421/0008526173','서버실 갇힌 AI는 끝, 산업 현장 뛰어든 피지컬 AI', NOW() - INTERVAL '18 days','제조·물류 등 현장에서 실물 작업하는 AI 본격화',4,210); + +-- ========================= +INSERT INTO interest_articles (interest_id, article_id) VALUES + (1,1), (1,4), (1,5), + (2,2), (2,14), (2,10), (2,1), (2,15), + (3,3), + (4,4), (4,5), + (5,1), (5,2), (5,3), (5,4), (5,5), (5,6), (5,7), (5,8), (5,9), (5,10), (5,11), (5,12), (5,13), (5,14), (5,15), + (6,1), (6,11), (6,2), (6,3), (6,4), (6,5), (6,6), (6,7), (6,8), + (7,7), (7,10), (7,1), (7,15), + (8,1), (8,11), (8,2), (8,3), (8,4), (8,5), (8,14), (8,6), (8,7), (8,8), + (9,11),(9,1), (9,14), (9,2), (9,3), (9,4), (9,5), (9,6), (9,7), (9,8), (9,9), + (10,1); \ No newline at end of file diff --git a/monew-api/src/main/resources/db/data/data-comments.sql b/monew-api/src/main/resources/db/data/data-comments.sql new file mode 100644 index 0000000..553cb76 --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-comments.sql @@ -0,0 +1,28 @@ +--댓글(Comment) 및 좋아요 도메인 +-- ======================== +INSERT INTO comments (user_id, article_id, content, like_count) VALUES +(1,1,'유익한 기사네요!',2), +(2,2,'ETF 유입 데이터 참고합니다',1), +(3,3,'외인 수급 추세가 흥미롭네요',3), +(5,4,'빅테크 실적이 핵심이군요',2), +(4,5,'핀테크 투자 사이클 기대',4), +(6,6,'환율 변동 심하네요.',2), +(7,7,'AI 인재 수요 공감합니다.',1), +(8,8,'친환경 정책 응원합니다.',2), +(9,9,'스마트시티 기대돼요.',4), +(10,10,'금리 유지 다행이에요.',5), +(11,11,'AI 의료 발전 굿!',0), +(12,12,'세제 개편 찬성입니다.',2), +(13,13,'ESG 중요하죠.',0), +(14,14,'IPO 뉴스 재밌어요.',1), +(15,15,'AI 경쟁 치열하네요.',3); + +INSERT INTO comment_likes (user_id, comment_id) VALUES +(2,1), (3,1), +(1,2), (5,2), +(1,3), +(2,4), (3,4), (5,4), +(1,5), (2,5), +(6,5), +(7,6),(8,7),(9,8),(10,9),(11,10), +(12,11),(13,12),(14,13),(15,14),(13,15); \ No newline at end of file diff --git a/monew-api/src/main/resources/db/data/data-interests.sql b/monew-api/src/main/resources/db/data/data-interests.sql new file mode 100644 index 0000000..789e8e7 --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-interests.sql @@ -0,0 +1,26 @@ +--관심사(Interest) 및 키워드(Keyword) 도메인 +-- ========================= +INSERT INTO interests (name) VALUES +('IT 뉴스'), ('블록체인'), ('국내증시'), ('해외증시'), ('스타트업'), +('기술'), ('자동차'), ('여행'), ('건강'), ('교육'); + +INSERT INTO keywords (keyword) VALUES +('AI'), ('머신러닝'), ('가상화폐'), ('비트코인'), +('투자'), ('거시경제'), ('빅데이터'), ('핀테크'), +('물가'), ('인공지능'), ('아파트'), ('탄소중립'), +('금리'), ('대출'), ('기술혁신'), +('자동차산업'), ('헬스케어'), ('여행지'), ('교육정책'), +('패션트렌드'), ('음악시장'), ('영화산업'), +('환경정책'), ('금융시장'), ('스타트업'), ('부동산세제'); + +INSERT INTO interest_keywords (interest_id, keyword_id) VALUES +(1,1), (1,2), (1,7), (1,14), +(2,1), (2,2), (2,3), (2,4), (2,8), +(3,1), (3,2), (3,3), (3,4), (3,5), (3,6), (3,10), (3,14), (3,15), (3,19), +(4,5), (4,6), +(5,1), (5,2), (5,3), (5,4), (5,8), (5,10), (5,15), (5,19), +(6,6), (6,7), (6,8), (6,9), +(7,1), (7,2), (7,3), (7,4), (7,8), (7,10), (7,15), (7,19), +(8,1), (8,2), (8,3), (8,6), (8,9), (8,11), (8,16), (8,20), (8,7), +(9,1), (9,2), (9,3), (9,4), (9,8), (9,10), +(10,1), (10,2), (10,3); diff --git a/monew-api/src/main/resources/db/data/data-notifications.sql b/monew-api/src/main/resources/db/data/data-notifications.sql new file mode 100644 index 0000000..0f4032f --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-notifications.sql @@ -0,0 +1,48 @@ +--알림(Notification) 도메인 +-- ========================= +INSERT INTO notifications (user_id, content, resource_type, resource_id, confirmed) +VALUES (1, '구독 관심사에 새 기사 등록', 'interest', 1, FALSE), + (1, '내 댓글에 새 좋아요', 'comment', 1, TRUE), + (2, '팔로우 관심사에 새 기사', 'interest', 2, FALSE), + (3, '새 댓글 알림', 'comment', 3, TRUE), + (5, '관심 기사 업데이트', 'interest', 5, TRUE), + (1, '새 댓글이 달렸습니다.', 'comment', 10, FALSE), + (4, '환경 소식 알림', 'interest', 4, TRUE), + (5, '스타트업 소식', 'interest', 5, FALSE), + (6, '금융 뉴스 도착', 'interest', 6, FALSE), + (7, '정치 관련 소식', 'interest', 7, TRUE), + (8, '국제 이슈 속보', 'interest', 8, FALSE), + (9, '스포츠 뉴스 업데이트', 'interest', 9, TRUE), + (10, '문화 기사 알림', 'interest', 10, FALSE), + (11, '기술 관련 업데이트', 'interest', 11, TRUE), + (12, '자동차 산업 소식', 'interest', 12, FALSE), + (13, '여행 기사 등록', 'interest', 13, TRUE), + (14, '건강 정보 도착', 'interest', 14, FALSE), + (15, '교육 소식 알림', 'interest', 15, TRUE); + +INSERT INTO notifications +(user_id, content, resource_type, resource_id, confirmed, created_at, updated_at) +VALUES + -- 페이징 테스트용. user_id = 1 : 미확인 알림 16개 + (1, 'user 1 - 테스트 알림 1 (미확인)', 'interest', 1, FALSE, '2025-10-28 10:00:00', '2025-10-28 10:00:00'), + (1, 'user 1 - 테스트 알림 2 (미확인)', 'comment', 1, FALSE, '2025-10-28 09:00:00', '2025-10-28 09:00:00'), + (1, 'user 1 - 테스트 알림 3 (미확인)', 'interest', 2, FALSE, '2025-10-28 09:00:00', '2025-10-28 09:00:00'), + (1, 'user 1 - 테스트 알림 4 (미확인)', 'comment', 2, FALSE, '2025-10-28 09:00:00', '2025-10-28 09:00:00'), + (1, 'user 1 - 테스트 알림 5 (미확인)', 'interest', 3, FALSE, '2025-10-28 06:00:00', '2025-10-28 06:00:00'), + (1, 'user 1 - 테스트 알림 6 (미확인)', 'interest', 1, FALSE, '2025-10-28 05:00:00', '2025-10-28 05:00:00'), + (1, 'user 1 - 테스트 알림 7 (미확인)', 'comment', 3, FALSE, '2025-10-28 04:00:00', '2025-10-28 04:00:00'), + (1, 'user 1 - 테스트 알림 8 (미확인)', 'interest', 4, FALSE, '2025-10-28 03:00:00', '2025-10-28 03:00:00'), + (1, 'user 1 - 테스트 알림 9 (미확인)', 'comment', 4, FALSE, '2025-10-28 02:00:00', '2025-10-28 02:00:00'), + (1, 'user 1 - 테스트 알림 10 (미확인)', 'interest', 5, FALSE, '2025-10-28 01:00:00', '2025-10-28 01:00:00'), + (1, 'user 1 - 테스트 알림 11 (미확인)', 'interest', 1, FALSE, '2025-10-27 23:00:00', '2025-10-27 23:00:00'), + (1, 'user 1 - 테스트 알림 12 (미확인)', 'comment', 5, FALSE, '2025-10-27 22:00:00', '2025-10-27 22:00:00'), + (1, 'user 1 - 테스트 알림 13 (미확인)', 'interest', 2, FALSE, '2025-10-27 21:00:00', '2025-10-27 21:00:00'), + (1, 'user 1 - 테스트 알림 14 (미확인)', 'comment', 6, FALSE, '2025-10-27 20:00:00', '2025-10-27 20:00:00'), + (1, 'user 1 - 테스트 알림 15 (미확인)', 'interest', 6, FALSE, '2025-10-27 19:00:00', '2025-10-27 19:00:00'), + (1, 'user 1 - 테스트 알림 16 (미확인)', 'interest', 7, FALSE, '2025-10-27 18:00:00', '2025-10-27 18:00:00'), + + -- user_id = 1 : 확인된 알람 4개 + (1, 'user 1 - 테스트 알림 17 (확인됨)', 'comment', 7, TRUE, '2025-10-27 17:00:00', '2025-10-27 18:00:00'), + (1, 'user 1 - 테스트 알림 18 (확인됨)', 'interest', 8, TRUE, '2025-10-27 16:00:00', '2025-10-27 17:00:00'), + (1, 'user 1 - 테스트 알림 19 (확인됨)', 'comment', 8, TRUE, '2025-10-27 15:00:00', '2025-10-27 15:30:00'), + (1, 'user 1 - 테스트 알림 20 (확인됨)', 'interest', 9, TRUE, '2025-10-27 14:00:00', '2025-10-27 14:20:00'); \ No newline at end of file diff --git a/monew-api/src/main/resources/db/data/data-subscribes.sql b/monew-api/src/main/resources/db/data/data-subscribes.sql new file mode 100644 index 0000000..a7115c6 --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-subscribes.sql @@ -0,0 +1,10 @@ +--구독(Subscribe) 도메인 +-- ========================= +INSERT INTO subscribes (user_id, interest_id) VALUES +(1,1), (1,2), (1,3), +(2,2), (2,4), +(3,1), (3,5), +(4,3), +(5,1), (5,2), (5,4), (5,5), +(6, 6), (7, 7), (8, 8), (9, 9), (10, 10) +; \ No newline at end of file diff --git a/monew-api/src/main/resources/db/data/data-users.sql b/monew-api/src/main/resources/db/data/data-users.sql new file mode 100644 index 0000000..290dd10 --- /dev/null +++ b/monew-api/src/main/resources/db/data/data-users.sql @@ -0,0 +1,24 @@ +--사용자(User) 도메인 +-- ========================= +-- 비밀번호: Pass1! (영문+숫자+특수문자 포함, 6자) +INSERT INTO users (email, nickname, password) VALUES +('alice@test.com', 'Alice', 'Pass1!'), +('bob@test.com', 'Bob', 'Pass1!'), +('carol@test.com', 'Carol', 'Pass1!'), +('dan@test.com', 'Dan', 'Pass1!'), +('erin@test.com', 'Erin', 'Pass1!'), +('fiona@example.com', 'Fiona', 'Pass1!'), +('george@example.com', 'George', 'Pass1!'), +('harry@example.com', 'Harry', 'Pass1!'), +('irene@example.com', 'Irene', 'Pass1!'), +('jack@example.com', 'Jack', 'Pass1!'), +('kate@example.com', 'Kate', 'Pass1!'), +('leo@example.com', 'Leo', 'Pass1!'), +('mia@example.com', 'Mia', 'Pass1!'), +('nick@example.com', 'Nick', 'Pass1!'), +('olivia@example.com', 'Olivia', 'Pass1!'), +('peter@example.com', 'Peter', 'Pass1!'), +('queen@example.com', 'Queen', 'Pass1!'), +('ryan@example.com', 'Ryan', 'Pass1!'), +('susan@example.com', 'Susan', 'Pass1!'), +('tom@example.com', 'Tom', 'Pass1!'); \ No newline at end of file diff --git a/monew-api/src/main/resources/db/schema.sql b/monew-api/src/main/resources/db/schema.sql new file mode 100644 index 0000000..a62ab49 --- /dev/null +++ b/monew-api/src/main/resources/db/schema.sql @@ -0,0 +1,211 @@ +-- ===== Clean drop (drop children first) ===== +DROP TABLE IF EXISTS comment_likes CASCADE; +DROP TABLE IF EXISTS article_views CASCADE; +DROP TABLE IF EXISTS comments CASCADE; +DROP TABLE IF EXISTS notifications CASCADE; +DROP TABLE IF EXISTS subscribes CASCADE; +DROP TABLE IF EXISTS interest_keywords CASCADE; +DROP TABLE IF EXISTS interest_articles_keywords CASCADE; +DROP TABLE IF EXISTS interest_articles CASCADE; +DROP TABLE IF EXISTS keywords CASCADE; +DROP TABLE IF EXISTS articles CASCADE; +DROP TABLE IF EXISTS interests CASCADE; +DROP TABLE IF EXISTS users CASCADE; + +-- ====================================================== +-- Users +-- ====================================================== +CREATE TABLE users +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + nickname VARCHAR(100) NOT NULL, + password VARCHAR(100) NOT NULL, + deleted_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ====================================================== +-- Articles +-- ====================================================== +CREATE TABLE articles +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + source VARCHAR(20) NOT NULL, + source_url VARCHAR(500) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + publish_date TIMESTAMP NOT NULL, + summary VARCHAR(200) NOT NULL, + comment_count INT NOT NULL DEFAULT 0, + view_count INT NOT NULL DEFAULT 0, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +-- ====================================================== +-- Article Views (per-user view tracking) +-- ====================================================== +CREATE TABLE article_views +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_article_views UNIQUE (user_id, article_id), + CONSTRAINT fk_article_views_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_article_views_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE +); +CREATE INDEX ix_article_views_user ON article_views (user_id); +CREATE INDEX ix_article_views_article ON article_views (article_id); + +-- ====================================================== +-- Interests +-- ====================================================== +CREATE TABLE interests +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + subscriber_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ====================================================== +-- Keywords +-- ====================================================== +CREATE TABLE keywords +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + keyword VARCHAR(50) NOT NULL UNIQUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ====================================================== +-- Interests <-> Keywords (M:N) +-- ====================================================== +CREATE TABLE interest_keywords +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + interest_id BIGINT NOT NULL, + keyword_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_interest_keywords UNIQUE (interest_id, keyword_id), + CONSTRAINT fk_interest_keywords_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE, + CONSTRAINT fk_interest_keywords_keyword + FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE +); +CREATE INDEX ix_interest_keywords_interest ON interest_keywords (interest_id); +CREATE INDEX ix_interest_keywords_keyword ON interest_keywords (keyword_id); + +-- ====================================================== +-- Interests <-> Articles (M:N) +-- ====================================================== +CREATE TABLE interest_articles +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + interest_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_interest_articles UNIQUE (interest_id, article_id), + CONSTRAINT fk_interest_articles_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE, + CONSTRAINT fk_interest_articles_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE +); +CREATE INDEX ix_interest_articles_interest ON interest_articles (interest_id); +CREATE INDEX ix_interest_articles_article ON interest_articles (article_id); + +-- ====================================================== +-- Subscribes (user follows interest) +-- ====================================================== +CREATE TABLE subscribes +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + interest_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_subscribes UNIQUE (user_id, interest_id), + CONSTRAINT fk_subscribes_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_subscribes_interest + FOREIGN KEY (interest_id) REFERENCES interests (id) ON DELETE CASCADE +); +CREATE INDEX ix_subscribes_user ON subscribes (user_id); +CREATE INDEX ix_subscribes_interest ON subscribes (interest_id); + +-- ====================================================== +-- Comments +-- ====================================================== +CREATE TABLE comments +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + article_id BIGINT NOT NULL, + content VARCHAR(500) NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + like_count INT NOT NULL DEFAULT 0, + CONSTRAINT fk_comments_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_comments_article + FOREIGN KEY (article_id) REFERENCES articles (id) ON DELETE CASCADE +); +CREATE INDEX ix_comments_user ON comments (user_id); +CREATE INDEX ix_comments_article ON comments (article_id); + +-- ====================================================== +-- Comment Likes +-- ====================================================== +CREATE TABLE comment_likes +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + comment_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_comment_likes UNIQUE (user_id, comment_id), + CONSTRAINT fk_comment_likes_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_comment_likes_comment + FOREIGN KEY (comment_id) REFERENCES comments (id) ON DELETE CASCADE +); +CREATE INDEX ix_comment_likes_user ON comment_likes (user_id); +CREATE INDEX ix_comment_likes_comment ON comment_likes (comment_id); + +-- ====================================================== +-- Notifications +-- ====================================================== +CREATE TABLE notifications +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + content VARCHAR(100) NOT NULL, + resource_type VARCHAR(30) NOT NULL, + resource_id BIGINT NOT NULL, + confirmed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT fk_notifications_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- ====================================================== +-- Interest - Article - Keyword 관계 (현재 상태) +-- ====================================================== +CREATE TABLE interest_articles_keywords ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + interest_article_id BIGINT NOT NULL, + keyword_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_interest_articles_keywords UNIQUE (interest_article_id, keyword_id), + CONSTRAINT fk_iak_interest_article FOREIGN KEY (interest_article_id) REFERENCES interest_articles (id) ON DELETE CASCADE, + CONSTRAINT fk_iak_keyword FOREIGN KEY (keyword_id) REFERENCES keywords (id) ON DELETE CASCADE +); +CREATE INDEX ix_interest_articles_keywords_interest_article ON interest_articles_keywords (interest_article_id); +CREATE INDEX ix_interest_articles_keywords_keyword ON interest_articles_keywords (keyword_id); \ No newline at end of file diff --git a/monew-api/src/main/resources/logback-spring.xml b/monew-api/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3b23cdf --- /dev/null +++ b/monew-api/src/main/resources/logback-spring.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + ${LOG_PATTERN} + + + + + + + + + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.log + + ${LOG_PATTERN} + + + ${LOG_FILE_PATH}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log + 30 + 1GB + + + + + + ${LOG_PATTERN} + + + + + + + + + + \ No newline at end of file diff --git a/monew-api/src/main/resources/static/assets/index-BBLciFoK.js b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js new file mode 100644 index 0000000..5a10cb1 --- /dev/null +++ b/monew-api/src/main/resources/static/assets/index-BBLciFoK.js @@ -0,0 +1,81 @@ +(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))s(o);new MutationObserver(o=>{for(const f of o)if(f.type==="childList")for(const d of f.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function r(o){const f={};return o.integrity&&(f.integrity=o.integrity),o.referrerPolicy&&(f.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?f.credentials="include":o.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function s(o){if(o.ep)return;o.ep=!0;const f=r(o);fetch(o.href,f)}})();function Ep(a){return a&&a.__esModule&&Object.prototype.hasOwnProperty.call(a,"default")?a.default:a}var oc={exports:{}},ki={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Z2;function Cp(){if(Z2)return ki;Z2=1;var a=Symbol.for("react.transitional.element"),i=Symbol.for("react.fragment");function r(s,o,f){var d=null;if(f!==void 0&&(d=""+f),o.key!==void 0&&(d=""+o.key),"key"in o){f={};for(var p in o)p!=="key"&&(f[p]=o[p])}else f=o;return o=f.ref,{$$typeof:a,type:s,key:d,ref:o!==void 0?o:null,props:f}}return ki.Fragment=i,ki.jsx=r,ki.jsxs=r,ki}var Q2;function _p(){return Q2||(Q2=1,oc.exports=Cp()),oc.exports}var m=_p(),cc={exports:{}},qi={},fc={exports:{}},dc={};/** + * @license React + * scheduler.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var K2;function Np(){return K2||(K2=1,(function(a){function i(B,K){var W=B.length;B.push(K);e:for(;0>>1,E=B[pe];if(0>>1;peo(Z,W))Po(xe,Z)?(B[pe]=xe,B[P]=W,pe=P):(B[pe]=Z,B[k]=W,pe=k);else if(Po(xe,W))B[pe]=xe,B[P]=W,pe=P;else break e}}return K}function o(B,K){var W=B.sortIndex-K.sortIndex;return W!==0?W:B.id-K.id}if(a.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var f=performance;a.unstable_now=function(){return f.now()}}else{var d=Date,p=d.now();a.unstable_now=function(){return d.now()-p}}var g=[],y=[],v=1,w=null,N=3,R=!1,T=!1,M=!1,C=!1,q=typeof setTimeout=="function"?setTimeout:null,A=typeof clearTimeout=="function"?clearTimeout:null,Y=typeof setImmediate<"u"?setImmediate:null;function Q(B){for(var K=r(y);K!==null;){if(K.callback===null)s(y);else if(K.startTime<=B)s(y),K.sortIndex=K.expirationTime,i(g,K);else break;K=r(y)}}function $(B){if(M=!1,Q(B),!T)if(r(g)!==null)T=!0,ee||(ee=!0,oe());else{var K=r(y);K!==null&&Se($,K.startTime-B)}}var ee=!1,J=-1,F=5,ae=-1;function re(){return C?!0:!(a.unstable_now()-aeB&&re());){var pe=w.callback;if(typeof pe=="function"){w.callback=null,N=w.priorityLevel;var E=pe(w.expirationTime<=B);if(B=a.unstable_now(),typeof E=="function"){w.callback=E,Q(B),K=!0;break t}w===r(g)&&s(g),Q(B)}else s(g);w=r(g)}if(w!==null)K=!0;else{var D=r(y);D!==null&&Se($,D.startTime-B),K=!1}}break e}finally{w=null,N=W,R=!1}K=void 0}}finally{K?oe():ee=!1}}}var oe;if(typeof Y=="function")oe=function(){Y(fe)};else if(typeof MessageChannel<"u"){var _e=new MessageChannel,Ue=_e.port2;_e.port1.onmessage=fe,oe=function(){Ue.postMessage(null)}}else oe=function(){q(fe,0)};function Se(B,K){J=q(function(){B(a.unstable_now())},K)}a.unstable_IdlePriority=5,a.unstable_ImmediatePriority=1,a.unstable_LowPriority=4,a.unstable_NormalPriority=3,a.unstable_Profiling=null,a.unstable_UserBlockingPriority=2,a.unstable_cancelCallback=function(B){B.callback=null},a.unstable_forceFrameRate=function(B){0>B||125pe?(B.sortIndex=W,i(y,B),r(g)===null&&B===r(y)&&(M?(A(J),J=-1):M=!0,Se($,W-pe))):(B.sortIndex=E,i(g,B),T||R||(T=!0,ee||(ee=!0,oe()))),B},a.unstable_shouldYield=re,a.unstable_wrapCallback=function(B){var K=N;return function(){var W=N;N=K;try{return B.apply(this,arguments)}finally{N=W}}}})(dc)),dc}var $2;function Dp(){return $2||($2=1,fc.exports=Np()),fc.exports}var mc={exports:{}},me={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var J2;function Op(){if(J2)return me;J2=1;var a=Symbol.for("react.transitional.element"),i=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),o=Symbol.for("react.profiler"),f=Symbol.for("react.consumer"),d=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),v=Symbol.for("react.lazy"),w=Symbol.iterator;function N(E){return E===null||typeof E!="object"?null:(E=w&&E[w]||E["@@iterator"],typeof E=="function"?E:null)}var R={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},T=Object.assign,M={};function C(E,D,k){this.props=E,this.context=D,this.refs=M,this.updater=k||R}C.prototype.isReactComponent={},C.prototype.setState=function(E,D){if(typeof E!="object"&&typeof E!="function"&&E!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,E,D,"setState")},C.prototype.forceUpdate=function(E){this.updater.enqueueForceUpdate(this,E,"forceUpdate")};function q(){}q.prototype=C.prototype;function A(E,D,k){this.props=E,this.context=D,this.refs=M,this.updater=k||R}var Y=A.prototype=new q;Y.constructor=A,T(Y,C.prototype),Y.isPureReactComponent=!0;var Q=Array.isArray,$={H:null,A:null,T:null,S:null,V:null},ee=Object.prototype.hasOwnProperty;function J(E,D,k,Z,P,xe){return k=xe.ref,{$$typeof:a,type:E,key:D,ref:k!==void 0?k:null,props:xe}}function F(E,D){return J(E.type,D,void 0,void 0,void 0,E.props)}function ae(E){return typeof E=="object"&&E!==null&&E.$$typeof===a}function re(E){var D={"=":"=0",":":"=2"};return"$"+E.replace(/[=:]/g,function(k){return D[k]})}var fe=/\/+/g;function oe(E,D){return typeof E=="object"&&E!==null&&E.key!=null?re(""+E.key):D.toString(36)}function _e(){}function Ue(E){switch(E.status){case"fulfilled":return E.value;case"rejected":throw E.reason;default:switch(typeof E.status=="string"?E.then(_e,_e):(E.status="pending",E.then(function(D){E.status==="pending"&&(E.status="fulfilled",E.value=D)},function(D){E.status==="pending"&&(E.status="rejected",E.reason=D)})),E.status){case"fulfilled":return E.value;case"rejected":throw E.reason}}throw E}function Se(E,D,k,Z,P){var xe=typeof E;(xe==="undefined"||xe==="boolean")&&(E=null);var se=!1;if(E===null)se=!0;else switch(xe){case"bigint":case"string":case"number":se=!0;break;case"object":switch(E.$$typeof){case a:case i:se=!0;break;case v:return se=E._init,Se(se(E._payload),D,k,Z,P)}}if(se)return P=P(E),se=Z===""?"."+oe(E,0):Z,Q(P)?(k="",se!=null&&(k=se.replace(fe,"$&/")+"/"),Se(P,D,k,"",function(dt){return dt})):P!=null&&(ae(P)&&(P=F(P,k+(P.key==null||E&&E.key===P.key?"":(""+P.key).replace(fe,"$&/")+"/")+se)),D.push(P)),1;se=0;var at=Z===""?".":Z+":";if(Q(E))for(var ze=0;ze"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(a)}catch(i){console.error(i)}}return a(),hc.exports=Mp(),hc.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var I2;function jp(){if(I2)return qi;I2=1;var a=Dp(),i=Lc(),r=zm();function s(e){var t="https://react.dev/errors/"+e;if(1E||(e.current=pe[E],pe[E]=null,E--)}function Z(e,t){E++,pe[E]=e.current,e.current=t}var P=D(null),xe=D(null),se=D(null),at=D(null);function ze(e,t){switch(Z(se,t),Z(xe,e),Z(P,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?x2(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=x2(t),e=b2(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}k(P),Z(P,e)}function dt(){k(P),k(xe),k(se)}function Bl(e){e.memoizedState!==null&&Z(at,e);var t=P.current,n=b2(t,e.type);t!==n&&(Z(xe,e),Z(P,n))}function Ra(e){xe.current===e&&(k(P),k(xe)),at.current===e&&(k(at),Ui._currentValue=W)}var Aa=Object.prototype.hasOwnProperty,kl=a.unstable_scheduleCallback,Ua=a.unstable_cancelCallback,ql=a.unstable_shouldYield,lr=a.unstable_requestPaint,gt=a.unstable_now,ir=a.unstable_getCurrentPriorityLevel,Dn=a.unstable_ImmediatePriority,za=a.unstable_UserBlockingPriority,na=a.unstable_NormalPriority,rr=a.unstable_LowPriority,Yl=a.unstable_IdlePriority,$s=a.log,Js=a.unstable_setDisableYieldValue,aa=null,mt=null;function It(e){if(typeof $s=="function"&&Js(e),mt&&typeof mt.setStrictMode=="function")try{mt.setStrictMode(aa,e)}catch{}}var ht=Math.clz32?Math.clz32:I,Fs=Math.log,Vl=Math.LN2;function I(e){return e>>>=0,e===0?32:31-(Fs(e)/Vl|0)|0}var he=256,ge=4194304;function lt(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function sr(e,t,n){var l=e.pendingLanes;if(l===0)return 0;var u=0,c=e.suspendedLanes,h=e.pingedLanes;e=e.warmLanes;var x=l&134217727;return x!==0?(l=x&~c,l!==0?u=lt(l):(h&=x,h!==0?u=lt(h):n||(n=x&~e,n!==0&&(u=lt(n))))):(x=l&~c,x!==0?u=lt(x):h!==0?u=lt(h):n||(n=l&~e,n!==0&&(u=lt(n)))),u===0?0:t!==0&&t!==u&&(t&c)===0&&(c=u&-u,n=t&-t,c>=n||c===32&&(n&4194048)!==0)?t:u}function Xl(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function f1(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tf(){var e=he;return he<<=1,(he&4194048)===0&&(he=256),e}function nf(){var e=ge;return ge<<=1,(ge&62914560)===0&&(ge=4194304),e}function Ws(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Gl(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function d1(e,t,n,l,u,c){var h=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var x=e.entanglements,S=e.expirationTimes,U=e.hiddenUpdates;for(n=h&~n;0)":-1u||S[l]!==U[u]){var V=` +`+S[l].replace(" at new "," at ");return e.displayName&&V.includes("")&&(V=V.replace("",e.displayName)),V}while(1<=l&&0<=u);break}}}finally{au=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?Ya(n):""}function v1(e){switch(e.tag){case 26:case 27:case 5:return Ya(e.type);case 16:return Ya("Lazy");case 13:return Ya("Suspense");case 19:return Ya("SuspenseList");case 0:case 15:return lu(e.type,!1);case 11:return lu(e.type.render,!1);case 1:return lu(e.type,!0);case 31:return Ya("Activity");default:return""}}function mf(e){try{var t="";do t+=v1(e),e=e.return;while(e);return t}catch(n){return` +Error generating stack: `+n.message+` +`+n.stack}}function Lt(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function hf(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function x1(e){var t=hf(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),l=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var u=n.get,c=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return u.call(this)},set:function(h){l=""+h,c.call(this,h)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return l},setValue:function(h){l=""+h},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function cr(e){e._valueTracker||(e._valueTracker=x1(e))}function yf(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),l="";return e&&(l=hf(e)?e.checked?"true":"false":e.value),e=l,e!==n?(t.setValue(e),!0):!1}function fr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var b1=/[\n"\\]/g;function Ht(e){return e.replace(b1,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function iu(e,t,n,l,u,c,h,x){e.name="",h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"?e.type=h:e.removeAttribute("type"),t!=null?h==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Lt(t)):e.value!==""+Lt(t)&&(e.value=""+Lt(t)):h!=="submit"&&h!=="reset"||e.removeAttribute("value"),t!=null?ru(e,h,Lt(t)):n!=null?ru(e,h,Lt(n)):l!=null&&e.removeAttribute("value"),u==null&&c!=null&&(e.defaultChecked=!!c),u!=null&&(e.checked=u&&typeof u!="function"&&typeof u!="symbol"),x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"?e.name=""+Lt(x):e.removeAttribute("name")}function pf(e,t,n,l,u,c,h,x){if(c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(e.type=c),t!=null||n!=null){if(!(c!=="submit"&&c!=="reset"||t!=null))return;n=n!=null?""+Lt(n):"",t=t!=null?""+Lt(t):n,x||t===e.value||(e.value=t),e.defaultValue=t}l=l??u,l=typeof l!="function"&&typeof l!="symbol"&&!!l,e.checked=x?e.checked:!!l,e.defaultChecked=!!l,h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"&&(e.name=h)}function ru(e,t,n){t==="number"&&fr(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function Va(e,t,n,l){if(e=e.options,t){t={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),fu=!1;if(cn)try{var $l={};Object.defineProperty($l,"passive",{get:function(){fu=!0}}),window.addEventListener("test",$l,$l),window.removeEventListener("test",$l,$l)}catch{fu=!1}var Mn=null,du=null,mr=null;function Tf(){if(mr)return mr;var e,t=du,n=t.length,l,u="value"in Mn?Mn.value:Mn.textContent,c=u.length;for(e=0;e=Wl),Of=" ",Mf=!1;function jf(e,t){switch(e){case"keyup":return $1.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Rf(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Qa=!1;function F1(e,t){switch(e){case"compositionend":return Rf(t);case"keypress":return t.which!==32?null:(Mf=!0,Of);case"textInput":return e=t.data,e===Of&&Mf?null:e;default:return null}}function W1(e,t){if(Qa)return e==="compositionend"||!gu&&jf(e,t)?(e=Tf(),mr=du=Mn=null,Qa=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=l}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=qf(n)}}function Vf(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Vf(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Xf(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=fr(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=fr(e.document)}return t}function bu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var iy=cn&&"documentMode"in document&&11>=document.documentMode,Ka=null,wu=null,ti=null,Su=!1;function Gf(e,t,n){var l=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Su||Ka==null||Ka!==fr(l)||(l=Ka,"selectionStart"in l&&bu(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),ti&&ei(ti,l)||(ti=l,l=as(wu,"onSelect"),0>=h,u-=h,dn=1<<32-ht(t)+u|n<c?c:8;var h=B.T,x={};B.T=x,so(e,!1,t,n);try{var S=u(),U=B.S;if(U!==null&&U(x,S),S!==null&&typeof S=="object"&&typeof S.then=="function"){var V=hy(S,l);pi(e,t,V,Rt(e))}else pi(e,t,l,Rt(e))}catch(G){pi(e,t,{then:function(){},status:"rejected",reason:G},Rt())}finally{K.p=c,B.T=h}}function xy(){}function io(e,t,n,l){if(e.tag!==5)throw Error(s(476));var u=Zd(e).queue;Gd(e,u,t,W,n===null?xy:function(){return Qd(e),n(l)})}function Zd(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:W,baseState:W,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pn,lastRenderedState:W},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pn,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Qd(e){var t=Zd(e).next.queue;pi(e,t,{},Rt())}function ro(){return ot(Ui)}function Kd(){return Je().memoizedState}function $d(){return Je().memoizedState}function by(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Rt();e=An(n);var l=Un(t,e,n);l!==null&&(At(l,t,n),ci(l,t,n)),t={cache:Lu()},e.payload=t;return}t=t.return}}function wy(e,t,n){var l=Rt();n={lane:l,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null},Hr(e)?Fd(t,n):(n=_u(e,t,n,l),n!==null&&(At(n,e,l),Wd(n,t,l)))}function Jd(e,t,n){var l=Rt();pi(e,t,n,l)}function pi(e,t,n,l){var u={lane:l,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null};if(Hr(e))Fd(t,u);else{var c=e.alternate;if(e.lanes===0&&(c===null||c.lanes===0)&&(c=t.lastRenderedReducer,c!==null))try{var h=t.lastRenderedState,x=c(h,n);if(u.hasEagerState=!0,u.eagerState=x,Nt(x,h))return br(e,t,u,0),Le===null&&xr(),!1}catch{}finally{}if(n=_u(e,t,u,l),n!==null)return At(n,e,l),Wd(n,t,l),!0}return!1}function so(e,t,n,l){if(l={lane:2,revertLane:qo(),action:l,hasEagerState:!1,eagerState:null,next:null},Hr(e)){if(t)throw Error(s(479))}else t=_u(e,n,l,2),t!==null&&At(t,e,2)}function Hr(e){var t=e.alternate;return e===ye||t!==null&&t===ye}function Fd(e,t){al=jr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Wd(e,t,n){if((n&4194048)!==0){var l=t.lanes;l&=e.pendingLanes,n|=l,t.lanes=n,lf(e,n)}}var Br={readContext:ot,use:Ar,useCallback:Qe,useContext:Qe,useEffect:Qe,useImperativeHandle:Qe,useLayoutEffect:Qe,useInsertionEffect:Qe,useMemo:Qe,useReducer:Qe,useRef:Qe,useState:Qe,useDebugValue:Qe,useDeferredValue:Qe,useTransition:Qe,useSyncExternalStore:Qe,useId:Qe,useHostTransitionStatus:Qe,useFormState:Qe,useActionState:Qe,useOptimistic:Qe,useMemoCache:Qe,useCacheRefresh:Qe},Pd={readContext:ot,use:Ar,useCallback:function(e,t){return bt().memoizedState=[e,t===void 0?null:t],e},useContext:ot,useEffect:zd,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,Lr(4194308,4,kd.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Lr(4194308,4,e,t)},useInsertionEffect:function(e,t){Lr(4,2,e,t)},useMemo:function(e,t){var n=bt();t=t===void 0?null:t;var l=e();if(pa){It(!0);try{e()}finally{It(!1)}}return n.memoizedState=[l,t],l},useReducer:function(e,t,n){var l=bt();if(n!==void 0){var u=n(t);if(pa){It(!0);try{n(t)}finally{It(!1)}}}else u=t;return l.memoizedState=l.baseState=u,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:u},l.queue=e,e=e.dispatch=wy.bind(null,ye,e),[l.memoizedState,e]},useRef:function(e){var t=bt();return e={current:e},t.memoizedState=e},useState:function(e){e=to(e);var t=e.queue,n=Jd.bind(null,ye,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ao,useDeferredValue:function(e,t){var n=bt();return lo(n,e,t)},useTransition:function(){var e=to(!1);return e=Gd.bind(null,ye,e.queue,!0,!1),bt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var l=ye,u=bt();if(Ce){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Le===null)throw Error(s(349));(Te&124)!==0||xd(l,t,n)}u.memoizedState=n;var c={value:n,getSnapshot:t};return u.queue=c,zd(wd.bind(null,l,c,e),[e]),l.flags|=2048,il(9,zr(),bd.bind(null,l,c,n,t),null),n},useId:function(){var e=bt(),t=Le.identifierPrefix;if(Ce){var n=mn,l=dn;n=(l&~(1<<32-ht(l)-1)).toString(32)+n,t="«"+t+"R"+n,n=Rr++,0ie?(nt=ne,ne=null):nt=ne.sibling;var Ee=z(O,ne,j[ie],X);if(Ee===null){ne===null&&(ne=nt);break}e&&ne&&Ee.alternate===null&&t(O,ne),_=c(Ee,_,ie),ve===null?te=Ee:ve.sibling=Ee,ve=Ee,ne=nt}if(ie===j.length)return n(O,ne),Ce&&ca(O,ie),te;if(ne===null){for(;ieie?(nt=ne,ne=null):nt=ne.sibling;var Wn=z(O,ne,Ee.value,X);if(Wn===null){ne===null&&(ne=nt);break}e&&ne&&Wn.alternate===null&&t(O,ne),_=c(Wn,_,ie),ve===null?te=Wn:ve.sibling=Wn,ve=Wn,ne=nt}if(Ee.done)return n(O,ne),Ce&&ca(O,ie),te;if(ne===null){for(;!Ee.done;ie++,Ee=j.next())Ee=G(O,Ee.value,X),Ee!==null&&(_=c(Ee,_,ie),ve===null?te=Ee:ve.sibling=Ee,ve=Ee);return Ce&&ca(O,ie),te}for(ne=l(ne);!Ee.done;ie++,Ee=j.next())Ee=L(ne,O,ie,Ee.value,X),Ee!==null&&(e&&Ee.alternate!==null&&ne.delete(Ee.key===null?ie:Ee.key),_=c(Ee,_,ie),ve===null?te=Ee:ve.sibling=Ee,ve=Ee);return e&&ne.forEach(function(Tp){return t(O,Tp)}),Ce&&ca(O,ie),te}function Re(O,_,j,X){if(typeof j=="object"&&j!==null&&j.type===T&&j.key===null&&(j=j.props.children),typeof j=="object"&&j!==null){switch(j.$$typeof){case N:e:{for(var te=j.key;_!==null;){if(_.key===te){if(te=j.type,te===T){if(_.tag===7){n(O,_.sibling),X=u(_,j.props.children),X.return=O,O=X;break e}}else if(_.elementType===te||typeof te=="object"&&te!==null&&te.$$typeof===F&&e0(te)===_.type){n(O,_.sibling),X=u(_,j.props),vi(X,j),X.return=O,O=X;break e}n(O,_);break}else t(O,_);_=_.sibling}j.type===T?(X=ua(j.props.children,O.mode,X,j.key),X.return=O,O=X):(X=Sr(j.type,j.key,j.props,null,O.mode,X),vi(X,j),X.return=O,O=X)}return h(O);case R:e:{for(te=j.key;_!==null;){if(_.key===te)if(_.tag===4&&_.stateNode.containerInfo===j.containerInfo&&_.stateNode.implementation===j.implementation){n(O,_.sibling),X=u(_,j.children||[]),X.return=O,O=X;break e}else{n(O,_);break}else t(O,_);_=_.sibling}X=Ou(j,O.mode,X),X.return=O,O=X}return h(O);case F:return te=j._init,j=te(j._payload),Re(O,_,j,X)}if(Se(j))return ue(O,_,j,X);if(oe(j)){if(te=oe(j),typeof te!="function")throw Error(s(150));return j=te.call(j),le(O,_,j,X)}if(typeof j.then=="function")return Re(O,_,kr(j),X);if(j.$$typeof===Y)return Re(O,_,_r(O,j),X);qr(O,j)}return typeof j=="string"&&j!==""||typeof j=="number"||typeof j=="bigint"?(j=""+j,_!==null&&_.tag===6?(n(O,_.sibling),X=u(_,j),X.return=O,O=X):(n(O,_),X=Du(j,O.mode,X),X.return=O,O=X),h(O)):n(O,_)}return function(O,_,j,X){try{gi=0;var te=Re(O,_,j,X);return rl=null,te}catch(ne){if(ne===ui||ne===Dr)throw ne;var ve=Dt(29,ne,null,O.mode);return ve.lanes=X,ve.return=O,ve}finally{}}}var sl=t0(!0),n0=t0(!1),Vt=D(null),tn=null;function Ln(e){var t=e.alternate;Z(Pe,Pe.current&1),Z(Vt,e),tn===null&&(t===null||nl.current!==null||t.memoizedState!==null)&&(tn=e)}function a0(e){if(e.tag===22){if(Z(Pe,Pe.current),Z(Vt,e),tn===null){var t=e.alternate;t!==null&&t.memoizedState!==null&&(tn=e)}}else Hn()}function Hn(){Z(Pe,Pe.current),Z(Vt,Vt.current)}function gn(e){k(Vt),tn===e&&(tn=null),k(Pe)}var Pe=D(0);function Yr(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||Po(n)))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if((t.flags&128)!==0)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function uo(e,t,n,l){t=e.memoizedState,n=n(l,t),n=n==null?t:v({},t,n),e.memoizedState=n,e.lanes===0&&(e.updateQueue.baseState=n)}var oo={enqueueSetState:function(e,t,n){e=e._reactInternals;var l=Rt(),u=An(l);u.payload=t,n!=null&&(u.callback=n),t=Un(e,u,l),t!==null&&(At(t,e,l),ci(t,e,l))},enqueueReplaceState:function(e,t,n){e=e._reactInternals;var l=Rt(),u=An(l);u.tag=1,u.payload=t,n!=null&&(u.callback=n),t=Un(e,u,l),t!==null&&(At(t,e,l),ci(t,e,l))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var n=Rt(),l=An(n);l.tag=2,t!=null&&(l.callback=t),t=Un(e,l,n),t!==null&&(At(t,e,n),ci(t,e,n))}};function l0(e,t,n,l,u,c,h){return e=e.stateNode,typeof e.shouldComponentUpdate=="function"?e.shouldComponentUpdate(l,c,h):t.prototype&&t.prototype.isPureReactComponent?!ei(n,l)||!ei(u,c):!0}function i0(e,t,n,l){e=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(n,l),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(n,l),t.state!==e&&oo.enqueueReplaceState(t,t.state,null)}function ga(e,t){var n=t;if("ref"in t){n={};for(var l in t)l!=="ref"&&(n[l]=t[l])}if(e=e.defaultProps){n===t&&(n=v({},n));for(var u in e)n[u]===void 0&&(n[u]=e[u])}return n}var Vr=typeof reportError=="function"?reportError:function(e){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof e=="object"&&e!==null&&typeof e.message=="string"?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",e);return}console.error(e)};function r0(e){Vr(e)}function s0(e){console.error(e)}function u0(e){Vr(e)}function Xr(e,t){try{var n=e.onUncaughtError;n(t.value,{componentStack:t.stack})}catch(l){setTimeout(function(){throw l})}}function o0(e,t,n){try{var l=e.onCaughtError;l(n.value,{componentStack:n.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(u){setTimeout(function(){throw u})}}function co(e,t,n){return n=An(n),n.tag=3,n.payload={element:null},n.callback=function(){Xr(e,t)},n}function c0(e){return e=An(e),e.tag=3,e}function f0(e,t,n,l){var u=n.type.getDerivedStateFromError;if(typeof u=="function"){var c=l.value;e.payload=function(){return u(c)},e.callback=function(){o0(t,n,l)}}var h=n.stateNode;h!==null&&typeof h.componentDidCatch=="function"&&(e.callback=function(){o0(t,n,l),typeof u!="function"&&(Xn===null?Xn=new Set([this]):Xn.add(this));var x=l.stack;this.componentDidCatch(l.value,{componentStack:x!==null?x:""})})}function Ty(e,t,n,l,u){if(n.flags|=32768,l!==null&&typeof l=="object"&&typeof l.then=="function"){if(t=n.alternate,t!==null&&ii(t,n,u,!0),n=Vt.current,n!==null){switch(n.tag){case 13:return tn===null?zo():n.alternate===null&&Ge===0&&(Ge=3),n.flags&=-257,n.flags|=65536,n.lanes=u,l===ku?n.flags|=16384:(t=n.updateQueue,t===null?n.updateQueue=new Set([l]):t.add(l),Ho(e,l,u)),!1;case 22:return n.flags|=65536,l===ku?n.flags|=16384:(t=n.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([l])},n.updateQueue=t):(n=t.retryQueue,n===null?t.retryQueue=new Set([l]):n.add(l)),Ho(e,l,u)),!1}throw Error(s(435,n.tag))}return Ho(e,l,u),zo(),!1}if(Ce)return t=Vt.current,t!==null?((t.flags&65536)===0&&(t.flags|=256),t.flags|=65536,t.lanes=u,l!==Ru&&(e=Error(s(422),{cause:l}),li(Bt(e,n)))):(l!==Ru&&(t=Error(s(423),{cause:l}),li(Bt(t,n))),e=e.current.alternate,e.flags|=65536,u&=-u,e.lanes|=u,l=Bt(l,n),u=co(e.stateNode,l,u),Vu(e,u),Ge!==4&&(Ge=2)),!1;var c=Error(s(520),{cause:l});if(c=Bt(c,n),Ci===null?Ci=[c]:Ci.push(c),Ge!==4&&(Ge=2),t===null)return!0;l=Bt(l,n),n=t;do{switch(n.tag){case 3:return n.flags|=65536,e=u&-u,n.lanes|=e,e=co(n.stateNode,l,e),Vu(n,e),!1;case 1:if(t=n.type,c=n.stateNode,(n.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||c!==null&&typeof c.componentDidCatch=="function"&&(Xn===null||!Xn.has(c))))return n.flags|=65536,u&=-u,n.lanes|=u,u=c0(u),f0(u,e,n,l),Vu(n,u),!1}n=n.return}while(n!==null);return!1}var d0=Error(s(461)),et=!1;function it(e,t,n,l){t.child=e===null?n0(t,null,n,l):sl(t,e.child,n,l)}function m0(e,t,n,l,u){n=n.render;var c=t.ref;if("ref"in l){var h={};for(var x in l)x!=="ref"&&(h[x]=l[x])}else h=l;return ha(t),l=Ku(e,t,n,h,c,u),x=$u(),e!==null&&!et?(Ju(e,t,u),vn(e,t,u)):(Ce&&x&&Mu(t),t.flags|=1,it(e,t,l,u),t.child)}function h0(e,t,n,l,u){if(e===null){var c=n.type;return typeof c=="function"&&!Nu(c)&&c.defaultProps===void 0&&n.compare===null?(t.tag=15,t.type=c,y0(e,t,c,l,u)):(e=Sr(n.type,null,l,t,t.mode,u),e.ref=t.ref,e.return=t,t.child=e)}if(c=e.child,!xo(e,u)){var h=c.memoizedProps;if(n=n.compare,n=n!==null?n:ei,n(h,l)&&e.ref===t.ref)return vn(e,t,u)}return t.flags|=1,e=fn(c,l),e.ref=t.ref,e.return=t,t.child=e}function y0(e,t,n,l,u){if(e!==null){var c=e.memoizedProps;if(ei(c,l)&&e.ref===t.ref)if(et=!1,t.pendingProps=l=c,xo(e,u))(e.flags&131072)!==0&&(et=!0);else return t.lanes=e.lanes,vn(e,t,u)}return fo(e,t,n,l,u)}function p0(e,t,n){var l=t.pendingProps,u=l.children,c=e!==null?e.memoizedState:null;if(l.mode==="hidden"){if((t.flags&128)!==0){if(l=c!==null?c.baseLanes|n:n,e!==null){for(u=t.child=e.child,c=0;u!==null;)c=c|u.lanes|u.childLanes,u=u.sibling;t.childLanes=c&~l}else t.childLanes=0,t.child=null;return g0(e,t,l,n)}if((n&536870912)!==0)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&Nr(t,c!==null?c.cachePool:null),c!==null?yd(t,c):Gu(),a0(t);else return t.lanes=t.childLanes=536870912,g0(e,t,c!==null?c.baseLanes|n:n,n)}else c!==null?(Nr(t,c.cachePool),yd(t,c),Hn(),t.memoizedState=null):(e!==null&&Nr(t,null),Gu(),Hn());return it(e,t,u,n),t.child}function g0(e,t,n,l){var u=Bu();return u=u===null?null:{parent:We._currentValue,pool:u},t.memoizedState={baseLanes:n,cachePool:u},e!==null&&Nr(t,null),Gu(),a0(t),e!==null&&ii(e,t,l,!0),null}function Gr(e,t){var n=t.ref;if(n===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof n!="function"&&typeof n!="object")throw Error(s(284));(e===null||e.ref!==n)&&(t.flags|=4194816)}}function fo(e,t,n,l,u){return ha(t),n=Ku(e,t,n,l,void 0,u),l=$u(),e!==null&&!et?(Ju(e,t,u),vn(e,t,u)):(Ce&&l&&Mu(t),t.flags|=1,it(e,t,n,u),t.child)}function v0(e,t,n,l,u,c){return ha(t),t.updateQueue=null,n=gd(t,l,n,u),pd(e),l=$u(),e!==null&&!et?(Ju(e,t,c),vn(e,t,c)):(Ce&&l&&Mu(t),t.flags|=1,it(e,t,n,c),t.child)}function x0(e,t,n,l,u){if(ha(t),t.stateNode===null){var c=Wa,h=n.contextType;typeof h=="object"&&h!==null&&(c=ot(h)),c=new n(l,c),t.memoizedState=c.state!==null&&c.state!==void 0?c.state:null,c.updater=oo,t.stateNode=c,c._reactInternals=t,c=t.stateNode,c.props=l,c.state=t.memoizedState,c.refs={},qu(t),h=n.contextType,c.context=typeof h=="object"&&h!==null?ot(h):Wa,c.state=t.memoizedState,h=n.getDerivedStateFromProps,typeof h=="function"&&(uo(t,n,h,l),c.state=t.memoizedState),typeof n.getDerivedStateFromProps=="function"||typeof c.getSnapshotBeforeUpdate=="function"||typeof c.UNSAFE_componentWillMount!="function"&&typeof c.componentWillMount!="function"||(h=c.state,typeof c.componentWillMount=="function"&&c.componentWillMount(),typeof c.UNSAFE_componentWillMount=="function"&&c.UNSAFE_componentWillMount(),h!==c.state&&oo.enqueueReplaceState(c,c.state,null),di(t,l,c,u),fi(),c.state=t.memoizedState),typeof c.componentDidMount=="function"&&(t.flags|=4194308),l=!0}else if(e===null){c=t.stateNode;var x=t.memoizedProps,S=ga(n,x);c.props=S;var U=c.context,V=n.contextType;h=Wa,typeof V=="object"&&V!==null&&(h=ot(V));var G=n.getDerivedStateFromProps;V=typeof G=="function"||typeof c.getSnapshotBeforeUpdate=="function",x=t.pendingProps!==x,V||typeof c.UNSAFE_componentWillReceiveProps!="function"&&typeof c.componentWillReceiveProps!="function"||(x||U!==h)&&i0(t,c,l,h),Rn=!1;var z=t.memoizedState;c.state=z,di(t,l,c,u),fi(),U=t.memoizedState,x||z!==U||Rn?(typeof G=="function"&&(uo(t,n,G,l),U=t.memoizedState),(S=Rn||l0(t,n,S,l,z,U,h))?(V||typeof c.UNSAFE_componentWillMount!="function"&&typeof c.componentWillMount!="function"||(typeof c.componentWillMount=="function"&&c.componentWillMount(),typeof c.UNSAFE_componentWillMount=="function"&&c.UNSAFE_componentWillMount()),typeof c.componentDidMount=="function"&&(t.flags|=4194308)):(typeof c.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=l,t.memoizedState=U),c.props=l,c.state=U,c.context=h,l=S):(typeof c.componentDidMount=="function"&&(t.flags|=4194308),l=!1)}else{c=t.stateNode,Yu(e,t),h=t.memoizedProps,V=ga(n,h),c.props=V,G=t.pendingProps,z=c.context,U=n.contextType,S=Wa,typeof U=="object"&&U!==null&&(S=ot(U)),x=n.getDerivedStateFromProps,(U=typeof x=="function"||typeof c.getSnapshotBeforeUpdate=="function")||typeof c.UNSAFE_componentWillReceiveProps!="function"&&typeof c.componentWillReceiveProps!="function"||(h!==G||z!==S)&&i0(t,c,l,S),Rn=!1,z=t.memoizedState,c.state=z,di(t,l,c,u),fi();var L=t.memoizedState;h!==G||z!==L||Rn||e!==null&&e.dependencies!==null&&Cr(e.dependencies)?(typeof x=="function"&&(uo(t,n,x,l),L=t.memoizedState),(V=Rn||l0(t,n,V,l,z,L,S)||e!==null&&e.dependencies!==null&&Cr(e.dependencies))?(U||typeof c.UNSAFE_componentWillUpdate!="function"&&typeof c.componentWillUpdate!="function"||(typeof c.componentWillUpdate=="function"&&c.componentWillUpdate(l,L,S),typeof c.UNSAFE_componentWillUpdate=="function"&&c.UNSAFE_componentWillUpdate(l,L,S)),typeof c.componentDidUpdate=="function"&&(t.flags|=4),typeof c.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof c.componentDidUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=4),typeof c.getSnapshotBeforeUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=1024),t.memoizedProps=l,t.memoizedState=L),c.props=l,c.state=L,c.context=S,l=V):(typeof c.componentDidUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=4),typeof c.getSnapshotBeforeUpdate!="function"||h===e.memoizedProps&&z===e.memoizedState||(t.flags|=1024),l=!1)}return c=l,Gr(e,t),l=(t.flags&128)!==0,c||l?(c=t.stateNode,n=l&&typeof n.getDerivedStateFromError!="function"?null:c.render(),t.flags|=1,e!==null&&l?(t.child=sl(t,e.child,null,u),t.child=sl(t,null,n,u)):it(e,t,n,u),t.memoizedState=c.state,e=t.child):e=vn(e,t,u),e}function b0(e,t,n,l){return ai(),t.flags|=256,it(e,t,n,l),t.child}var mo={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function ho(e){return{baseLanes:e,cachePool:sd()}}function yo(e,t,n){return e=e!==null?e.childLanes&~n:0,t&&(e|=Xt),e}function w0(e,t,n){var l=t.pendingProps,u=!1,c=(t.flags&128)!==0,h;if((h=c)||(h=e!==null&&e.memoizedState===null?!1:(Pe.current&2)!==0),h&&(u=!0,t.flags&=-129),h=(t.flags&32)!==0,t.flags&=-33,e===null){if(Ce){if(u?Ln(t):Hn(),Ce){var x=Xe,S;if(S=x){e:{for(S=x,x=en;S.nodeType!==8;){if(!x){x=null;break e}if(S=Jt(S.nextSibling),S===null){x=null;break e}}x=S}x!==null?(t.memoizedState={dehydrated:x,treeContext:oa!==null?{id:dn,overflow:mn}:null,retryLane:536870912,hydrationErrors:null},S=Dt(18,null,null,0),S.stateNode=x,S.return=t,t.child=S,yt=t,Xe=null,S=!0):S=!1}S||da(t)}if(x=t.memoizedState,x!==null&&(x=x.dehydrated,x!==null))return Po(x)?t.lanes=32:t.lanes=536870912,null;gn(t)}return x=l.children,l=l.fallback,u?(Hn(),u=t.mode,x=Zr({mode:"hidden",children:x},u),l=ua(l,u,n,null),x.return=t,l.return=t,x.sibling=l,t.child=x,u=t.child,u.memoizedState=ho(n),u.childLanes=yo(e,h,n),t.memoizedState=mo,l):(Ln(t),po(t,x))}if(S=e.memoizedState,S!==null&&(x=S.dehydrated,x!==null)){if(c)t.flags&256?(Ln(t),t.flags&=-257,t=go(e,t,n)):t.memoizedState!==null?(Hn(),t.child=e.child,t.flags|=128,t=null):(Hn(),u=l.fallback,x=t.mode,l=Zr({mode:"visible",children:l.children},x),u=ua(u,x,n,null),u.flags|=2,l.return=t,u.return=t,l.sibling=u,t.child=l,sl(t,e.child,null,n),l=t.child,l.memoizedState=ho(n),l.childLanes=yo(e,h,n),t.memoizedState=mo,t=u);else if(Ln(t),Po(x)){if(h=x.nextSibling&&x.nextSibling.dataset,h)var U=h.dgst;h=U,l=Error(s(419)),l.stack="",l.digest=h,li({value:l,source:null,stack:null}),t=go(e,t,n)}else if(et||ii(e,t,n,!1),h=(n&e.childLanes)!==0,et||h){if(h=Le,h!==null&&(l=n&-n,l=(l&42)!==0?1:Ps(l),l=(l&(h.suspendedLanes|n))!==0?0:l,l!==0&&l!==S.retryLane))throw S.retryLane=l,Fa(e,l),At(h,e,l),d0;x.data==="$?"||zo(),t=go(e,t,n)}else x.data==="$?"?(t.flags|=192,t.child=e.child,t=null):(e=S.treeContext,Xe=Jt(x.nextSibling),yt=t,Ce=!0,fa=null,en=!1,e!==null&&(qt[Yt++]=dn,qt[Yt++]=mn,qt[Yt++]=oa,dn=e.id,mn=e.overflow,oa=t),t=po(t,l.children),t.flags|=4096);return t}return u?(Hn(),u=l.fallback,x=t.mode,S=e.child,U=S.sibling,l=fn(S,{mode:"hidden",children:l.children}),l.subtreeFlags=S.subtreeFlags&65011712,U!==null?u=fn(U,u):(u=ua(u,x,n,null),u.flags|=2),u.return=t,l.return=t,l.sibling=u,t.child=l,l=u,u=t.child,x=e.child.memoizedState,x===null?x=ho(n):(S=x.cachePool,S!==null?(U=We._currentValue,S=S.parent!==U?{parent:U,pool:U}:S):S=sd(),x={baseLanes:x.baseLanes|n,cachePool:S}),u.memoizedState=x,u.childLanes=yo(e,h,n),t.memoizedState=mo,l):(Ln(t),n=e.child,e=n.sibling,n=fn(n,{mode:"visible",children:l.children}),n.return=t,n.sibling=null,e!==null&&(h=t.deletions,h===null?(t.deletions=[e],t.flags|=16):h.push(e)),t.child=n,t.memoizedState=null,n)}function po(e,t){return t=Zr({mode:"visible",children:t},e.mode),t.return=e,e.child=t}function Zr(e,t){return e=Dt(22,e,null,t),e.lanes=0,e.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},e}function go(e,t,n){return sl(t,e.child,null,n),e=po(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function S0(e,t,n){e.lanes|=t;var l=e.alternate;l!==null&&(l.lanes|=t),Uu(e.return,t,n)}function vo(e,t,n,l,u){var c=e.memoizedState;c===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:l,tail:n,tailMode:u}:(c.isBackwards=t,c.rendering=null,c.renderingStartTime=0,c.last=l,c.tail=n,c.tailMode=u)}function T0(e,t,n){var l=t.pendingProps,u=l.revealOrder,c=l.tail;if(it(e,t,l.children,n),l=Pe.current,(l&2)!==0)l=l&1|2,t.flags|=128;else{if(e!==null&&(e.flags&128)!==0)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&S0(e,n,t);else if(e.tag===19)S0(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}l&=1}switch(Z(Pe,l),u){case"forwards":for(n=t.child,u=null;n!==null;)e=n.alternate,e!==null&&Yr(e)===null&&(u=n),n=n.sibling;n=u,n===null?(u=t.child,t.child=null):(u=n.sibling,n.sibling=null),vo(t,!1,u,n,c);break;case"backwards":for(n=null,u=t.child,t.child=null;u!==null;){if(e=u.alternate,e!==null&&Yr(e)===null){t.child=u;break}e=u.sibling,u.sibling=n,n=u,u=e}vo(t,!0,n,null,c);break;case"together":vo(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function vn(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),Vn|=t.lanes,(n&t.childLanes)===0)if(e!==null){if(ii(e,t,n,!1),(n&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(s(153));if(t.child!==null){for(e=t.child,n=fn(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=fn(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function xo(e,t){return(e.lanes&t)!==0?!0:(e=e.dependencies,!!(e!==null&&Cr(e)))}function Ey(e,t,n){switch(t.tag){case 3:ze(t,t.stateNode.containerInfo),jn(t,We,e.memoizedState.cache),ai();break;case 27:case 5:Bl(t);break;case 4:ze(t,t.stateNode.containerInfo);break;case 10:jn(t,t.type,t.memoizedProps.value);break;case 13:var l=t.memoizedState;if(l!==null)return l.dehydrated!==null?(Ln(t),t.flags|=128,null):(n&t.child.childLanes)!==0?w0(e,t,n):(Ln(t),e=vn(e,t,n),e!==null?e.sibling:null);Ln(t);break;case 19:var u=(e.flags&128)!==0;if(l=(n&t.childLanes)!==0,l||(ii(e,t,n,!1),l=(n&t.childLanes)!==0),u){if(l)return T0(e,t,n);t.flags|=128}if(u=t.memoizedState,u!==null&&(u.rendering=null,u.tail=null,u.lastEffect=null),Z(Pe,Pe.current),l)break;return null;case 22:case 23:return t.lanes=0,p0(e,t,n);case 24:jn(t,We,e.memoizedState.cache)}return vn(e,t,n)}function E0(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps)et=!0;else{if(!xo(e,n)&&(t.flags&128)===0)return et=!1,Ey(e,t,n);et=(e.flags&131072)!==0}else et=!1,Ce&&(t.flags&1048576)!==0&&ed(t,Er,t.index);switch(t.lanes=0,t.tag){case 16:e:{e=t.pendingProps;var l=t.elementType,u=l._init;if(l=u(l._payload),t.type=l,typeof l=="function")Nu(l)?(e=ga(l,e),t.tag=1,t=x0(null,t,l,e,n)):(t.tag=0,t=fo(null,t,l,e,n));else{if(l!=null){if(u=l.$$typeof,u===Q){t.tag=11,t=m0(null,t,l,e,n);break e}else if(u===J){t.tag=14,t=h0(null,t,l,e,n);break e}}throw t=Ue(l)||l,Error(s(306,t,""))}}return t;case 0:return fo(e,t,t.type,t.pendingProps,n);case 1:return l=t.type,u=ga(l,t.pendingProps),x0(e,t,l,u,n);case 3:e:{if(ze(t,t.stateNode.containerInfo),e===null)throw Error(s(387));l=t.pendingProps;var c=t.memoizedState;u=c.element,Yu(e,t),di(t,l,null,n);var h=t.memoizedState;if(l=h.cache,jn(t,We,l),l!==c.cache&&zu(t,[We],n,!0),fi(),l=h.element,c.isDehydrated)if(c={element:l,isDehydrated:!1,cache:h.cache},t.updateQueue.baseState=c,t.memoizedState=c,t.flags&256){t=b0(e,t,l,n);break e}else if(l!==u){u=Bt(Error(s(424)),t),li(u),t=b0(e,t,l,n);break e}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName==="HTML"?e.ownerDocument.body:e}for(Xe=Jt(e.firstChild),yt=t,Ce=!0,fa=null,en=!0,n=n0(t,null,l,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(ai(),l===u){t=vn(e,t,n);break e}it(e,t,l,n)}t=t.child}return t;case 26:return Gr(e,t),e===null?(n=D2(t.type,null,t.pendingProps,null))?t.memoizedState=n:Ce||(n=t.type,e=t.pendingProps,l=is(se.current).createElement(n),l[ut]=t,l[vt]=e,st(l,n,e),Ie(l),t.stateNode=l):t.memoizedState=D2(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return Bl(t),e===null&&Ce&&(l=t.stateNode=C2(t.type,t.pendingProps,se.current),yt=t,en=!0,u=Xe,Qn(t.type)?(Io=u,Xe=Jt(l.firstChild)):Xe=u),it(e,t,t.pendingProps.children,n),Gr(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&Ce&&((u=l=Xe)&&(l=Py(l,t.type,t.pendingProps,en),l!==null?(t.stateNode=l,yt=t,Xe=Jt(l.firstChild),en=!1,u=!0):u=!1),u||da(t)),Bl(t),u=t.type,c=t.pendingProps,h=e!==null?e.memoizedProps:null,l=c.children,Jo(u,c)?l=null:h!==null&&Jo(u,h)&&(t.flags|=32),t.memoizedState!==null&&(u=Ku(e,t,py,null,null,n),Ui._currentValue=u),Gr(e,t),it(e,t,l,n),t.child;case 6:return e===null&&Ce&&((e=n=Xe)&&(n=Iy(n,t.pendingProps,en),n!==null?(t.stateNode=n,yt=t,Xe=null,e=!0):e=!1),e||da(t)),null;case 13:return w0(e,t,n);case 4:return ze(t,t.stateNode.containerInfo),l=t.pendingProps,e===null?t.child=sl(t,null,l,n):it(e,t,l,n),t.child;case 11:return m0(e,t,t.type,t.pendingProps,n);case 7:return it(e,t,t.pendingProps,n),t.child;case 8:return it(e,t,t.pendingProps.children,n),t.child;case 12:return it(e,t,t.pendingProps.children,n),t.child;case 10:return l=t.pendingProps,jn(t,t.type,l.value),it(e,t,l.children,n),t.child;case 9:return u=t.type._context,l=t.pendingProps.children,ha(t),u=ot(u),l=l(u),t.flags|=1,it(e,t,l,n),t.child;case 14:return h0(e,t,t.type,t.pendingProps,n);case 15:return y0(e,t,t.type,t.pendingProps,n);case 19:return T0(e,t,n);case 31:return l=t.pendingProps,n=t.mode,l={mode:l.mode,children:l.children},e===null?(n=Zr(l,n),n.ref=t.ref,t.child=n,n.return=t,t=n):(n=fn(e.child,l),n.ref=t.ref,t.child=n,n.return=t,t=n),t;case 22:return p0(e,t,n);case 24:return ha(t),l=ot(We),e===null?(u=Bu(),u===null&&(u=Le,c=Lu(),u.pooledCache=c,c.refCount++,c!==null&&(u.pooledCacheLanes|=n),u=c),t.memoizedState={parent:l,cache:u},qu(t),jn(t,We,u)):((e.lanes&n)!==0&&(Yu(e,t),di(t,null,null,n),fi()),u=e.memoizedState,c=t.memoizedState,u.parent!==l?(u={parent:l,cache:l},t.memoizedState=u,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=u),jn(t,We,l)):(l=c.cache,jn(t,We,l),l!==u.cache&&zu(t,[We],n,!0))),it(e,t,t.pendingProps.children,n),t.child;case 29:throw t.pendingProps}throw Error(s(156,t.tag))}function xn(e){e.flags|=4}function C0(e,t){if(t.type!=="stylesheet"||(t.state.loading&4)!==0)e.flags&=-16777217;else if(e.flags|=16777216,!A2(t)){if(t=Vt.current,t!==null&&((Te&4194048)===Te?tn!==null:(Te&62914560)!==Te&&(Te&536870912)===0||t!==tn))throw oi=ku,ud;e.flags|=8192}}function Qr(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag!==22?nf():536870912,e.lanes|=t,fl|=t)}function xi(e,t){if(!Ce)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var l=null;n!==null;)n.alternate!==null&&(l=n),n=n.sibling;l===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:l.sibling=null}}function Ve(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,l=0;if(t)for(var u=e.child;u!==null;)n|=u.lanes|u.childLanes,l|=u.subtreeFlags&65011712,l|=u.flags&65011712,u.return=e,u=u.sibling;else for(u=e.child;u!==null;)n|=u.lanes|u.childLanes,l|=u.subtreeFlags,l|=u.flags,u.return=e,u=u.sibling;return e.subtreeFlags|=l,e.childLanes=n,t}function Cy(e,t,n){var l=t.pendingProps;switch(ju(t),t.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Ve(t),null;case 1:return Ve(t),null;case 3:return n=t.stateNode,l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),yn(We),dt(),n.pendingContext&&(n.context=n.pendingContext,n.pendingContext=null),(e===null||e.child===null)&&(ni(t)?xn(t):e===null||e.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,ad())),Ve(t),null;case 26:return n=t.memoizedState,e===null?(xn(t),n!==null?(Ve(t),C0(t,n)):(Ve(t),t.flags&=-16777217)):n?n!==e.memoizedState?(xn(t),Ve(t),C0(t,n)):(Ve(t),t.flags&=-16777217):(e.memoizedProps!==l&&xn(t),Ve(t),t.flags&=-16777217),null;case 27:Ra(t),n=se.current;var u=t.type;if(e!==null&&t.stateNode!=null)e.memoizedProps!==l&&xn(t);else{if(!l){if(t.stateNode===null)throw Error(s(166));return Ve(t),null}e=P.current,ni(t)?td(t):(e=C2(u,l,n),t.stateNode=e,xn(t))}return Ve(t),null;case 5:if(Ra(t),n=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==l&&xn(t);else{if(!l){if(t.stateNode===null)throw Error(s(166));return Ve(t),null}if(e=P.current,ni(t))td(t);else{switch(u=is(se.current),e){case 1:e=u.createElementNS("http://www.w3.org/2000/svg",n);break;case 2:e=u.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;default:switch(n){case"svg":e=u.createElementNS("http://www.w3.org/2000/svg",n);break;case"math":e=u.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;case"script":e=u.createElement("div"),e.innerHTML=" + + + +
+ + diff --git a/monew-api/src/test/java/com/monew/monew_api/Comment/CommentServiceHardDeleteTest.java b/monew-api/src/test/java/com/monew/monew_api/Comment/CommentServiceHardDeleteTest.java new file mode 100644 index 0000000..7884b0b --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/Comment/CommentServiceHardDeleteTest.java @@ -0,0 +1,75 @@ +package com.monew.monew_api.Comment; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.monew.monew_api.comments.repository.CommentRepository; +import com.monew.monew_api.comments.service.CommentService; +import com.monew.monew_api.common.exception.comment.CommentNotFoundException; + +/** + * 물리 삭제 단위 테스트 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CommentService - hardDelete") +public class CommentServiceHardDeleteTest { + + @InjectMocks + private CommentService commentService; + + @Mock + private CommentRepository commentRepository; + + // 물리 삭제 성공 - is_deleted=true인 댓글 + @Test + void hardDelete_Success_DeletedComment() { + + // given + Long commentId = 1L; + given(commentRepository.hardDeleteById(commentId)).willReturn(1); // 1개 삭제됨 + + // when + commentService.hardDelete(commentId); + + // then + then(commentRepository).should().hardDeleteById(commentId); + } + + // 물리 삭제 실패 - 존재하지 않는 댓글 + @Test + void hardDelete_CommentNotFound() { + + // given + Long commentId = 999L; + given(commentRepository.hardDeleteById(commentId)).willReturn(0); // 삭제된 row 없음 + + // when & then + assertThatThrownBy(() -> commentService.hardDelete(commentId)) + .isInstanceOf(CommentNotFoundException.class); + + then(commentRepository).should().hardDeleteById(commentId); + } + + // 물리 삭제 실패 - is_deleted=false인 댓글 (논리 삭제 안됨) + @Test + void hardDelete_NotDeletedComment() { + + // given + Long commentId = 2L; + // is_deleted=false 삭제 조건에 안 맞음 → 0개 삭제 + given(commentRepository.hardDeleteById(commentId)).willReturn(0); + + // when & then + assertThatThrownBy(() -> commentService.hardDelete(commentId)) + .isInstanceOf(CommentNotFoundException.class); + + then(commentRepository).should().hardDeleteById(commentId); + } +} diff --git a/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java b/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java deleted file mode 100644 index 4dfa58a..0000000 --- a/monew-api/src/test/java/com/monew/monew_api/MonewApiApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.monew.monew_api; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class MonewApiApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java new file mode 100644 index 0000000..6e254ee --- /dev/null +++ b/monew-api/src/test/java/com/monew/monew_api/notification/repository/NotificationRepositoryTest.java @@ -0,0 +1,99 @@ +package com.monew.monew_api.notification.repository; + +import com.monew.monew_api.common.config.QuerydslConfig; +import com.monew.monew_api.user.User; +import com.monew.monew_api.notification.entity.Notification; +import com.monew.monew_api.notification.enums.ResourceType; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuerydslConfig.class) +class NotificationRepositoryTest { + + @Autowired + NotificationRepository notificationRepository; + + @Autowired + EntityManager entityManager; + + private User user1; + private User user2; + + @BeforeEach + void setup() { + user1 = new User("user1@example.com", "테스트 유저", "1234"); + entityManager.persist(user1); + entityManager.flush(); + + user2 = new User("user2@example.com", "테스트 유저 2", "1234"); + entityManager.persist(user2); + entityManager.flush(); + } + + @Test + @DisplayName("확인한지 일주일 경과된 알림은 삭제된다.") + void deleteAllOldConfirmedTest() { + // Given + LocalDateTime now = LocalDateTime.now(); + LocalDateTime oneWeekAgo = now.minusWeeks(1); + LocalDateTime eightDaysAgo = now.minusDays(8); + LocalDateTime sixDaysAgo = now.minusDays(6); + + Notification toDelete = createAndPersistNotification(user1, "삭제 대상 알림", true, eightDaysAgo.minusDays(1), eightDaysAgo); + Notification keepConfirmed = createAndPersistNotification(user1, "유지 대상 (확인 됨)", true, oneWeekAgo, sixDaysAgo); + Notification keepUnconfirmed = createAndPersistNotification(user1, "유지 대상 (미확인)", false, eightDaysAgo, eightDaysAgo); + Notification toDelete2 = createAndPersistNotification(user2, "삭제 대상 (다른 사용자 알림)", true, eightDaysAgo, eightDaysAgo); + + entityManager.flush(); + entityManager.clear(); + + // When + int deletedCount = notificationRepository.deleteAllOldConfirmed(oneWeekAgo); + + // Then + assertThat(deletedCount).isEqualTo(2); + + assertThat(notificationRepository.findById(toDelete.getId()).isEmpty()); + assertThat(notificationRepository.findById(toDelete2.getId()).isEmpty()); + + assertThat(notificationRepository.findById(keepConfirmed.getId()).isPresent()); + assertThat(notificationRepository.findById(keepUnconfirmed.getId()).isPresent()); + + assertThat(notificationRepository.count()).isEqualTo(2); + } + + private Notification createAndPersistNotification(User user, String content, boolean confirmed, LocalDateTime createdAt, LocalDateTime updatedAt) { + + Notification notification = new Notification(user, content, ResourceType.interest, 1L); + + if (confirmed) { + notification.confirm(); + } + + notificationRepository.save(notification); + entityManager.flush(); + + int updatedRows = entityManager.createQuery("UPDATE Notification n SET n.createdAt = :createdAt, n.updatedAt = :updatedAt WHERE n.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("updatedAt", updatedAt) + .setParameter("id", notification.getId()) + .executeUpdate(); + + assertThat(updatedRows).isEqualTo(1); + + return notification; + } + +} \ No newline at end of file diff --git a/monew-api/src/test/resources/application-test.yml b/monew-api/src/test/resources/application-test.yml new file mode 100644 index 0000000..85e22be --- /dev/null +++ b/monew-api/src/test/resources/application-test.yml @@ -0,0 +1,29 @@ +server: + port: 0 + +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + +# h2: +# console: +# enabled: true +# path: /h2-console + +logging: + level: + root: WARN + org.hibernate.SQL: DEBUG + org.springframework: WARN \ No newline at end of file diff --git a/monew-batch/build.gradle b/monew-batch/build.gradle index 23ba004..7700994 100644 --- a/monew-batch/build.gradle +++ b/monew-batch/build.gradle @@ -3,11 +3,27 @@ plugins { } dependencies { + implementation project(':monew-api') implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' - runtimeOnly 'org.postgresql:postgresql' - runtimeOnly 'com.h2database:h2' + implementation project(':monew-api') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'software.amazon.awssdk:s3:2.31.7' + implementation 'com.rometools:rome:1.18.0' + + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.batch:spring-batch-test' } \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/ExampleJobConfig01.java b/monew-batch/src/main/java/com/monew/monew_batch/ExampleJobConfig01.java deleted file mode 100644 index 5a5913f..0000000 --- a/monew-batch/src/main/java/com/monew/monew_batch/ExampleJobConfig01.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.monew.monew_batch; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.repeat.RepeatStatus; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -/** - * 테스트용 - */ -@Slf4j -@Configuration -@EnableBatchProcessing -@RequiredArgsConstructor -public class ExampleJobConfig01 { - - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - - @Bean - public Job exampleJob01() { - - return new JobBuilder("exampleJob01", jobRepository) - .start(step01()) - .build(); - } - - @Bean - public Step step01() { - return new StepBuilder("step01", jobRepository) - .tasklet((contribution, chunkContext) -> { - log.info("In step 01"); - System.out.println("In step 01"); -// return RepeatStatus.CONTINUABLE; - return RepeatStatus.FINISHED; - } - , transactionManager) - .build(); - - } - -} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java index d2fcd58..daaab0d 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/MonewBatchApplication.java @@ -2,13 +2,33 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; -@SpringBootApplication +import java.util.TimeZone; + +@SpringBootApplication( + scanBasePackages = { + "com.monew.monew_batch", + "com.monew.monew_api.article.repository", + }, + exclude = { + org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration.class + } +) +@EntityScan(basePackages = "com.monew.monew_api") +@EnableJpaRepositories(basePackages = "com.monew.monew_api") @EnableScheduling +@EnableJpaAuditing +@EnableAsync public class MonewBatchApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(MonewBatchApplication.class, args); } diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/config/ChosunJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/article/config/ChosunJobConfig.java new file mode 100644 index 0000000..f8b2cd4 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/config/ChosunJobConfig.java @@ -0,0 +1,59 @@ +package com.monew.monew_batch.article.config; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.processor.ChosunArticleItemProcessor; +import com.monew.monew_batch.article.job.ArticleItemReader; +import com.monew.monew_batch.article.job.ArticleItemWriter; +import com.monew.monew_batch.article.properties.ChosunProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(ChosunProperties.class) +public class ChosunJobConfig { + + private final ArticleItemReader reader; // 키워드 재사용 + private final ChosunArticleItemProcessor processor; + private final ArticleItemWriter writer; + + @Bean + public Job chosunRssJob(JobRepository jobRepository, Step chosunRssStep) { + return new JobBuilder("chosunRssJob", jobRepository) + .start(chosunRssStep) + .build(); + } + + @Bean + public Step chosunRssStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("chosunRssStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + @Bean(name = "chosunTaskExecutor") + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("chosun-news-thread-"); + executor.setConcurrencyLimit(2); + return executor; + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/config/HankyungJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/article/config/HankyungJobConfig.java new file mode 100644 index 0000000..e9b6984 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/config/HankyungJobConfig.java @@ -0,0 +1,59 @@ +package com.monew.monew_batch.article.config; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.processor.HankyungArticleItemProcessor; +import com.monew.monew_batch.article.job.ArticleItemReader; +import com.monew.monew_batch.article.job.ArticleItemWriter; +import com.monew.monew_batch.article.properties.HankyungProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(HankyungProperties.class) +public class HankyungJobConfig { + + private final ArticleItemReader reader; // 기존 KeywordReader 재사용 + private final HankyungArticleItemProcessor processor; + private final ArticleItemWriter writer; + + @Bean + public Job hankyungRssJob(JobRepository jobRepository, Step hankyungRssStep) { + return new JobBuilder("hankyungRssJob", jobRepository) + .start(hankyungRssStep) + .build(); + } + + @Bean + public Step hankyungRssStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("hankyungRssStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + @Bean(name = "hankyungTaskExecutor") + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("hankyung-news-thread-"); + executor.setConcurrencyLimit(2); + return executor; + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/config/NaverJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/article/config/NaverJobConfig.java new file mode 100644 index 0000000..93ebb14 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/config/NaverJobConfig.java @@ -0,0 +1,64 @@ +package com.monew.monew_batch.article.config; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.ArticleNotificationRequestListener; +import com.monew.monew_batch.article.job.processor.NaverArticleItemProcessor; +import com.monew.monew_batch.article.job.ArticleItemReader; +import com.monew.monew_batch.article.job.ArticleItemWriter; +import com.monew.monew_batch.article.properties.NaverProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(NaverProperties.class) +public class NaverJobConfig { + + private final ArticleItemReader reader; + private final NaverArticleItemProcessor processor; + private final ArticleItemWriter writer; + private final ArticleNotificationRequestListener listener; + + @Bean + public Job naverNewsJob(JobRepository jobRepository, Step naverNewsStep) { + return new JobBuilder("naverNewsJob", jobRepository) + .start(naverNewsStep) + .listener(listener) + .build(); + } + + @Bean + public Step naverNewsStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager) { + return new StepBuilder("naverNewsStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + @Bean(name = "naverTaskExecutor") + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("naver-news-thread-"); + executor.setConcurrencyLimit(2); + return executor; + } + +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/config/YonhapJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/article/config/YonhapJobConfig.java new file mode 100644 index 0000000..87557db --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/config/YonhapJobConfig.java @@ -0,0 +1,59 @@ +package com.monew.monew_batch.article.config; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.job.ArticleItemReader; +import com.monew.monew_batch.article.job.ArticleItemWriter; +import com.monew.monew_batch.article.job.processor.YonhapArticleItemProcessor; +import com.monew.monew_batch.article.properties.YonhapProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +@EnableConfigurationProperties(YonhapProperties.class) +public class YonhapJobConfig { + + private final ArticleItemReader reader; // 키워드 재사용 + private final YonhapArticleItemProcessor processor; + private final ArticleItemWriter writer; + + @Bean + public Job yonhapRssJob(JobRepository jobRepository, Step yonhapRssStep) { + return new JobBuilder("yonhapRssJob", jobRepository) + .start(yonhapRssStep) + .build(); + } + + @Bean + public Step yonhapRssStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("yonhapRssStep", jobRepository) + .>chunk(1, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .taskExecutor(taskExecutor()) + .build(); + } + + @Bean(name = "yonhapTaskExecutor") + public TaskExecutor taskExecutor() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("yonhap-news-thread-"); + executor.setConcurrencyLimit(2); + return executor; + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleKeywordPair.java b/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleKeywordPair.java new file mode 100644 index 0000000..05093fd --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/dto/ArticleKeywordPair.java @@ -0,0 +1,9 @@ +package com.monew.monew_batch.article.dto; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; + +public record ArticleKeywordPair( + Article article, + Keyword keyword +) {} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/enums/ArticleSource.java b/monew-batch/src/main/java/com/monew/monew_batch/article/enums/ArticleSource.java new file mode 100644 index 0000000..9c1767f --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/enums/ArticleSource.java @@ -0,0 +1,13 @@ +package com.monew.monew_batch.article.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ArticleSource { + NAVER, + HANKYUNG, + CHOSUN, + YEONHAP +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemReader.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemReader.java new file mode 100644 index 0000000..0e0e045 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemReader.java @@ -0,0 +1,37 @@ +package com.monew.monew_batch.article.job; + +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_api.interest.repository.KeywordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemReader; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@StepScope +@RequiredArgsConstructor +@Slf4j +public class ArticleItemReader implements ItemReader { + + private final KeywordRepository keywordRepository; + private List items; + private int nextIndex = 0; + + @Override + public synchronized Keyword read() { + if (items == null) { + items = keywordRepository.findAll(); + log.info("키워드 {}개 로드 완료", items.size()); + } + + if (nextIndex < items.size()) { + return items.get(nextIndex++); + } else { + return null; + } + } + +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemWriter.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemWriter.java new file mode 100644 index 0000000..621f84b --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleItemWriter.java @@ -0,0 +1,97 @@ +package com.monew.monew_batch.article.job; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.entity.InterestArticles; +import com.monew.monew_api.article.repository.ArticleJdbcRepository; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_api.article.repository.InterestArticleKeywordRepository; +import com.monew.monew_api.article.repository.InterestArticlesRepository; +import com.monew.monew_api.interest.entity.Interest; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_api.interest.repository.InterestRepository; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.matric.ArticleBatchMetrics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class ArticleItemWriter implements ItemWriter>, StepExecutionListener { + + private final ArticleJdbcRepository articleJdbcRepository; + private final ArticleRepository articleRepository; + private final InterestRepository interestRepository; + private final InterestArticlesRepository interestArticlesRepository; + private final InterestArticleKeywordRepository interestArticleKeywordRepository; + private final ArticleBatchMetrics metrics; + + private final Map newLinkCountsByInterestId = new ConcurrentHashMap<>(); + + private int total = 0; + private int newCount = 0; + private int linkedCount = 0; + + @Override + public void write(Chunk> chunk) { + for (List batch : chunk) { + for (ArticleKeywordPair pair : batch) { + total++; + Article article = pair.article(); + Keyword keyword = pair.keyword(); + + boolean isNew = articleJdbcRepository.insertIgnore(article); + if (isNew) newCount++; + + Article savedArticle = articleRepository.findBySourceUrl(article.getSourceUrl()) + .orElseThrow(); + + List relatedInterests = interestRepository.findAllByKeyword(keyword); + for (Interest interest : relatedInterests) { + int insertedRow = interestArticlesRepository.insertIgnore(interest.getId(), savedArticle.getId()); + if (insertedRow > 0) { + newLinkCountsByInterestId.merge(interest.getId(), 1, Integer::sum); + } + + InterestArticles ia = interestArticlesRepository.findByArticleAndInterest(savedArticle, interest) + .orElseThrow(); + + interestArticleKeywordRepository.insertIgnore(ia.getId(), keyword.getId()); + linkedCount++; + } + } + } + + log.info("Writer 결과 | 총: {} | 신규 기사: {} | 연결: {}", total, newCount, linkedCount); + } + + // 스텝이 모두 끝난 후 한 번만 JobExecutionContext에 데이터 저장 + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + log.info("Writer Step 완료 - 관심사별 누적 데이터: {}", newLinkCountsByInterestId); + + if (!newLinkCountsByInterestId.isEmpty()) { + stepExecution.getJobExecution() + .getExecutionContext() + .put("newLinkCountsByInterestId", newLinkCountsByInterestId); + log.info("JobExecutionContext에 최종 집계 데이터 저장 완료."); + } + + metrics.recordArticles(total, newCount, linkedCount); + log.info("Prometheus 메트릭 기록 완료 | total={}, new={}, linked={}", total, newCount, linkedCount); + + return ExitStatus.COMPLETED; + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java new file mode 100644 index 0000000..be8e943 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/ArticleNotificationRequestListener.java @@ -0,0 +1,27 @@ +package com.monew.monew_batch.article.job; + +import com.monew.monew_batch.notification.service.NotificationAsyncService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleNotificationRequestListener implements JobExecutionListener { + + private final NotificationAsyncService notificationAsyncService; + + @Override + public void afterJob(JobExecution jobExecution) { + @SuppressWarnings("unchecked") + Map stats = + (Map) jobExecution.getExecutionContext().get("newLinkCountsByInterestId"); + + notificationAsyncService.sendNotification(stats); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/ChosunArticleItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/ChosunArticleItemProcessor.java new file mode 100644 index 0000000..e3c48d3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/ChosunArticleItemProcessor.java @@ -0,0 +1,114 @@ +package com.monew.monew_batch.article.job.processor; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.properties.ChosunProperties; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChosunArticleItemProcessor implements ItemProcessor> { + + private final ChosunProperties properties; + + @Override + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("[조선일보 RSS] '{}' 관련 뉴스 수집 시작", keywordText); + + List pairs = new ArrayList<>(); + + for (String feedUrl : properties.getFeeds()) { + try { + SyndFeed feed = fetchFeed(feedUrl); + int totalEntries = feed.getEntries().size(); + int matchedCount = processFeedEntries(feed, keywordText, keyword, pairs); + + log.info("[조선일보 RSS] {} 에서 전체 {}건 중 {}건 매칭", feedUrl, totalEntries, matchedCount); + + } catch (Exception e) { + log.warn("[조선일보 RSS] 피드 파싱 실패: {}", feedUrl, e); + } + } + + log.info("[조선일보 RSS] '{}' 관련 뉴스 최종 {}건 수집 완료", keywordText, pairs.size()); + return pairs.isEmpty() ? Collections.emptyList() : pairs; + } + + /** RSS 피드를 불러와 SyndFeed 객체로 반환 */ + private SyndFeed fetchFeed(String feedUrl) throws Exception { + return new SyndFeedInput().build(new XmlReader(new URL(feedUrl))); + } + + /** 하나의 피드 내 모든 엔트리를 순회하며 필터링 및 수집 */ + private int processFeedEntries(SyndFeed feed, String keywordText, Keyword keyword, List pairs) { + int before = pairs.size(); + + feed.getEntries().forEach(entry -> { + String title = safeText(entry.getTitle()); + String desc = extractDescription(entry); + + // 본문이 비어있으면 스킵 + if (desc.isBlank()) return; + + // 제목 또는 본문에 키워드 포함 시만 수집 + if (!containsKeyword(title, desc, keywordText)) return; + + String link = Optional.ofNullable(entry.getLink()).orElse(""); + LocalDateTime pubDate = parsePublishedDate(entry.getPublishedDate()); + + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, pubDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + }); + + return pairs.size() - before; // 매칭된 건수 반환 + } + + /** title 또는 description 중 하나라도 키워드 포함 여부 검사 */ + private boolean containsKeyword(String title, String desc, String keyword) { + String lower = keyword.toLowerCase(); + return title.toLowerCase().contains(lower) || desc.toLowerCase().contains(lower); + } + + /** description 추출 및 HTML 정리 */ + private String extractDescription(com.rometools.rome.feed.synd.SyndEntry entry) { + return Optional.ofNullable(entry.getDescription()) + .map(d -> cleanText(d.getValue())) + .orElse("") // description이 없으면 빈 문자열 반환 → processFeedEntries에서 스킵 처리됨 + .trim(); + } + + /** null-safe로 title 텍스트 반환 */ + private String safeText(String text) { + return Optional.ofNullable(text).orElse("").trim(); + } + + /** HTML 태그 제거 후 공백 정리 */ + private String cleanText(String text) { + return text.replaceAll("<[^>]*>", "").trim(); + } + + /** 발행일 파싱 (없을 경우 현재 시각 사용) */ + private LocalDateTime parsePublishedDate(java.util.Date publishedDate) { + return Optional.ofNullable(publishedDate) + .map(d -> d.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()) + .orElse(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/HankyungArticleItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/HankyungArticleItemProcessor.java new file mode 100644 index 0000000..b7c68fa --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/HankyungArticleItemProcessor.java @@ -0,0 +1,126 @@ +package com.monew.monew_batch.article.job.processor; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.properties.HankyungProperties; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HankyungArticleItemProcessor implements ItemProcessor> { + + private final HankyungProperties properties; + + @Override + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("[한국경제 RSS] '{}' 관련 뉴스 수집 시작", keywordText); + + List pairs = new ArrayList<>(); + + for (String feedUrl : properties.getFeeds()) { + try { + SyndFeed feed = fetchFeed(feedUrl); + int totalEntries = feed.getEntries().size(); + int matchedCount = processFeedEntries(feed, keywordText, keyword, pairs); + + log.info("[한국경제 RSS] {} 에서 전체 {}건 중 {}건 매칭", feedUrl, totalEntries, matchedCount); + + } catch (Exception e) { + log.warn("[한국경제 RSS] 피드 파싱 실패: {}", feedUrl, e); + } + } + + log.info("[한국경제 RSS] '{}' 관련 뉴스 최종 {}건 수집 완료", keywordText, pairs.size()); + return pairs.isEmpty() ? Collections.emptyList() : pairs; + } + + /** RSS 피드를 불러와 SyndFeed 객체로 반환 */ + private SyndFeed fetchFeed(String feedUrl) throws Exception { + for (int i = 0; i < 3; i++) { + try { + Thread.sleep(800); // 요청 간 간격 확보 + URLConnection connection = new URL(feedUrl).openConnection(); + connection.setRequestProperty("User-Agent", "MoNewBatchBot/1.0 (+https://monew.com)"); + return new SyndFeedInput().build(new XmlReader(connection)); + } catch (IOException e) { + if (i < 2) { + log.warn("[한국경제 RSS] 요청 제한 (429) 발생 - 재시도 {}회차: {}", i + 1, feedUrl); + Thread.sleep(1000); + } else { + throw e; + } + } + } + return null; + } + + /** 하나의 피드 내 모든 엔트리를 순회하며 필터링 및 수집 */ + private int processFeedEntries(SyndFeed feed, String keywordText, Keyword keyword, List pairs) { + int before = pairs.size(); + + feed.getEntries().forEach(entry -> { + String title = safeText(entry.getTitle()); + String desc = extractDescription(entry); + + // 본문이 비어있으면 스킵 + if (desc.isBlank()) return; + + // 제목 또는 본문에 키워드 포함 시만 수집 + if (!containsKeyword(title, desc, keywordText)) return; + + String link = Optional.ofNullable(entry.getLink()).orElse(""); + LocalDateTime pubDate = parsePublishedDate(entry.getPublishedDate()); + + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, pubDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + }); + + return pairs.size() - before; + } + + private boolean containsKeyword(String title, String desc, String keyword) { + String lower = keyword.toLowerCase(); + return title.toLowerCase().contains(lower) || desc.toLowerCase().contains(lower); + } + + private String extractDescription(com.rometools.rome.feed.synd.SyndEntry entry) { + return Optional.ofNullable(entry.getDescription()) + .map(d -> cleanText(d.getValue())) + .orElse("") + .trim(); + } + + private String safeText(String text) { + return Optional.ofNullable(text).orElse("").trim(); + } + + private String cleanText(String text) { + return text.replaceAll("<[^>]*>", "").trim(); + } + + private LocalDateTime parsePublishedDate(java.util.Date publishedDate) { + return Optional.ofNullable(publishedDate) + .map(d -> d.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()) + .orElse(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/NaverArticleItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/NaverArticleItemProcessor.java new file mode 100644 index 0000000..f29e289 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/NaverArticleItemProcessor.java @@ -0,0 +1,102 @@ +package com.monew.monew_batch.article.job.processor; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.properties.NaverProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NaverArticleItemProcessor implements ItemProcessor> { + + private static final int DISPLAY_COUNT = 10; + private static final String SORT_TYPE = "sim"; + private static final int REQUEST_DELAY_MS = 200; + + private final RestTemplate restTemplate; + private final NaverProperties properties; + + @Override + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("'{}' 뉴스 수집 시작", keywordText); + + List> items = fetchNewsItems(keywordText); + if (items.isEmpty()) { + log.warn("[{}] 뉴스 없음", keywordText); + return Collections.emptyList(); + } + + List pairs = new ArrayList<>(); + for (Map item : items) { + String title = cleanText((String) item.get("title")); + String desc = cleanText((String) item.get("description")); + String link = Optional.ofNullable((String) item.get("link")).orElse(""); + String pubDateStr = (String) item.get("pubDate"); + LocalDateTime publishDate = parsePublishDate(pubDateStr); + + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, publishDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + } + + log.info("'{}' 뉴스 {}건 수집 완료", keywordText, pairs.size()); + + try { + Thread.sleep(REQUEST_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("뉴스 수집 중 인터럽트 발생", e); + } + + return pairs; + } + + private List> fetchNewsItems(String keyword) { + String uri = UriComponentsBuilder.fromHttpUrl(properties.getUrl()) + .queryParam("query", keyword) + .queryParam("display", DISPLAY_COUNT) + .queryParam("sort", SORT_TYPE) + .build() + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Naver-Client-Id", properties.getClientId()); + headers.set("X-Naver-Client-Secret", properties.getClientSecret()); + + HttpEntity entity = new HttpEntity<>(headers); + Map response = restTemplate.exchange(uri, HttpMethod.GET, entity, Map.class).getBody(); + return (List>) response.getOrDefault("items", Collections.emptyList()); + } + + private String cleanText(String text) { + return Optional.ofNullable(text) + .map(t -> t.replaceAll("<[^>]*>", "")) + .orElse(""); + } + + private LocalDateTime parsePublishDate(String pubDateStr) { + try { + return ZonedDateTime.parse(pubDateStr, DateTimeFormatter.RFC_1123_DATE_TIME) + .toLocalDateTime(); + } catch (Exception e) { + return LocalDateTime.now(); + } + } + +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/YonhapArticleItemProcessor.java b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/YonhapArticleItemProcessor.java new file mode 100644 index 0000000..4e7793a --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/job/processor/YonhapArticleItemProcessor.java @@ -0,0 +1,114 @@ +package com.monew.monew_batch.article.job.processor; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.interest.entity.Keyword; +import com.monew.monew_batch.article.dto.ArticleKeywordPair; +import com.monew.monew_batch.article.properties.YonhapProperties; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class YonhapArticleItemProcessor implements ItemProcessor> { + + private final YonhapProperties properties; + + @Override + public List process(Keyword keyword) { + String keywordText = keyword.getKeyword(); + log.info("[연합뉴스TV RSS] '{}' 관련 뉴스 수집 시작", keywordText); + + List pairs = new ArrayList<>(); + + for (String feedUrl : properties.getFeeds()) { + try { + SyndFeed feed = fetchFeed(feedUrl); + int totalEntries = feed.getEntries().size(); + int matchedCount = processFeedEntries(feed, keywordText, keyword, pairs); + + log.info("[연합뉴스TV RSS] {} 에서 전체 {}건 중 {}건 매칭", feedUrl, totalEntries, matchedCount); + + } catch (Exception e) { + log.warn("[연합뉴스TV RSS] 피드 파싱 실패: {}", feedUrl, e); + } + } + + log.info("[연합뉴스TV RSS] '{}' 관련 뉴스 최종 {}건 수집 완료", keywordText, pairs.size()); + return pairs.isEmpty() ? Collections.emptyList() : pairs; + } + + /** RSS 피드를 불러와 SyndFeed 객체로 반환 */ + private SyndFeed fetchFeed(String feedUrl) throws Exception { + return new SyndFeedInput().build(new XmlReader(new URL(feedUrl))); + } + + /** 하나의 피드 내 모든 엔트리를 순회하며 필터링 및 수집 */ + private int processFeedEntries(SyndFeed feed, String keywordText, Keyword keyword, List pairs) { + int before = pairs.size(); + + feed.getEntries().forEach(entry -> { + String title = safeText(entry.getTitle()); + String desc = extractDescription(entry); + + // 본문이 비어있으면 스킵 + if (desc.isBlank()) return; + + // 제목 또는 본문에 키워드 포함 시만 수집 + if (!containsKeyword(title, desc, keywordText)) return; + + String link = Optional.ofNullable(entry.getLink()).orElse(""); + LocalDateTime pubDate = parsePublishedDate(entry.getPublishedDate()); + + String articleSourceName = properties.getArticleSource().name(); + Article article = new Article(articleSourceName, link, title, pubDate, desc); + pairs.add(new ArticleKeywordPair(article, keyword)); + }); + + return pairs.size() - before; // 매칭된 건수 반환 + } + + /** title 또는 description 중 하나라도 키워드 포함 여부 검사 */ + private boolean containsKeyword(String title, String desc, String keyword) { + String lower = keyword.toLowerCase(); + return title.toLowerCase().contains(lower) || desc.toLowerCase().contains(lower); + } + + /** description 추출 및 HTML 정리 */ + private String extractDescription(com.rometools.rome.feed.synd.SyndEntry entry) { + return Optional.ofNullable(entry.getDescription()) + .map(d -> cleanText(d.getValue())) + .orElse("") + .trim(); + } + + /** null-safe로 title 텍스트 반환 */ + private String safeText(String text) { + return Optional.ofNullable(text).orElse("").trim(); + } + + /** HTML 태그 제거 후 공백 정리 */ + private String cleanText(String text) { + return text.replaceAll("<[^>]*>", "").trim(); + } + + /** 발행일 파싱 (없을 경우 현재 시각 사용) */ + private LocalDateTime parsePublishedDate(java.util.Date publishedDate) { + return Optional.ofNullable(publishedDate) + .map(d -> d.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()) + .orElse(LocalDateTime.now()); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/matric/ArticleBatchMetrics.java b/monew-batch/src/main/java/com/monew/monew_batch/article/matric/ArticleBatchMetrics.java new file mode 100644 index 0000000..a77d6cc --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/matric/ArticleBatchMetrics.java @@ -0,0 +1,35 @@ +package com.monew.monew_batch.article.matric; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 배치 통계 수집 메트릭 + * - Prometheus에서 batch.*.* 메트릭 이름으로 노출됨 + */ +@Component +@RequiredArgsConstructor +public class ArticleBatchMetrics { + + private final MeterRegistry meterRegistry; + + public void recordArticles(int total, int newCount, int linkedCount) { + meterRegistry.counter("batch.articles.total").increment(total); + meterRegistry.counter("batch.articles.new").increment(newCount); + meterRegistry.counter("batch.articles.linked").increment(linkedCount); + } + + public void recordBackup(boolean success, int count) { + if (success) { + meterRegistry.counter("batch.backup.success").increment(); + meterRegistry.counter("batch.backup.count").increment(count); + } else { + meterRegistry.counter("batch.backup.fail").increment(); + } + } + + public void recordCleanup(int deletedCount) { + meterRegistry.counter("batch.cleanup.deleted").increment(deletedCount); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/ChosunProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/ChosunProperties.java new file mode 100644 index 0000000..ade84f3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/ChosunProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "rss.chosun") +public class ChosunProperties { + private final List feeds; + private final ArticleSource articleSource = ArticleSource.CHOSUN; +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/HankyungProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/HankyungProperties.java new file mode 100644 index 0000000..019138d --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/HankyungProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "rss.hankyung") +public class HankyungProperties { + private final List feeds; + private final ArticleSource articleSource = ArticleSource.HANKYUNG; +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverProperties.java new file mode 100644 index 0000000..63c3ede --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/NaverProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "naver") +public class NaverProperties { + private final String url; + private final String clientId; + private final String clientSecret; + private final ArticleSource articleSource = ArticleSource.NAVER; +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/properties/YonhapProperties.java b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/YonhapProperties.java new file mode 100644 index 0000000..e6b4a31 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/properties/YonhapProperties.java @@ -0,0 +1,16 @@ +package com.monew.monew_batch.article.properties; + +import com.monew.monew_batch.article.enums.ArticleSource; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "rss.yonhap") +public class YonhapProperties { + private final List feeds; + private final ArticleSource articleSource = ArticleSource.YEONHAP; +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java new file mode 100644 index 0000000..7921ad6 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepository.java @@ -0,0 +1,10 @@ +package com.monew.monew_batch.article.repository; + +import com.monew.monew_api.article.dto.ArticleBackupData; + +import java.util.List; + +public interface ArticleBackupQueryRepository { + + List findAllArticlesForBackup(); +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java new file mode 100644 index 0000000..8c3de0c --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/repository/ArticleBackupQueryRepositoryImpl.java @@ -0,0 +1,48 @@ +package com.monew.monew_batch.article.repository; + + +import com.monew.monew_api.article.dto.ArticleBackupData; +import com.monew.monew_api.article.dto.QArticleBackupData_ArticleData; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.monew.monew_api.article.entity.QArticle.article; +import static com.monew.monew_api.article.entity.QInterestArticles.interestArticles; +import static com.monew.monew_api.article.entity.QInterestArticleKeyword.interestArticleKeyword; +import static com.monew.monew_api.interest.entity.QKeyword.keyword1; +import static com.querydsl.core.types.dsl.Expressions.stringTemplate; + +/** + * 뉴스 백업용 QueryDSL 리포지토리 + * - 기사와 연결된 키워드를 한 번에 조회 (N+1 방지) + * - string_agg()로 키워드 문자열을 집계 후 DTO에서 분리 처리 + */ +@Repository +@RequiredArgsConstructor +public class ArticleBackupQueryRepositoryImpl implements ArticleBackupQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllArticlesForBackup() { + return queryFactory + .select(new QArticleBackupData_ArticleData( + article.source, + article.sourceUrl, + article.title, + article.publishDate, + article.summary, + stringTemplate("string_agg({0}, ',')", keyword1.keyword) // PostgreSQL 집계 함수 + )) + .from(article) + .join(article.interestArticles, interestArticles) + .join(interestArticles.interestArticleKeywords, interestArticleKeyword) + .join(interestArticleKeyword.keyword, keyword1) + .where(article.isDeleted.isFalse()) + .groupBy(article.id) + .fetch(); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java new file mode 100644 index 0000000..1dfe588 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBackupScheduler.java @@ -0,0 +1,29 @@ +package com.monew.monew_batch.article.scheduler; + +import com.monew.monew_batch.article.service.AricleBackupService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 뉴스 백업 스케줄러 + * - 매일 새벽 4시, S3 자동 백업 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(value = "app.scheduling.enabled", havingValue = "true", matchIfMissing = true) +public class AricleBackupScheduler { + + private final AricleBackupService aricleBackupService; + +// @Scheduled(cron = "0 20 4 * * *", zone = "Asia/Seoul") + @Scheduled(fixedRate = 600000) // 테스트용 + public void backupNews() { + log.info("🗄 뉴스 백업 시작"); + aricleBackupService.backupAllArticles(); + log.info("🗃 뉴스 백업 완료"); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java new file mode 100644 index 0000000..1077cf3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/AricleBatchScheduler.java @@ -0,0 +1,52 @@ +package com.monew.monew_batch.article.scheduler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@EnableScheduling +public class AricleBatchScheduler { + + private final JobLauncher jobLauncher; + private final Job naverNewsJob; + private final Job chosunRssJob; + private final Job hankyungRssJob; + private final Job yonhapRssJob; + + public AricleBatchScheduler( + JobLauncher jobLauncher, + @Qualifier("naverNewsJob") Job naverNewsJob, + @Qualifier("chosunRssJob") Job chosunRssJob, + @Qualifier("hankyungRssJob") Job hankyungRssJob, + @Qualifier("yonhapRssJob") Job yonhapRssJob + ) { + this.jobLauncher = jobLauncher; + this.naverNewsJob = naverNewsJob; + this.chosunRssJob = chosunRssJob; + this.hankyungRssJob = hankyungRssJob; + this.yonhapRssJob = yonhapRssJob; + } + +// @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") + @Scheduled(fixedRate = 600000) // 테스트용 + public void runJob() throws Exception { + log.info("🕒 [Batch Scheduler] 뉴스 수집 Job 실행"); + + JobParameters params = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(naverNewsJob, params); + jobLauncher.run(chosunRssJob, params); + jobLauncher.run(yonhapRssJob, params); +// jobLauncher.run(hankyungRssJob, params); // 한경은 불안정함(엄격한 속도 제한과 아이피 제한), 사용 불가능 + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java new file mode 100644 index 0000000..e7b78c9 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/scheduler/ArticleCleanupScheduler.java @@ -0,0 +1,43 @@ +package com.monew.monew_batch.article.scheduler; + +import com.monew.monew_api.article.entity.Article; +import com.monew.monew_api.article.repository.ArticleRepository; +import com.monew.monew_batch.article.matric.ArticleBatchMetrics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ArticleCleanupScheduler { + + private final ArticleRepository articleRepository; + private final ArticleBatchMetrics metrics; + + /** + * 매일 새벽 4시에 is_deleted = true인 뉴스들을 물리 삭제 + */ + @Transactional + @Scheduled(cron = "0 10 4 * * *", zone = "Asia/Seoul") +// @Scheduled(fixedRate = 600000) // 테스트용 + public void deleteSoftDeletedArticles() { + log.info("🧹 [ArticleCleanupScheduler] 논리 삭제된 뉴스 정리 시작"); + List
deletedArticles = articleRepository.findAllByIsDeletedTrue(); + if (deletedArticles.isEmpty()) { + log.info("✅ 삭제할 뉴스 없음"); + return; + } + + int total = deletedArticles.size(); + articleRepository.deleteAll(deletedArticles); + + metrics.recordCleanup(total); + log.info("🗑 물리 삭제 완료 | 총 {}건 (FK CASCADE 포함)", total); + } + +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/article/service/AricleBackupService.java b/monew-batch/src/main/java/com/monew/monew_batch/article/service/AricleBackupService.java new file mode 100644 index 0000000..04fb3d3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/article/service/AricleBackupService.java @@ -0,0 +1,76 @@ +package com.monew.monew_batch.article.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.monew.monew_api.article.dto.ArticleBackupData; +import com.monew.monew_batch.article.matric.ArticleBatchMetrics; +import com.monew.monew_batch.article.repository.ArticleBackupQueryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 뉴스 백업 서비스 + * - JPA fetch join 기반으로 조회된 기사/키워드를 S3에 JSON으로 백업 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AricleBackupService { + + private final ArticleBackupQueryRepository backupQueryRepository; + private final ObjectMapper objectMapper; + private final S3Client s3Client; + private final ArticleBatchMetrics metrics; + + @Value("${aws.bucket}") + private String bucketName; + + private static final String PREFIX = "backup/article_backup_"; + + @Transactional(readOnly = true) + public void backupAllArticles() { + List articles = backupQueryRepository.findAllArticlesForBackup(); + + if (articles.isEmpty()) { + log.info("백업할 뉴스가 없습니다. (isDeleted = false)"); + return; + } + + ArticleBackupData backupData = new ArticleBackupData(); + backupData.setBackupDate(LocalDateTime.now()); + backupData.setArticles(articles); + + try { + String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(backupData); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")); + String key = PREFIX + timestamp + ".json"; + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType("application/json") + .build(), + RequestBody.fromString(json, StandardCharsets.UTF_8) + ); + + log.info("✅ 뉴스 전체 백업 완료 | 총 {}건 | S3 Key = {}", articles.size(), key); + metrics.recordBackup(true, articles.size()); + + } catch (Exception e) { + log.error("❌ 뉴스 백업 실패", e); + metrics.recordBackup(false, 0); + throw new RuntimeException("뉴스 백업 실패", e); + } + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/AWSConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/AWSConfig.java new file mode 100644 index 0000000..2b39793 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/AWSConfig.java @@ -0,0 +1,36 @@ +package com.monew.monew_batch.common.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Getter +@Configuration +public class AWSConfig { + @Value("${aws.accessKeyId}") + private String accessKey; + + @Value("${aws.secretKey}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Value("${aws.bucket}") + private String bucket; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey); + + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } +} \ No newline at end of file diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/QuerydslConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/QuerydslConfig.java new file mode 100644 index 0000000..da0e8e2 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.monew.monew_batch.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/common/config/RestTemplateConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..dcb7f11 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/common/config/RestTemplateConfig.java @@ -0,0 +1,15 @@ +package com.monew.monew_batch.common.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/notification/config/NotificationJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/notification/config/NotificationJobConfig.java new file mode 100644 index 0000000..5034d8f --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/notification/config/NotificationJobConfig.java @@ -0,0 +1,63 @@ +package com.monew.monew_batch.notification.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDateTime; + +@Slf4j +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +public class NotificationJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JdbcTemplate jdbcTemplate; + + @Bean + public Job deleteOldNotificationJob() { + + return new JobBuilder("deleteOldNotificationJob", jobRepository) + .start(deleteOldNotificationStep()) + .build(); + } + + @Bean + public Step deleteOldNotificationStep() { + return new StepBuilder("deleteOldNotificationStep", jobRepository) + .tasklet(deleteOldNotificationTasklet(), transactionManager) + .build(); + } + + /** + * 확인한지 일주일 경과한 알림 삭제 + */ + @Bean + public Tasklet deleteOldNotificationTasklet() { + return ((contribution, chunkContext) -> { + log.info("[배치 시작] 확인한지 일주일이 경과한 알림 삭제 작업 시작"); + + LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1); + + String sql = "DELETE FROM notifications WHERE confirmed = true AND updated_at < ?"; + int deletedRows = jdbcTemplate.update(sql, oneWeekAgo); + + log.info("[배치 성공] 오랜된 확인 알림 삭제 작업 완료. 삭제된 개수: {}", deletedRows); + + return RepeatStatus.FINISHED; + }); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java new file mode 100644 index 0000000..88007cf --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/notification/scheduler/NotificationCleanupScheduler.java @@ -0,0 +1,46 @@ +package com.monew.monew_batch.notification.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationCleanupScheduler { + + private final JobLauncher jobLauncher; + private final Job deleteOldNotificationJob; + + // 한국 기준 오전 4시 + @Scheduled(cron = "0 30 4 * * *", zone = "Asia/Seoul") + public void runDeleteOldNotificationJob() { + try { + JobParameters parameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + log.info("[스케줄러] deleteOldNotificationJob 실행"); + + JobExecution exec = jobLauncher.run(deleteOldNotificationJob, parameters); + + // 실행 결과 로그 + log.info("==== Job Finished ===="); + log.info("Status : {}", exec.getStatus()); + log.info("Exit Status : {}", exec.getExitStatus()); + log.info("Job Instance ID : {}", exec.getJobId()); + log.info("Job getCreateTime : {}", exec.getCreateTime()); + log.info("Job getEndTime : {}", exec.getEndTime()); + log.info("Last Updated : {}", exec.getLastUpdated()); + log.info("Failure Exceptions: {}", exec.getFailureExceptions()); + } catch (Exception e) { + log.error("[스케줄러] deleteOldNotificationJob 실행 중 오류 발생", e); + } + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/notification/service/NotificationAsyncService.java b/monew-batch/src/main/java/com/monew/monew_batch/notification/service/NotificationAsyncService.java new file mode 100644 index 0000000..99b48f3 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/notification/service/NotificationAsyncService.java @@ -0,0 +1,37 @@ +package com.monew.monew_batch.notification.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationAsyncService { + + private final RestTemplate restTemplate; + + @Value("${monew.api.url}") + private String monewApiUrl; + + @Async + public void sendNotification(Map stats) { + if (stats == null || stats.isEmpty()) { + log.info("전송할 알림 데이터 없음"); + return; + } + + try { + String apiUrl = monewApiUrl + "/api/internal/notifications/articles-registered"; + restTemplate.postForEntity(apiUrl, stats, Void.class); + log.info("✅ 관심사별 신규 기사 통계 전송 완료: {}개 관심사", stats.size()); + } catch (Exception e) { + log.error("❌ API 서버 알림 요청 실패", e); + } + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java b/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java new file mode 100644 index 0000000..0c96422 --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/config/DeletionJobConfig.java @@ -0,0 +1,110 @@ +package com.monew.monew_batch.user.config; + +import com.monew.monew_api.user.User; +import com.monew.monew_batch.user.metrics.DeletionMetrics; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import jakarta.persistence.EntityManager; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Map; + +/** + * [요구사항] Soft delete 후 1일(24시간) 경과한 사용자를 영구 삭제 + * [프로토타입] 테스트 환경에서는 5분 후 삭제로 구현 (빠른 테스트를 위함) + */ +@Slf4j +@Configuration +@EnableBatchProcessing +@RequiredArgsConstructor +public class DeletionJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final DeletionMetrics deletionMetrics; + + @Value("${batch.user-deletion.chunk-size:10}") + private int chunkSize; + + // 프로토타입: 5분 + @Value("${batch.user-deletion.retention-minutes:5}") + private int retentionMinutes; + + /** + * 사용자 삭제 Job 정의 + */ + @Bean + public Job userDeletionJob(EntityManager entityManager) { + return new JobBuilder("userDeletionJob", jobRepository) + .start(userDeletionStep(entityManager)) + .build(); + } + + /** + * 사용자 삭제 Step 정의 (Chunk 기반 처리) + */ + @Bean + public Step userDeletionStep(EntityManager entityManager) { + return new StepBuilder("userDeletionStep", jobRepository) + .chunk(chunkSize, transactionManager) + .reader(userDeletionReader(null)) + .writer(userDeletionWriter(entityManager)) + .build(); + } + + /** + * ItemReader: 삭제 대상 사용자 조회 + * [프로토타입] 5분 이전 = deletedAt < (현재 - 5분) + */ + @Bean + @StepScope + public JpaPagingItemReader userDeletionReader( + @Value("#{jobParameters['runTime']}") Long runTime) { + LocalDateTime cutoffDate = LocalDateTime.now().minus(retentionMinutes, ChronoUnit.MINUTES); + + log.info("UserDeletionReader 초기화 - cutoffDate: {}, retentionMinutes: {}", cutoffDate, retentionMinutes); + + return new JpaPagingItemReaderBuilder() + .name("userDeletionReader") + .entityManagerFactory(entityManagerFactory) + .queryString("SELECT u FROM User u WHERE u.deletedAt IS NOT NULL AND u.deletedAt < :cutoffDate") + .parameterValues(Map.of("cutoffDate", cutoffDate)) + .pageSize(chunkSize) + .build(); + } + + /** + * ItemWriter: 사용자 영구 삭제 + * DB의 ON DELETE CASCADE로 연관 데이터 자동 삭제 + */ + @Bean + public ItemWriter userDeletionWriter(EntityManager entityManager) { + return chunk -> { + for (User user : chunk.getItems()) { + User managedUser = entityManager.merge(user); + entityManager.remove(managedUser); + + log.info("사용자 삭제: id={}, email={}", user.getId(), user.getEmail()); + deletionMetrics.incrementDeletedUserCount(); + } + log.info("청크 완료: {}명 삭제", chunk.size()); + }; + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/user/metrics/DeletionMetrics.java b/monew-batch/src/main/java/com/monew/monew_batch/user/metrics/DeletionMetrics.java new file mode 100644 index 0000000..39ccdde --- /dev/null +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/metrics/DeletionMetrics.java @@ -0,0 +1,49 @@ +package com.monew.monew_batch.user.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 사용자 삭제 배치 작업에 대한 커스텀 메트릭 + * Spring Actuator를 통해 Prometheus/Grafana로 노출됩니다. + */ +@Slf4j +@Component +public class DeletionMetrics { + + private final Counter deletedUserCounter; + + public DeletionMetrics(MeterRegistry meterRegistry) { + this.deletedUserCounter = Counter.builder("user.deletion.count") + .description("Total number of permanently deleted users") + .tag("type", "batch") + .register(meterRegistry); + + log.info("DeletionMetrics initialized"); + } + + /** + * 삭제된 사용자 수 증가 + */ + public void incrementDeletedUserCount() { + deletedUserCounter.increment(); + log.debug("Deleted user count incremented: current={}", deletedUserCounter.count()); + } + + /** + * 삭제된 사용자 수를 지정된 값만큼 증가 + */ + public void incrementDeletedUserCount(long count) { + deletedUserCounter.increment(count); + log.debug("Deleted user count incremented by {}: current={}", count, deletedUserCounter.count()); + } + + /** + * 현재까지 삭제된 사용자 수 조회 + */ + public double getDeletedUserCount() { + return deletedUserCounter.count(); + } +} diff --git a/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java similarity index 54% rename from monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java rename to monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java index 8ebd7b1..35f33ce 100644 --- a/monew-batch/src/main/java/com/monew/monew_batch/JobScheduler.java +++ b/monew-batch/src/main/java/com/monew/monew_batch/user/scheduler/DeletionScheduler.java @@ -1,4 +1,4 @@ -package com.monew.monew_batch; +package com.monew.monew_batch.user.scheduler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,33 +10,35 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -/** - * 테스트용 - */ @Slf4j @Component @RequiredArgsConstructor -public class JobScheduler { +public class DeletionScheduler { - // job을 실행시켜줄 객체 private final JobLauncher jobLauncher; - private final Job exampleJob01; // 사용자가 만든 job + private final Job userDeletionJob; + + /** + * [요구사항] Soft delete 후 1일 경과한 사용자를 영구 삭제 + * [프로토타입] 5초마다 체크하여 5분 경과한 사용자 삭제 + */ + // @Scheduled(fixedDelay = 50000) + @Scheduled(cron = "0 10 5 * * *", zone = "Asia/Seoul") + public void runUserDeletionJob() throws Exception { + log.info("==== Starting User Deletion Job ===="); - @Scheduled(initialDelay = 1000, fixedRate = 5000) - public void runJob() throws Exception { JobParameters parameters = new JobParametersBuilder() - .addLong("ts", System.currentTimeMillis()) + .addLong("runTime", System.currentTimeMillis()) .toJobParameters(); - JobExecution exec = jobLauncher.run(exampleJob01, parameters); + JobExecution exec = jobLauncher.run(userDeletionJob, parameters); - // 실행 결과 로그 - log.info("==== Job Finished ===="); + log.info("==== User Deletion Job Finished ===="); log.info("Status : {}", exec.getStatus()); log.info("Exit Status : {}", exec.getExitStatus()); log.info("Job Instance ID : {}", exec.getJobId()); - log.info("Job getCreateTime : {}", exec.getCreateTime()); - log.info("Job getEndTime : {}", exec.getEndTime()); + log.info("Job Create Time : {}", exec.getCreateTime()); + log.info("Job End Time : {}", exec.getEndTime()); log.info("Last Updated : {}", exec.getLastUpdated()); log.info("Failure Exceptions: {}", exec.getFailureExceptions()); } diff --git a/monew-batch/src/main/resources/application-dev.yml b/monew-batch/src/main/resources/application-dev.yml index b8c4f13..2d62703 100644 --- a/monew-batch/src/main/resources/application-dev.yml +++ b/monew-batch/src/main/resources/application-dev.yml @@ -1,3 +1,6 @@ +server: + port: 8081 + spring: datasource: url: ${DB_URL} @@ -8,10 +11,12 @@ spring: jpa: hibernate: ddl-auto: update - show-sql: true + show-sql: false properties: hibernate: format_sql: true + jdbc: + time_zone: Asia/Seoul batch: jdbc: @@ -25,17 +30,30 @@ spring: continue-on-error: true schema-locations: classpath:org/springframework/batch/core/schema-postgresql.sql -# 스케줄러 풀 task: scheduling: pool: size: 4 -server: - port: 8081 +management: + endpoints: + web: + exposure: + include: health, info, metrics, prometheus, env + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true logging: level: root: INFO org.springframework.batch: DEBUG - org.hibernate.SQL: DEBUG \ No newline at end of file + org.hibernate.SQL: DEBUG + +monew: + api: + url: http://localhost:8080 \ No newline at end of file diff --git a/monew-batch/src/main/resources/application-prod.yml b/monew-batch/src/main/resources/application-prod.yml index aa61f2c..e39c3ae 100644 --- a/monew-batch/src/main/resources/application-prod.yml +++ b/monew-batch/src/main/resources/application-prod.yml @@ -1,3 +1,6 @@ +server: + port: 8081 + spring: datasource: url: ${DB_URL} @@ -9,12 +12,16 @@ spring: hibernate: ddl-auto: none show-sql: false + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul batch: jdbc: initialize-schema: never job: - enabled: true + enabled: false task: scheduling: @@ -25,14 +32,24 @@ management: endpoints: web: exposure: - include: health,metrics,prometheus + include: health, info, metrics, prometheus, beans endpoint: health: - show-details: never + show-details: when_authorized # 인증된 요청만 상세 노출 + probes: + enabled: true # ECS/K8s 헬스체크용 prometheus: metrics: export: enabled: true + metrics: + tags: + application: monew-batch # prometheus 필터 태그로 사용됨 + enable: + jvm: true + logback: true + process: true + spring.batch: true logging: level: @@ -43,9 +60,10 @@ logging: aws: s3: - access-key: ${AWS_ACCESS_KEY} - secret-key: ${AWS_SECRET_KEY} + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} bucket: monew-backup -server: - port: ${SERVER_PORT:8081} \ No newline at end of file +monew: + api: + url: ${MONEW_API_URL} # 배포 후 추가 필요 \ No newline at end of file diff --git a/monew-batch/src/main/resources/application.yml b/monew-batch/src/main/resources/application.yml index 00ccff3..fb32dda 100644 --- a/monew-batch/src/main/resources/application.yml +++ b/monew-batch/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: application: name: monew-batch profiles: - active: dev + active: prod config: import: optional:file:../.env[.properties],optional:file:.env[.properties] @@ -12,15 +12,79 @@ spring: jdbc: initialize-schema: always +batch: + user-deletion: + chunk-size: 10 + # [요구사항] 1일 후 삭제 + # [프로토타입] 5분으로 설정 + retention-minutes: 5 + management: endpoints: web: exposure: - include: health,info,metrics + include: health,info,metrics,prometheus endpoint: health: show-details: always + prometheus: + metrics: + export: + enabled: true logging: level: - root: INFO \ No newline at end of file + root: INFO + +aws: + accessKeyId: ${AWS_S3_ACCESS_KEY} + secretKey: ${AWS_S3_SECRET_KEY} + region: ${AWS_S3_REGION} + bucket: ${AWS_S3_BUCKET} + +naver: + url: https://openapi.naver.com/v1/search/news.json + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + +rss: + chosun: + feeds: + - https://www.chosun.com/arc/outboundfeeds/rss/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/politics/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/economy/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/national/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/international/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/culture-life/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/opinion/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/sports/?outputType=xml + - https://www.chosun.com/arc/outboundfeeds/rss/category/entertainments/?outputType=xml + + hankyung: + feeds: + - https://www.hankyung.com/feed/economy + - https://www.hankyung.com/feed/finance + - https://www.hankyung.com/feed/realestate + - https://www.hankyung.com/feed/it + - https://www.hankyung.com/feed/politics + - https://www.hankyung.com/feed/international + - https://www.hankyung.com/feed/society + - https://www.hankyung.com/feed/life + - https://www.hankyung.com/feed/opinion + - https://www.hankyung.com/feed/sports + - https://www.hankyung.com/feed/entertainment + + yonhap: + feeds: + - https://www.yna.co.kr/rss/politics.xml + - https://www.yna.co.kr/rss/economy.xml + - https://www.yna.co.kr/rss/society.xml + - https://www.yna.co.kr/rss/culture.xml + - https://www.yna.co.kr/rss/sports.xml + - https://www.yna.co.kr/rss/northkorea.xml + - https://www.yna.co.kr/rss/international.xml + - https://www.yna.co.kr/rss/local.xml + +monew: + api: + url: http://localhost:8080 \ No newline at end of file diff --git a/monew-batch/src/test/java/com/monew/monew_batch/MonewBatchApplicationTests.java b/monew-batch/src/test/java/com/monew/monew_batch/MonewBatchApplicationTests.java deleted file mode 100644 index 0aa33e0..0000000 --- a/monew-batch/src/test/java/com/monew/monew_batch/MonewBatchApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.monew.monew_batch; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class MonewBatchApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/monew-batch/src/test/java/com/monew/monew_batch/s3/AWSS3Test.java b/monew-batch/src/test/java/com/monew/monew_batch/s3/AWSS3Test.java new file mode 100755 index 0000000..af20833 --- /dev/null +++ b/monew-batch/src/test/java/com/monew/monew_batch/s3/AWSS3Test.java @@ -0,0 +1,70 @@ +package com.monew.monew_batch.s3; + +import com.monew.monew_batch.common.config.AWSConfig; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.util.UUID; + +@Slf4j +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AWSS3Test { + + private String testKey; + + @Autowired + private S3Client s3Client; + + @Autowired + private AWSConfig config; + + @BeforeAll + void setUp() { + testKey = "test/" + UUID.randomUUID() + ".txt"; + } + + @Test + @Order(1) + void uploadFile() { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(config.getBucket()) + .key(testKey) + .build(), + RequestBody.fromString("hello world") + ); + log.info("업로드 성공: {}", testKey); + } + + @Test + @Order(2) + void downloadFile() { + String content = s3Client.getObjectAsBytes( + GetObjectRequest.builder() + .bucket(config.getBucket()) + .key(testKey) + .build() + ).asUtf8String(); + + Assertions.assertEquals("hello world", content); + log.info("다운로드 성공: {}", content); + } + + @AfterAll + void cleanUp() { + s3Client.deleteObject( + DeleteObjectRequest.builder() + .bucket(config.getBucket()) + .key(testKey) + .build() + ); + log.info("테스트 파일 삭제 완료: {}", testKey); + } +} \ No newline at end of file diff --git a/monew-batch/src/test/resources/application-test.yml b/monew-batch/src/test/resources/application-test.yml new file mode 100644 index 0000000..dc96d13 --- /dev/null +++ b/monew-batch/src/test/resources/application-test.yml @@ -0,0 +1,37 @@ +server: + port: 0 + +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + batch: + job: + enabled: false + jdbc: + initialize-schema: always + +batch: + user-deletion: + chunk-size: 10 + retention-days: 30 + cron: "0 0 0 * * ?" + +logging: + level: + root: WARN + org.hibernate.SQL: DEBUG + org.springframework: WARN + org.springframework.batch: INFO \ No newline at end of file diff --git a/monew-monitor/src/main/resources/application.yml b/monew-monitor/src/main/resources/application.yml index 1a294bd..11ff4de 100644 --- a/monew-monitor/src/main/resources/application.yml +++ b/monew-monitor/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: config: import: optional:file:.env[.properties] profiles: - active: dev + active: prod application: name: monew-monitor diff --git a/monew-monitor/src/main/resources/prometheus.yml b/monew-monitor/src/main/resources/prometheus.yml new file mode 100644 index 0000000..600957b --- /dev/null +++ b/monew-monitor/src/main/resources/prometheus.yml @@ -0,0 +1,54 @@ +#global: +# scrape_interval: 10s # 10초마다 메트릭 수집 + +# local +#scrape_configs: +# - job_name: 'monew-api' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8080'] +# +# - job_name: 'monew-batch' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8081'] +# +# - job_name: 'monew-monitor' +# metrics_path: '/actuator/prometheus' +# static_configs: +# - targets: ['host.docker.internal:8082'] + + +# (로컬용은 그대로 주석 유지) +global: + scrape_interval: 10s # 10초마다 메트릭 수집 + +# 배포 +scrape_configs: + # 0) Prometheus 자기 자신 (프리픽스 사용하므로 metrics_path 변경) + - job_name: 'prometheus' + metrics_path: /prometheus/metrics + static_configs: + - targets: ['localhost:9090'] + scheme: http + + # 1) monew-api + - job_name: 'monew-api' + metrics_path: /actuator/prometheus + static_configs: + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http + + # 2) monew-batch + - job_name: 'monew-batch' + metrics_path: /batch/actuator/prometheus + static_configs: + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http + + # 3) monew-monitor + - job_name: 'monew-monitor' + metrics_path: /monitor/actuator/prometheus + static_configs: + - targets: ['monew-alb-721921608.ap-northeast-2.elb.amazonaws.com'] + scheme: http \ No newline at end of file