diff --git a/.github/workflows/cd-api-dev.yml b/.github/workflows/cd-api-dev.yml new file mode 100644 index 0000000..4530297 --- /dev/null +++ b/.github/workflows/cd-api-dev.yml @@ -0,0 +1,98 @@ +name: CD API DEV + +on: + push: + branches: [ develop ] + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + api_changed: ${{ steps.filter.outputs.api }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + base: develop + filters: | + api: + - 'api/**' + + build-api: + needs: detect-changes + if: needs.detect-changes.outputs.api_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Grant execute permission for run-test-mysql-redis.sh + run: chmod +x run-test-mysql-redis.sh + working-directory: ./domain + + - name: Run test mysql redis script + run: ./run-test-mysql-redis.sh + working-directory: ./domain + + - name: Test & Build api only + run: ./gradlew :api:build + + - name: Sign in Dockerhub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build the Docker image + run: docker build -f ./Dockerfile --platform linux/amd64 --no-cache -t samhap/kokomen-notification-api:dev . + working-directory: ./api + + - name: Push the Docker Image to Dockerhub + run: docker push samhap/kokomen-notification-api:dev + working-directory: ./api + + deploy-api: + needs: build-api + runs-on: [ self-hosted, dev-notification ] + steps: + - name: Stop existing container + run: sudo docker rm -f kokomen-notification-dev-api || true + + - name: Remove old API Docker image + run: | + if sudo docker images samhap/kokomen-notification-api:dev -q | grep -q .; then + sudo docker rmi -f samhap/kokomen-notification-api:dev || true + fi + + - name: pull docker compose yaml files + working-directory: /home/ubuntu + run: | + [ -d kokomen-notification ] || git clone --filter=blob:none --no-checkout https://github.com/samhap-soft/kokomen-notification.git + cd kokomen-notification + git sparse-checkout init --cone + git fetch origin develop + git checkout develop + git sparse-checkout set docker/dev + git pull origin develop + + - name: Docker Image pull + run: sudo docker pull samhap/kokomen-notification-api:dev + + - name: Docker run + working-directory: /home/ubuntu + env: + MYSQL_ROOT_PASSWORD_DEV: ${{ secrets.MYSQL_ROOT_PASSWORD_DEV }} + SPRING_DATASOURCE_USERNAME_DEV: ${{ secrets.SPRING_DATASOURCE_USERNAME_DEV }} + SPRING_DATASOURCE_PASSWORD_DEV: ${{ secrets.SPRING_DATASOURCE_PASSWORD_DEV }} + run: | + export HOSTNAME=$(hostname) + cd kokomen-notification/docker/dev + sudo -E docker compose -f docker-compose-dev.yml up -d kokomen-notification-dev-api kokomen-notification-mysql-dev diff --git a/.github/workflows/cd-api-prod.yml b/.github/workflows/cd-api-prod.yml new file mode 100644 index 0000000..8dce561 --- /dev/null +++ b/.github/workflows/cd-api-prod.yml @@ -0,0 +1,99 @@ +name: CD API PROD + +on: + push: + branches: [ main ] + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + api_changed: ${{ steps.filter.outputs.api }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + base: main + filters: | + api: + - 'api/**' + + build-api: + needs: detect-changes + if: needs.detect-changes.outputs.api_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Grant execute permission for run-test-mysql-redis.sh + run: chmod +x run-test-mysql-redis.sh + working-directory: ./domain + + - name: Run test mysql redis script + run: ./run-test-mysql-redis.sh + working-directory: ./domain + + - name: Test & Build api only + run: ./gradlew :api:build + + - name: Sign in Dockerhub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build the Docker image + run: docker build -f ./Dockerfile --platform linux/arm64 --no-cache -t samhap/kokomen-notification-api:prod . + working-directory: ./api + + - name: Push the Docker Image to Dockerhub + run: docker push samhap/kokomen-notification-api:prod + working-directory: ./api + + deploy-api: + needs: build-api + runs-on: [ self-hosted, prod-notification ] + + strategy: + matrix: + runner: [ prod-notification-a, prod-notification-b ] + + steps: + - name: Stop existing container + run: sudo docker rm -f kokomen-notification-api || true + + - name: Remove old API Docker image + run: | + if sudo docker images samhap/kokomen-notification-api:prod -q | grep -q .; then + sudo docker rmi -f samhap/kokomen-notification-api:prod || true + fi + + - name: pull docker compose yaml files + working-directory: /home/ubuntu + run: | + [ -d kokomen-notification ] || git clone --filter=blob:none --no-checkout https://github.com/samhap-soft/kokomen-notification.git + cd kokomen-notification + git sparse-checkout init --cone + git fetch origin main + git checkout main + git sparse-checkout set docker/prod + git pull origin main + + - name: Docker Image pull + run: sudo docker pull samhap/kokomen-notification-api:prod + + - name: Docker run + working-directory: /home/ubuntu + run: | + export HOSTNAME=$(hostname) + cd kokomen-notification/docker/prod + sudo -E docker compose -f docker-compose-prod.yml up -d kokomen-notification-api diff --git a/.github/workflows/cd-internal-dev.yml b/.github/workflows/cd-internal-dev.yml new file mode 100644 index 0000000..f041e26 --- /dev/null +++ b/.github/workflows/cd-internal-dev.yml @@ -0,0 +1,98 @@ +name: CD INTERNAL DEV + +on: + push: + branches: [ develop ] + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + internal_changed: ${{ steps.filter.outputs.internal }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + base: develop + filters: | + internal: + - 'internal/**' + + build-internal: + needs: detect-changes + if: needs.detect-changes.outputs.internal_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Grant execute permission for run-test-mysql-redis.sh + run: chmod +x run-test-mysql-redis.sh + working-directory: ./domain + + - name: Run test mysql redis script + run: ./run-test-mysql-redis.sh + working-directory: ./domain + + - name: Test & Build internal only + run: ./gradlew :internal:build + + - name: Sign in Dockerhub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build the Docker image + run: docker build -f ./Dockerfile --platform linux/amd64 --no-cache -t samhap/kokomen-notification-internal:dev . + working-directory: ./internal + + - name: Push the Docker Image to Dockerhub + run: docker push samhap/kokomen-notification-internal:dev + working-directory: ./internal + + deploy-internal: + needs: build-internal + runs-on: [ self-hosted, dev-notification ] + steps: + - name: Stop existing container + run: sudo docker rm -f kokomen-notification-dev-internal || true + + - name: Remove old INTERNAL Docker image + run: | + if sudo docker images samhap/kokomen-notification-internal:dev -q | grep -q .; then + sudo docker rmi -f samhap/kokomen-notification-internal:dev || true + fi + + - name: pull docker compose yaml files + working-directory: /home/ubuntu + run: | + [ -d kokomen-notification ] || git clone --filter=blob:none --no-checkout https://github.com/samhap-soft/kokomen-notification.git + cd kokomen-notification + git sparse-checkout init --cone + git fetch origin develop + git checkout develop + git sparse-checkout set docker/dev + git pull origin develop + + - name: Docker Image pull + run: sudo docker pull samhap/kokomen-notification-internal:dev + + - name: Docker run + working-directory: /home/ubuntu + env: + MYSQL_ROOT_PASSWORD_DEV: ${{ secrets.MYSQL_ROOT_PASSWORD_DEV }} + SPRING_DATASOURCE_USERNAME_DEV: ${{ secrets.SPRING_DATASOURCE_USERNAME_DEV }} + SPRING_DATASOURCE_PASSWORD_DEV: ${{ secrets.SPRING_DATASOURCE_PASSWORD_DEV }} + run: | + export HOSTNAME=$(hostname) + cd kokomen-notification/docker/dev + sudo -E docker compose -f docker-compose-dev.yml up -d kokomen-notification-dev-internal kokomen-notification-mysql-dev diff --git a/.github/workflows/cd-internal-prod.yml b/.github/workflows/cd-internal-prod.yml new file mode 100644 index 0000000..b3849a3 --- /dev/null +++ b/.github/workflows/cd-internal-prod.yml @@ -0,0 +1,99 @@ +name: CD INTERNAL PROD + +on: + push: + branches: [ main ] + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + internal_changed: ${{ steps.filter.outputs.internal }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + base: main + filters: | + internal: + - 'internal/**' + + build-internal: + needs: detect-changes + if: needs.detect-changes.outputs.internal_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Grant execute permission for run-test-mysql-redis.sh + run: chmod +x run-test-mysql-redis.sh + working-directory: ./domain + + - name: Run test mysql redis script + run: ./run-test-mysql-redis.sh + working-directory: ./domain + + - name: Test & Build internal only + run: ./gradlew :internal:build + + - name: Sign in Dockerhub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build the Docker image + run: docker build -f ./Dockerfile --platform linux/arm64 --no-cache -t samhap/kokomen-notification-internal:prod . + working-directory: ./internal + + - name: Push the Docker Image to Dockerhub + run: docker push samhap/kokomen-notification-internal:prod + working-directory: ./internal + + deploy-internal: + needs: build-internal + runs-on: [ self-hosted, prod-notification ] + + strategy: + matrix: + runner: [ prod-notification-a, prod-notification-b ] + + steps: + - name: Stop existing container + run: sudo docker rm -f kokomen-notification-internal || true + + - name: Remove old INTERNAL Docker image + run: | + if sudo docker images samhap/kokomen-notification-internal:prod -q | grep -q .; then + sudo docker rmi -f samhap/kokomen-notification-internal:prod || true + fi + + - name: pull docker compose yaml files + working-directory: /home/ubuntu + run: | + [ -d kokomen-notification ] || git clone --filter=blob:none --no-checkout https://github.com/samhap-soft/kokomen-notification.git + cd kokomen-notification + git sparse-checkout init --cone + git fetch origin main + git checkout main + git sparse-checkout set docker/prod + git pull origin main + + - name: Docker Image pull + run: sudo docker pull samhap/kokomen-notification-internal:prod + + - name: Docker run + working-directory: /home/ubuntu + run: | + export HOSTNAME=$(hostname) + cd kokomen-notification/docker/prod + sudo -E docker compose -f docker-compose-prod.yml up -d kokomen-notification-internal diff --git a/.github/workflows/ci-api-test.yml b/.github/workflows/ci-api-test.yml new file mode 100644 index 0000000..fcaded1 --- /dev/null +++ b/.github/workflows/ci-api-test.yml @@ -0,0 +1,65 @@ +name: CI API TEST + +on: + pull_request: + branches: [ main, develop ] + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + api_changed: ${{ steps.filter.outputs.api }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + api: + - 'api/**' + + build: + needs: detect-changes + if: needs.detect-changes.outputs.api_changed == 'true' + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Grant execute permission for run-test-mysql-redis.sh + run: chmod +x run-test-mysql-redis.sh + working-directory: ./domain + + - name: Run test mysql redis script + run: ./run-test-mysql-redis.sh + working-directory: ./domain + + - name: Test & Build api only + run: ./gradlew :api:build + + - name: Publish Unit Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: ${{ always() }} + with: + files: ${{ github.workspace }}/api/build/test-results/**/*.xml + seconds_between_github_reads: 1.0 + seconds_between_github_writes: 3.0 + secondary_rate_limit_wait_seconds: 90.0 + + - name: When test fail, comment on that code + uses: mikepenz/action-junit-report@v3 + if: always() + with: + report_paths: ${{ github.workspace }}/api/build/test-results/**/*.xml + token: ${{ github.token }} diff --git a/.github/workflows/ci-internal-test.yml b/.github/workflows/ci-internal-test.yml new file mode 100644 index 0000000..f0707e8 --- /dev/null +++ b/.github/workflows/ci-internal-test.yml @@ -0,0 +1,65 @@ +name: CI INTERNAL TEST + +on: + pull_request: + branches: [ main, develop ] + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + internal_changed: ${{ steps.filter.outputs.internal }} + steps: + - uses: actions/checkout@v4 + - id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + internal: + - 'internal/**' + + build: + needs: detect-changes + if: needs.detect-changes.outputs.internal_changed == 'true' + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Grant execute permission for run-test-mysql-redis.sh + run: chmod +x run-test-mysql-redis.sh + working-directory: ./domain + + - name: Run test mysql redis script + run: ./run-test-mysql-redis.sh + working-directory: ./domain + + - name: Test & Build internal only + run: ./gradlew :internal:build + + - name: Publish Unit Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: ${{ always() }} + with: + files: ${{ github.workspace }}/internal/build/test-results/**/*.xml + seconds_between_github_reads: 1.0 + seconds_between_github_writes: 3.0 + secondary_rate_limit_wait_seconds: 90.0 + + - name: When test fail, comment on that code + uses: mikepenz/action-junit-report@v3 + if: always() + with: + report_paths: ${{ github.workspace }}/internal/build/test-results/**/*.xml + token: ${{ github.token }} diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..2ba0963 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:17-jdk + +COPY build/libs/*SNAPSHOT.jar /app.jar + +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/api/build.gradle b/api/build.gradle index 4a1332b..e4bd56d 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -1,50 +1,38 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.3' - id 'io.spring.dependency-management' version '1.1.7' - id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } -group = 'com.samhap' -version = '0.0.1-SNAPSHOT' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } +ext { + set('snippetsDir', file("build/generated-snippets")) } -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} +dependencies { + implementation project(':domain') + implementation project(':common') + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.session:spring-session-data-redis' -repositories { - mavenCentral() -} + runtimeOnly 'com.mysql:mysql-connector-j' -ext { - set('snippetsDir', file("build/generated-snippets")) + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' } -dependencies { - implementation project(':domain') - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +tasks.named('test') { + useJUnitPlatform() + outputs.dir snippetsDir } -tasks.named('test') { - outputs.dir snippetsDir - useJUnitPlatform() +asciidoctor { + attributes 'snippetsDir': snippetsDir + inputs.dir snippetsDir + dependsOn test } -tasks.named('asciidoctor') { - inputs.dir snippetsDir - dependsOn test +bootJar { + dependsOn asciidoctor + from("${asciidoctor.outputDir}") { + into 'static/docs' + } } diff --git a/api/local-api-docker-compose.yml b/api/local-api-docker-compose.yml new file mode 100644 index 0000000..de7418f --- /dev/null +++ b/api/local-api-docker-compose.yml @@ -0,0 +1,18 @@ +services: + kokomen-notification-local-api: + container_name: kokomen-notification-local-api + build: + dockerfile: ./Dockerfile + no_cache: true + ports: + - 8100:8080 + - 8001:8001 + environment: + SPRING_PROFILES_ACTIVE: local + networks: + - local-kokomen-net + +networks: + local-kokomen-net: + external: true + driver: bridge diff --git a/api/run-local-api.sh b/api/run-local-api.sh new file mode 100755 index 0000000..15590bc --- /dev/null +++ b/api/run-local-api.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +set -e + +# 현재 쉘 스크립트 파일의 디렉토리로 이동 +cd "$(dirname "$0")" + +cd ../common + +chmod +x run-test-redis.sh +./run-test-redis.sh + +cd ../domain + +chmod +x run-test-mysql.sh +./run-test-mysql.sh + +# 도커 네트워크 생성 +NETWORK_NAME="local-kokomen-net" + +if ! docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then + echo "Creating Docker network: $NETWORK_NAME" + docker network create --driver bridge "$NETWORK_NAME" +else + echo "Docker network '$NETWORK_NAME' already exists. Skipping." +fi + +# 도메인 도커 컴포즈 실행 +docker compose -f ../domain/local-docker-compose.yml up -d + +../gradlew clean :api:build + +# 로컬 도커 컴포즈 실행 +# 실행 전에 헬스 체크 필요 +cd ../api +docker rm -f kokomen-notification-local-api +docker compose -f local-api-docker-compose.yml up --build -d diff --git a/api/src/docs/asciidoc/index.adoc b/api/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..4341c9d --- /dev/null +++ b/api/src/docs/asciidoc/index.adoc @@ -0,0 +1,46 @@ += Kokomen API Guide +:doctype: book +:icons: font +:toc: left +:toc-title: Table of Contents +:toclevels: 2 +:source-highlighter: highlightjs +:sectlinks: +:sectnums: + +== 알림 + +=== 자신의 안 읽은 알림 조회 + +include::{snippetsDir}/notification-findMyUnreadNotifications/http-request.adoc[] +include::{snippetsDir}/notification-findMyUnreadNotifications/query-parameters.adoc[] +include::{snippetsDir}/notification-findMyUnreadNotifications/request-headers.adoc[] +include::{snippetsDir}/notification-findMyUnreadNotifications/http-response.adoc[] +include::{snippetsDir}/notification-findMyUnreadNotifications/response-fields.adoc[] +include::{snippetsDir}/notification-findMyUnreadNotifications/curl-request.adoc[] + +=== 자신의 읽은 알림 조회 + +include::{snippetsDir}/notification-findMyReadNotifications/http-request.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications/query-parameters.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications/request-headers.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications/http-response.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications/response-fields.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications/curl-request.adoc[] + +=== 자신의 알림 타입별 조회 상세(읽음 여부와 관련 없이 동일) + +==== 인터뷰 좋아요 알림 + +include::{snippetsDir}/notification-findMyReadNotifications-interviewLike/http-response.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications-interviewLike/response-fields.adoc[] + +==== 답변 좋아요 알림 + +include::{snippetsDir}/notification-findMyReadNotifications-answerLike/http-response.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications-answerLike/response-fields.adoc[] + +==== 인터뷰 조회수 알림 + +include::{snippetsDir}/notification-findMyReadNotifications-interviewViewCount/http-response.adoc[] +include::{snippetsDir}/notification-findMyReadNotifications-interviewViewCount/response-fields.adoc[] diff --git a/api/src/main/java/com/samhap/kokomen/KokomenApplication.java b/api/src/main/java/com/samhap/kokomen/KokomenApplication.java deleted file mode 100644 index 7b02c8a..0000000 --- a/api/src/main/java/com/samhap/kokomen/KokomenApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.samhap.kokomen; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class KokomenApplication { - - public static void main(String[] args) { - SpringApplication.run(KokomenApplication.class, args); - } - -} diff --git a/api/src/main/java/com/samhap/kokomen/KokomenNotificationApiApplication.java b/api/src/main/java/com/samhap/kokomen/KokomenNotificationApiApplication.java new file mode 100644 index 0000000..f432d33 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/KokomenNotificationApiApplication.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class KokomenNotificationApiApplication { + + public static void main(String[] args) { + SpringApplication.run(KokomenNotificationApiApplication.class, args); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/global/annotation/Authentication.java b/api/src/main/java/com/samhap/kokomen/global/annotation/Authentication.java new file mode 100644 index 0000000..ab06d8f --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/annotation/Authentication.java @@ -0,0 +1,12 @@ +package com.samhap.kokomen.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Authentication { + boolean required() default true; +} diff --git a/api/src/main/java/com/samhap/kokomen/global/config/WebConfig.java b/api/src/main/java/com/samhap/kokomen/global/config/WebConfig.java new file mode 100644 index 0000000..10c1f1b --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/config/WebConfig.java @@ -0,0 +1,37 @@ +package com.samhap.kokomen.global.config; + +import com.samhap.kokomen.global.infrastructure.MemberAuthArgumentResolver; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final String[] allowedOrigins; + + public WebConfig( + @Value("${cors.allowed-origins}") String[] allowedOrigins + ) { + this.allowedOrigins = allowedOrigins; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS") + .allowCredentials(true) + .allowedHeaders("*"); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.addAll(List.of( + new MemberAuthArgumentResolver() + )); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java b/api/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java new file mode 100644 index 0000000..69d1d9f --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/dto/ErrorResponse.java @@ -0,0 +1,6 @@ +package com.samhap.kokomen.global.dto; + +public record ErrorResponse( + String message +) { +} diff --git a/api/src/main/java/com/samhap/kokomen/global/dto/MemberAuth.java b/api/src/main/java/com/samhap/kokomen/global/dto/MemberAuth.java new file mode 100644 index 0000000..3d39706 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/dto/MemberAuth.java @@ -0,0 +1,16 @@ +package com.samhap.kokomen.global.dto; + +public record MemberAuth( + Long memberId +) { + + private static final MemberAuth NOT_AUTHENTICATED = new MemberAuth(null); + + public static MemberAuth notAuthenticated() { + return NOT_AUTHENTICATED; + } + + public boolean isAuthenticated() { + return memberId != null; + } +} diff --git a/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..1173136 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,49 @@ +package com.samhap.kokomen.global.exception; + +import com.samhap.kokomen.global.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +// TODO: HttpMessageNotReadableException 예외 처리 추가 +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(KokomenException.class) + public ResponseEntity handleKokomenException(KokomenException e) { + log.warn("KokomenException :: status: {}, message: {}", e.getHttpStatusCode(), e.getMessage()); + return ResponseEntity.status(e.getHttpStatusCode()) + .body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String defaultErrorMessageForUser = "잘못된 요청입니다."; + String message = e.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse(defaultErrorMessageForUser); + + if (message.equals(defaultErrorMessageForUser)) { + log.warn("MethodArgumentNotValidException :: message: {}", e.getMessage()); + } else { + log.warn("MethodArgumentNotValidException :: message: {}", message); + } + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java new file mode 100644 index 0000000..10825ed --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java @@ -0,0 +1,62 @@ +package com.samhap.kokomen.global.infrastructure; + +import com.samhap.kokomen.global.annotation.Authentication; +import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Slf4j +@Component +public class MemberAuthArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(MemberAuth.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + Authentication authentication = parameter.getParameterAnnotation(Authentication.class); + if (authentication == null) { + throw new IllegalStateException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); + } + boolean authenticationRequired = authentication.required(); + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + HttpSession session = request.getSession(false); + + validateAuthentication(session, authenticationRequired); + if (session == null) { + return MemberAuth.notAuthenticated(); + } + Long memberId = (Long) session.getAttribute("MEMBER_ID"); + validateAuthentication(memberId, authenticationRequired); + + return new MemberAuth(memberId); + } + + private void validateAuthentication(HttpSession session, boolean authenticationRequired) { + if (session == null && authenticationRequired) { + throw new UnauthorizedException("로그인이 필요합니다"); + } + } + + private void validateAuthentication(Long memberId, boolean authenticationRequired) { + if (memberId == null) { + log.error("세션에 MEMBER_ID가 없습니다."); + } + if (memberId == null && authenticationRequired) { + throw new IllegalStateException("세션에 MEMBER_ID가 없습니다."); + } + } +} diff --git a/api/src/main/java/com/samhap/kokomen/global/logging/LoggingFilter.java b/api/src/main/java/com/samhap/kokomen/global/logging/LoggingFilter.java new file mode 100644 index 0000000..52bc5ed --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/logging/LoggingFilter.java @@ -0,0 +1,78 @@ +package com.samhap.kokomen.global.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StopWatch; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +public class LoggingFilter extends OncePerRequestFilter { + + private static final List WHITE_LIST = List.of( + "/favicon.ico", + "/docs/index.html", + "/metrics", + "/actuator/**"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + String requestId = readRequestId(request); + MDC.put("requestId", requestId); + + try { + filterChain.doFilter(request, response); + } finally { + stopWatch.stop(); + log.info("{} {} {} ({}) - {}ms", + readMemberId(request), + request.getMethod(), + request.getRequestURI(), + HttpStatus.valueOf(response.getStatus()), + stopWatch.getTotalTimeMillis()); + + MDC.clear(); + } + } + + private String readRequestId(HttpServletRequest request) { + String requestId = request.getHeader("X-RequestID"); + if (requestId != null && !requestId.isEmpty()) { + return requestId; + } + return UUID.randomUUID().toString(); + } + + private String readMemberId(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session != null) { + Long memberId = (Long) session.getAttribute("MEMBER_ID"); + if (memberId != null) { + return "memberId=" + memberId; + } + } + return ""; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String requestURI = request.getRequestURI(); + AntPathMatcher antPathMatcher = new AntPathMatcher(); + return WHITE_LIST.stream().anyMatch(path -> antPathMatcher.match(path, requestURI)); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/notification/controller/NotificationApiController.java b/api/src/main/java/com/samhap/kokomen/notification/controller/NotificationApiController.java new file mode 100644 index 0000000..13768d9 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/notification/controller/NotificationApiController.java @@ -0,0 +1,42 @@ +package com.samhap.kokomen.notification.controller; + +import com.samhap.kokomen.global.annotation.Authentication; +import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.notification.service.NotificationApiService; +import com.samhap.kokomen.notification.service.dto.ReadNotificationResponses; +import com.samhap.kokomen.notification.service.dto.UnreadNotificationResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.data.web.SortDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/notifications") +@RestController +public class NotificationApiController { + + private final NotificationApiService notificationApiService; + + @GetMapping("/me/unread") + public ResponseEntity findMyUnreadNotifications( + @RequestParam(required = false, defaultValue = "10") int size, + @SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort, + @Authentication MemberAuth memberAuth + ) { + return ResponseEntity.ok(notificationApiService.findMyUnreadNotifications(memberAuth, size, sort)); + } + + @GetMapping("/me/read") + public ResponseEntity findMyReadNotifications( + @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable, + @Authentication MemberAuth memberAuth + ) { + return ResponseEntity.ok(notificationApiService.findMyReadNotifications(memberAuth, pageable)); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/notification/service/NotificationApiService.java b/api/src/main/java/com/samhap/kokomen/notification/service/NotificationApiService.java new file mode 100644 index 0000000..f72a05c --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/notification/service/NotificationApiService.java @@ -0,0 +1,44 @@ +package com.samhap.kokomen.notification.service; + +import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.notification.domain.Notification; +import com.samhap.kokomen.notification.domain.NotificationState; +import com.samhap.kokomen.notification.repository.NotificationRepository; +import com.samhap.kokomen.notification.service.dto.ReadNotificationResponses; +import com.samhap.kokomen.notification.service.dto.UnreadNotificationResponses; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class NotificationApiService { + + private final NotificationRepository notificationRepository; + + @Transactional(readOnly = true) + public UnreadNotificationResponses findMyUnreadNotifications(MemberAuth memberAuth, int size, Sort sort) { + int sizeForHasNext = size + 1; + List unreadNotifications = findMyNotifications(memberAuth, NotificationState.UNREAD, PageRequest.of(0, sizeForHasNext, sort)); + unreadNotifications.stream() + .limit(size) + .forEach(Notification::markAsRead); + + return UnreadNotificationResponses.of(unreadNotifications, size); + } + + @Transactional(readOnly = true) + public ReadNotificationResponses findMyReadNotifications(MemberAuth memberAuth, Pageable pageable) { + List readNotifications = findMyNotifications(memberAuth, NotificationState.READ, pageable); + + return ReadNotificationResponses.from(readNotifications); + } + + private List findMyNotifications(MemberAuth memberAuth, NotificationState notificationState, Pageable pageable) { + return notificationRepository.findByReceiverMemberIdAndNotificationState(memberAuth.memberId(), notificationState, pageable); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/notification/service/dto/NotificationResponse.java b/api/src/main/java/com/samhap/kokomen/notification/service/dto/NotificationResponse.java new file mode 100644 index 0000000..f6a46b7 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/notification/service/dto/NotificationResponse.java @@ -0,0 +1,15 @@ +package com.samhap.kokomen.notification.service.dto; + +import com.samhap.kokomen.notification.domain.Notification; +import com.samhap.kokomen.notification.domain.payload.NotificationPayload; +import java.time.LocalDateTime; + +public record NotificationResponse( + NotificationPayload notificationPayload, + LocalDateTime createdAt +) { + + public NotificationResponse(Notification notification) { + this(notification.getNotificationPayload(), notification.getCreatedAt()); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/notification/service/dto/ReadNotificationResponses.java b/api/src/main/java/com/samhap/kokomen/notification/service/dto/ReadNotificationResponses.java new file mode 100644 index 0000000..4e05097 --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/notification/service/dto/ReadNotificationResponses.java @@ -0,0 +1,17 @@ +package com.samhap.kokomen.notification.service.dto; + +import com.samhap.kokomen.notification.domain.Notification; +import java.util.List; + +public record ReadNotificationResponses( + List notifications +) { + + public static ReadNotificationResponses from(List notifications) { + List notificationPayloads = notifications.stream() + .map(NotificationResponse::new) + .toList(); + + return new ReadNotificationResponses(notificationPayloads); + } +} diff --git a/api/src/main/java/com/samhap/kokomen/notification/service/dto/UnreadNotificationResponses.java b/api/src/main/java/com/samhap/kokomen/notification/service/dto/UnreadNotificationResponses.java new file mode 100644 index 0000000..f2d986a --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/notification/service/dto/UnreadNotificationResponses.java @@ -0,0 +1,20 @@ +package com.samhap.kokomen.notification.service.dto; + +import com.samhap.kokomen.notification.domain.Notification; +import java.util.List; + +public record UnreadNotificationResponses( + List notifications, + Boolean hasNext +) { + + public static UnreadNotificationResponses of(List notifications, int size) { + boolean hasNext = notifications.size() > size; + List notificationPayloads = notifications.stream() + .limit(size) + .map(NotificationResponse::new) + .toList(); + + return new UnreadNotificationResponses(notificationPayloads, hasNext); + } +} diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml index e69de29..ac41bde 100644 --- a/api/src/main/resources/application.yml +++ b/api/src/main/resources/application.yml @@ -0,0 +1,68 @@ +management: + server: + address: 0.0.0.0 + port: 8001 +spring: + application: + name: kokomen-notification-api + profiles: + include: + - domain + - common + jackson: + property-naming-strategy: SNAKE_CASE + default-property-inclusion: non_null + session: + store-type: redis +server: + servlet: + session: + timeout: 24h + cookie: + name: JSESSIONID + path: / + http-only: true + same-site: lax + max-age: 86400 + +--- +# local profile +spring: + config: + activate: + on-profile: local +cors: + allowed-origins: http://localhost:8080 +server: + servlet: + session: + cookie: + secure: true +--- +# dev profile +spring: + config: + activate: + on-profile: dev +cors: + allowed-origins: https://dev.kokomen.kr, https://kokomen.kr:3000, https://local.kokomen.kr:3000, http://local.kokomen.kr:3000, https://www.webview-dev.kokomen.kr, https://webview-dev.kokomen.kr +server: + servlet: + session: + cookie: + domain: kokomen.kr + secure: false +--- +# prod profile +spring: + config: + activate: + on-profile: prod +cors: + allowed-origins: https://kokomen.kr, https://www.kokomen.kr, https://www.webview.kokomen.kr, https://webview.kokomen.kr +server: + servlet: + session: + cookie: + domain: kokomen.kr + secure: true diff --git a/api/src/test/java/com/samhap/kokomen/KokomenApplicationTests.java b/api/src/test/java/com/samhap/kokomen/KokomenApplicationTests.java deleted file mode 100644 index 234e7e7..0000000 --- a/api/src/test/java/com/samhap/kokomen/KokomenApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.samhap.kokomen; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class KokomenApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/api/src/test/java/com/samhap/kokomen/KokomenNotificationApiApplicationTests.java b/api/src/test/java/com/samhap/kokomen/KokomenNotificationApiApplicationTests.java new file mode 100644 index 0000000..346e510 --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/KokomenNotificationApiApplicationTests.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen; + +import com.samhap.kokomen.global.BaseTest; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class KokomenNotificationApiApplicationTests extends BaseTest { + + @Test + void contextLoads() { + } + +} diff --git a/api/src/test/java/com/samhap/kokomen/global/BaseControllerTest.java b/api/src/test/java/com/samhap/kokomen/global/BaseControllerTest.java new file mode 100644 index 0000000..fd9e39b --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/global/BaseControllerTest.java @@ -0,0 +1,39 @@ +package com.samhap.kokomen.global; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith(RestDocumentationExtension.class) +public abstract class BaseControllerTest extends BaseTest { + + protected MockMvc mockMvc; + + @BeforeEach + void baseControllerTestSetUp(WebApplicationContext context, RestDocumentationContextProvider restDocumentation) { + var uriPreprocessor = modifyUris() + .scheme("https") + .host("notification.api.kokomen.kr") + .removePort(); + + var headerPreprocessor = modifyHeaders().remove(HttpHeaders.CONTENT_LENGTH); + + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .alwaysDo(print()) + .apply(documentationConfiguration(restDocumentation).operationPreprocessors() + .withRequestDefaults(uriPreprocessor, prettyPrint(), headerPreprocessor) + .withResponseDefaults(prettyPrint(), headerPreprocessor) + ).build(); + } +} diff --git a/api/src/test/java/com/samhap/kokomen/global/BaseTest.java b/api/src/test/java/com/samhap/kokomen/global/BaseTest.java new file mode 100644 index 0000000..739137d --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/global/BaseTest.java @@ -0,0 +1,21 @@ +package com.samhap.kokomen.global; + + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = WebEnvironment.MOCK) +public abstract class BaseTest { + + @Autowired + private MySQLDatabaseCleaner mySQLDatabaseCleaner; + + @BeforeEach + void baseTestSetUp() { + mySQLDatabaseCleaner.executeTruncate(); + } +} diff --git a/api/src/test/java/com/samhap/kokomen/global/MySQLDatabaseCleaner.java b/api/src/test/java/com/samhap/kokomen/global/MySQLDatabaseCleaner.java new file mode 100644 index 0000000..9db2dd5 --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/global/MySQLDatabaseCleaner.java @@ -0,0 +1,69 @@ +package com.samhap.kokomen.global; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.metamodel.EntityType; +import java.util.List; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class MySQLDatabaseCleaner { + + public static final String CAMEL_CASE = "([a-z])([A-Z])"; + public static final String SNAKE_CASE = "$1_$2"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @PostConstruct + public void findTableNames() { + tableNames = entityManager.getMetamodel().getEntities().stream() + .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) + .map(MySQLDatabaseCleaner::convertCamelToSnake) + .toList(); + } + + private static String convertCamelToSnake(final EntityType e) { + return e.getName() + .replaceAll(CAMEL_CASE, SNAKE_CASE) + .toLowerCase(); + } + + @Transactional + public void executeTruncate() { + entityManager.flush(); + entityManager.clear(); + + disableIntegrity(); + for (String tableName : tableNames) { + truncateTable(tableName); + resetAutoIncrement(tableName); + } + enableIntegrity(); + } + + private void disableIntegrity() { + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0") + .executeUpdate(); + } + + private void truncateTable(final String tableName) { + entityManager.createNativeQuery(String.format("TRUNCATE TABLE %s", tableName)) + .executeUpdate(); + } + + private void resetAutoIncrement(final String tableName) { + entityManager.createNativeQuery(String.format("ALTER TABLE %s AUTO_INCREMENT = 1", tableName)) + .executeUpdate(); + } + + private void enableIntegrity() { + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1") + .executeUpdate(); + } +} diff --git a/api/src/test/java/com/samhap/kokomen/global/fixture/NotificationFixtureBuilder.java b/api/src/test/java/com/samhap/kokomen/global/fixture/NotificationFixtureBuilder.java new file mode 100644 index 0000000..d3af125 --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/global/fixture/NotificationFixtureBuilder.java @@ -0,0 +1,48 @@ +package com.samhap.kokomen.global.fixture; + +import com.samhap.kokomen.notification.domain.Notification; +import com.samhap.kokomen.notification.domain.NotificationState; +import com.samhap.kokomen.notification.domain.NotificationType; +import com.samhap.kokomen.notification.domain.payload.InterviewLikePayload; +import com.samhap.kokomen.notification.domain.payload.NotificationPayload; + +public class NotificationFixtureBuilder { + + private Long id; + private Long receiverMemberId; + private NotificationPayload notificationPayload; + private NotificationState notificationState; + + public static NotificationFixtureBuilder builder() { + return new NotificationFixtureBuilder(); + } + + public NotificationFixtureBuilder id(Long id) { + this.id = id; + return this; + } + + public NotificationFixtureBuilder receiverMemberId(Long receiverMemberId) { + this.receiverMemberId = receiverMemberId; + return this; + } + + public NotificationFixtureBuilder notificationPayload(NotificationPayload notificationPayload) { + this.notificationPayload = notificationPayload; + return this; + } + + public NotificationFixtureBuilder notificationState(NotificationState notificationState) { + this.notificationState = notificationState; + return this; + } + + public Notification build() { + return new Notification( + id != null ? id : null, + receiverMemberId != null ? receiverMemberId : 1L, + notificationPayload != null ? notificationPayload : new InterviewLikePayload(NotificationType.INTERVIEW_LIKE, 1L, 1L, 10L), + notificationState != null ? notificationState : NotificationState.UNREAD + ); + } +} diff --git a/api/src/test/java/com/samhap/kokomen/notification/controller/NotificationApiControllerTest.java b/api/src/test/java/com/samhap/kokomen/notification/controller/NotificationApiControllerTest.java new file mode 100644 index 0000000..3b5b2a6 --- /dev/null +++ b/api/src/test/java/com/samhap/kokomen/notification/controller/NotificationApiControllerTest.java @@ -0,0 +1,371 @@ +package com.samhap.kokomen.notification.controller; + +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.samhap.kokomen.global.BaseControllerTest; +import com.samhap.kokomen.global.fixture.NotificationFixtureBuilder; +import com.samhap.kokomen.notification.domain.NotificationState; +import com.samhap.kokomen.notification.domain.NotificationType; +import com.samhap.kokomen.notification.domain.payload.AnswerLikePayload; +import com.samhap.kokomen.notification.domain.payload.InterviewLikePayload; +import com.samhap.kokomen.notification.domain.payload.InterviewViewCountPayload; +import com.samhap.kokomen.notification.repository.NotificationRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpSession; + +class NotificationApiControllerTest extends BaseControllerTest { + + @Autowired + private NotificationRepository notificationRepository; + + @Test + void 자신의_안_읽은_알림_조회() throws Exception { + // given + Long memberId = 1L; + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", memberId); + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.READ) + .notificationPayload(new InterviewLikePayload(NotificationType.INTERVIEW_LIKE, 2L, 2L, 10L)) + .build() + ); + + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.UNREAD) + .notificationPayload(new InterviewLikePayload(NotificationType.INTERVIEW_LIKE, 2L, 3L, 11L)) + .build() + ); + + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.UNREAD) + .notificationPayload(new InterviewViewCountPayload(NotificationType.INTERVIEW_VIEW_COUNT, 2L, 100L)) + .build() + ); + + String responseJson = """ + { + "notifications": [ + { + "notification_payload": { + "notification_type": "INTERVIEW_VIEW_COUNT", + "interview_id": 2, + "view_count": 100 + } + }, + { + "notification_payload": { + "notification_type": "INTERVIEW_LIKE", + "interview_id": 2, + "liker_member_id": 3, + "like_count": 11 + } + } + ], + "has_next": false + } + """; + + mockMvc.perform(get("/api/v1/notifications/me/unread") + .param("size", "10") + .param("sort", "id,desc") + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session)) + .andExpect(status().isOk()) + .andExpect(content().json(responseJson)) + .andExpect(jsonPath("$.notifications[0].created_at").exists()) + .andExpect(jsonPath("$.notifications[1].created_at").exists()) + .andDo(document("notification-findMyUnreadNotifications", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + queryParameters( + parameterWithName("size").description("페이지 크기 (기본값: 10)"), + parameterWithName("sort").description("정렬 기준 (기본값: id,desc)") + ), + responseFields( + fieldWithPath("notifications").description("알림 목록"), + fieldWithPath("notifications[].notification_payload").description("알림 페이로드"), + fieldWithPath("notifications[].notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notifications[].notification_payload.*").ignored(), + fieldWithPath("notifications[].created_at").description("알림 생성 시간"), + fieldWithPath("has_next").description("다음 페이지가 있는지 여부") + ) + )); + } + + @Test + void 자신의_읽은_알림_조회() throws Exception { + // given + Long memberId = 1L; + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", memberId); + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.READ) + .notificationPayload(new InterviewLikePayload(NotificationType.INTERVIEW_LIKE, 2L, 2L, 10L)) + .build() + ); + + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.READ) + .notificationPayload(new InterviewLikePayload(NotificationType.INTERVIEW_LIKE, 2L, 3L, 11L)) + .build() + ); + + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.UNREAD) + .notificationPayload(new InterviewViewCountPayload(NotificationType.INTERVIEW_VIEW_COUNT, 2L, 100L)) + .build() + ); + + String responseJson = """ + { + "notifications": [ + { + "notification_payload": { + "notification_type": "INTERVIEW_LIKE", + "interview_id": 2, + "liker_member_id": 3, + "like_count": 11 + } + }, + { + "notification_payload": { + "notification_type": "INTERVIEW_LIKE", + "interview_id": 2, + "liker_member_id": 2, + "like_count": 10 + } + } + ] + } + """; + + mockMvc.perform(get("/api/v1/notifications/me/read") + .param("page", "0") + .param("size", "10") + .param("sort", "id,desc") + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session)) + .andExpect(status().isOk()) + .andExpect(content().json(responseJson)) + .andExpect(jsonPath("$.notifications[0].created_at").exists()) + .andExpect(jsonPath("$.notifications[1].created_at").exists()) + .andDo(document("notification-findMyReadNotifications", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 0)"), + parameterWithName("size").description("페이지 크기 (기본값: 10)"), + parameterWithName("sort").description("정렬 기준 (기본값: id,desc)") + ), + responseFields( + fieldWithPath("notifications").description("알림 목록"), + fieldWithPath("notifications[].notification_payload").description("알림 페이로드"), + fieldWithPath("notifications[].notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notifications[].notification_payload.*").ignored(), + fieldWithPath("notifications[].created_at").description("알림 생성 시간") + ) + )); + } + + @Test + void 자신의_읽은_알림_조회_중_인터뷰_좋아요_알림_조회() throws Exception { + // given + Long memberId = 1L; + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", memberId); + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.READ) + .notificationPayload(new InterviewLikePayload(NotificationType.INTERVIEW_LIKE, 2L, 2L, 10L)) + .build() + ); + + String responseJson = """ + { + "notifications": [ + { + "notification_payload": { + "notification_type": "INTERVIEW_LIKE", + "interview_id": 2, + "liker_member_id": 2, + "like_count": 10 + } + } + ] + } + """; + + mockMvc.perform(get("/api/v1/notifications/me/read") + .param("page", "0") + .param("size", "10") + .param("sort", "id,desc") + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session)) + .andExpect(status().isOk()) + .andExpect(content().json(responseJson)) + .andExpect(jsonPath("$.notifications[0].created_at").exists()) + .andDo(document("notification-findMyReadNotifications-interviewLike", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 0)"), + parameterWithName("size").description("페이지 크기 (기본값: 10)"), + parameterWithName("sort").description("정렬 기준 (기본값: id,desc)") + ), + responseFields( + fieldWithPath("notifications").description("알림 목록"), + fieldWithPath("notifications[].notification_payload").description("알림 페이로드"), + fieldWithPath("notifications[].notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notifications[].notification_payload.interview_id").description("인터뷰 ID"), + fieldWithPath("notifications[].notification_payload.liker_member_id").description("좋아요를 누른 회원 ID"), + fieldWithPath("notifications[].notification_payload.like_count").description("좋아요 수"), + fieldWithPath("notifications[].created_at").description("알림 생성 시간") + ) + )); + } + + @Test + void 자신의_읽은_알림_조회_중_답변_좋아요_알림_조회() throws Exception { + // given + Long memberId = 1L; + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", memberId); + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.READ) + .notificationPayload(new AnswerLikePayload(NotificationType.ANSWER_LIKE, 3L, 2L, 4L, 10L)) + .build() + ); + + String responseJson = """ + { + "notifications": [ + { + "notification_payload": { + "notification_type": "ANSWER_LIKE", + "answer_id": 3, + "interview_id": 2, + "liker_member_id": 4, + "like_count": 10 + } + } + ] + } + """; + + mockMvc.perform(get("/api/v1/notifications/me/read") + .param("page", "0") + .param("size", "10") + .param("sort", "id,desc") + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session)) + .andExpect(status().isOk()) + .andExpect(content().json(responseJson)) + .andExpect(jsonPath("$.notifications[0].created_at").exists()) + .andDo(document("notification-findMyReadNotifications-answerLike", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 0)"), + parameterWithName("size").description("페이지 크기 (기본값: 10)"), + parameterWithName("sort").description("정렬 기준 (기본값: id,desc)") + ), + responseFields( + fieldWithPath("notifications").description("알림 목록"), + fieldWithPath("notifications[].notification_payload").description("알림 페이로드"), + fieldWithPath("notifications[].notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notifications[].notification_payload.answer_id").description("답변 ID"), + fieldWithPath("notifications[].notification_payload.interview_id").description("인터뷰 ID"), + fieldWithPath("notifications[].notification_payload.liker_member_id").description("좋아요를 누른 회원 ID"), + fieldWithPath("notifications[].notification_payload.like_count").description("좋아요 수"), + fieldWithPath("notifications[].created_at").description("알림 생성 시간") + ) + )); + } + + @Test + void 자신의_읽은_알림_조회_중_인터뷰_조회수_알림_조회() throws Exception { + // given + Long memberId = 1L; + MockHttpSession session = new MockHttpSession(); + session.setAttribute("MEMBER_ID", memberId); + notificationRepository.save( + NotificationFixtureBuilder.builder() + .receiverMemberId(memberId) + .notificationState(NotificationState.READ) + .notificationPayload(new InterviewViewCountPayload(NotificationType.INTERVIEW_VIEW_COUNT, 2L, 100L)) + .build() + ); + + String responseJson = """ + { + "notifications": [ + { + "notification_payload": { + "notification_type": "INTERVIEW_VIEW_COUNT", + "interview_id": 2, + "view_count": 100 + } + } + ] + } + """; + + mockMvc.perform(get("/api/v1/notifications/me/read") + .param("page", "0") + .param("size", "10") + .param("sort", "id,desc") + .header("Cookie", "JSESSIONID=" + session.getId()) + .session(session)) + .andExpect(status().isOk()) + .andExpect(content().json(responseJson)) + .andExpect(jsonPath("$.notifications[0].created_at").exists()) + .andDo(document("notification-findMyReadNotifications-interviewViewCount", + requestHeaders( + headerWithName("Cookie").description("로그인 세션을 위한 JSESSIONID 쿠키") + ), + queryParameters( + parameterWithName("page").description("페이지 번호 (기본값: 0)"), + parameterWithName("size").description("페이지 크기 (기본값: 10)"), + parameterWithName("sort").description("정렬 기준 (기본값: id,desc)") + ), + responseFields( + fieldWithPath("notifications").description("알림 목록"), + fieldWithPath("notifications[].notification_payload").description("알림 페이로드"), + fieldWithPath("notifications[].notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notifications[].notification_payload.interview_id").description("인터뷰 ID"), + fieldWithPath("notifications[].notification_payload.view_count").description("조회수"), + fieldWithPath("notifications[].created_at").description("알림 생성 시간") + ) + )); + } +} diff --git a/api/src/test/resources/application.yml b/api/src/test/resources/application.yml new file mode 100644 index 0000000..54c4b1a --- /dev/null +++ b/api/src/test/resources/application.yml @@ -0,0 +1,12 @@ +spring: + profiles: + include: + - domain-test + - common-test + main: + lazy-initialization: true + jackson: + property-naming-strategy: SNAKE_CASE + default-property-inclusion: non_null +cors: + allowed-origins: http://localhost:8080 diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..68af1c7 --- /dev/null +++ b/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'java-library' + id 'org.springframework.boot' version '3.5.3' + id 'io.spring.dependency-management' version '1.1.7' +} + +subprojects { + apply plugin: 'java' + apply plugin: 'java-library' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + + group = 'com.samhap' + version = '0.0.1-SNAPSHOT' + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } + + repositories { + mavenCentral() + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } + + tasks.named('test') { + useJUnitPlatform() + } +} diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..e6d9b47 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,14 @@ +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + implementation 'io.micrometer:micrometer-registry-prometheus' +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} diff --git a/common/run-test-mysql-redis.sh b/common/run-test-mysql-redis.sh new file mode 100755 index 0000000..f75cafc --- /dev/null +++ b/common/run-test-mysql-redis.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +if ! docker ps --format '{{.Names}}' | grep -q '^notification-test-redis$'; then + echo "notification-test-redis 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d notification-test-redis로 시작합니다..." + docker compose -f test.yml up -d notification-test-redis +else + echo "notification-test-redis 컨테이너가 이미 실행 중입니다." +fi + +# Redis Health check 대기 (최대 30초, 1초 간격) +echo "notification-test-redis 컨테이너의 Health check 상태 확인 중..." +for i in {1..30}; do + health_status=$(docker inspect --format='{{.State.Health.Status}}' notification-test-redis 2>/dev/null) + + if [ "$health_status" == "healthy" ]; then + echo "notification-test-redis 컨테이너가 healthy 상태입니다. 빌드를 시작합니다." + break + elif [ "$health_status" == "unhealthy" ]; then + echo "notification-test-redis 컨테이너가 unhealthy 상태입니다. 중단합니다." + exit 1 + else + echo "아직 healthy 아님 ($i초)..." + sleep 1 + fi + + if [ $i -eq 30 ]; then + echo "notification-test-redis 컨테이너가 30초 안에 healthy 상태가 되지 않았습니다. 중단합니다." + exit 1 + fi +done diff --git a/common/run-test-redis.sh b/common/run-test-redis.sh new file mode 100755 index 0000000..f75cafc --- /dev/null +++ b/common/run-test-redis.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +if ! docker ps --format '{{.Names}}' | grep -q '^notification-test-redis$'; then + echo "notification-test-redis 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d notification-test-redis로 시작합니다..." + docker compose -f test.yml up -d notification-test-redis +else + echo "notification-test-redis 컨테이너가 이미 실행 중입니다." +fi + +# Redis Health check 대기 (최대 30초, 1초 간격) +echo "notification-test-redis 컨테이너의 Health check 상태 확인 중..." +for i in {1..30}; do + health_status=$(docker inspect --format='{{.State.Health.Status}}' notification-test-redis 2>/dev/null) + + if [ "$health_status" == "healthy" ]; then + echo "notification-test-redis 컨테이너가 healthy 상태입니다. 빌드를 시작합니다." + break + elif [ "$health_status" == "unhealthy" ]; then + echo "notification-test-redis 컨테이너가 unhealthy 상태입니다. 중단합니다." + exit 1 + else + echo "아직 healthy 아님 ($i초)..." + sleep 1 + fi + + if [ $i -eq 30 ]; then + echo "notification-test-redis 컨테이너가 30초 안에 healthy 상태가 되지 않았습니다. 중단합니다." + exit 1 + fi +done diff --git a/common/src/main/java/com/samhap/kokomen/global/config/RedisSingleNodeConfig.java b/common/src/main/java/com/samhap/kokomen/global/config/RedisSingleNodeConfig.java new file mode 100644 index 0000000..be3db7a --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/config/RedisSingleNodeConfig.java @@ -0,0 +1,44 @@ +package com.samhap.kokomen.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisSingleNodeConfig { + + private final String host; + private final String port; + + public RedisSingleNodeConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") String port + ) { + this.host = host; + this.port = port; + } + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); + redisStandaloneConfiguration.setHostName(host); + redisStandaloneConfiguration.setPort(Integer.parseInt(port)); + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java b/common/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java new file mode 100644 index 0000000..a21e8aa --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class BadRequestException extends KokomenException { + + public BadRequestException(String message) { + super(message, 400); + } +} diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/ForbiddenException.java b/common/src/main/java/com/samhap/kokomen/global/exception/ForbiddenException.java new file mode 100644 index 0000000..be85bd6 --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class ForbiddenException extends KokomenException { + + public ForbiddenException(String message) { + super(message, 403); + } +} diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/KokomenException.java b/common/src/main/java/com/samhap/kokomen/global/exception/KokomenException.java new file mode 100644 index 0000000..a47dc3d --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/KokomenException.java @@ -0,0 +1,19 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public class KokomenException extends RuntimeException { + + private final int httpStatusCode; + + public KokomenException(String message, int httpStatusCode) { + super(message); + this.httpStatusCode = httpStatusCode; + } + + public KokomenException(String message, Throwable cause, int httpStatusCode) { + super(message, cause); + this.httpStatusCode = httpStatusCode; + } +} diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/UnauthorizedException.java b/common/src/main/java/com/samhap/kokomen/global/exception/UnauthorizedException.java new file mode 100644 index 0000000..d84d9fa --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class UnauthorizedException extends KokomenException { + + public UnauthorizedException(String message) { + super(message, 401); + } +} diff --git a/common/src/main/resources/application-common-test.yml b/common/src/main/resources/application-common-test.yml new file mode 100644 index 0000000..2f9e029 --- /dev/null +++ b/common/src/main/resources/application-common-test.yml @@ -0,0 +1,6 @@ +spring: + data: + redis: + host: localhost + port: 16380 + diff --git a/common/src/main/resources/application-common.yml b/common/src/main/resources/application-common.yml new file mode 100644 index 0000000..42e3aa5 --- /dev/null +++ b/common/src/main/resources/application-common.yml @@ -0,0 +1,46 @@ +management: + endpoint: + health: + show-components: always + access: read_only + info: + access: read_only + metrics: + access: read_only + prometheus: + access: read_only + endpoints: + web: + exposure: + include: prometheus, info, health, metrics +--- +# local profile +spring: + config: + activate: + on-profile: local + data: + redis: + host: kokomen-local-redis + port: 6379 + +--- +# dev profile +spring: + config: + activate: + on-profile: dev + data: + redis: + host: kokomen-redis-dev + port: 6379 +--- +# prod profile +spring: + config: + activate: + on-profile: prod + data: + redis: + host: ${REDIS_PRIMARY_HOST_PROD} + port: 6379 diff --git a/common/src/main/resources/logback-spring.xml b/common/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3f75031 --- /dev/null +++ b/common/src/main/resources/logback-spring.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + ./logs/app.log + + ./logs/app.%d{yyyy-MM-dd}.log + 30 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/test.yml b/common/test.yml new file mode 100644 index 0000000..0ef83db --- /dev/null +++ b/common/test.yml @@ -0,0 +1,16 @@ +services: + notification-test-redis: + container_name: notification-test-redis + image: valkey/valkey:8.0.1 + ports: + - 16380:6379 + volumes: + - kokomen-notification-test-redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + kokomen-notification-test-redis-data: diff --git a/docker/dev/docker-compose-dev.yml b/docker/dev/docker-compose-dev.yml new file mode 100644 index 0000000..7ca932b --- /dev/null +++ b/docker/dev/docker-compose-dev.yml @@ -0,0 +1,107 @@ +services: + # ✅ Notification Internal Spring Application + kokomen-notification-dev-internal: + image: samhap/kokomen-notification-internal:dev + container_name: kokomen-notification-dev-internal + restart: on-failure:3 + expose: + - 8080 + - 8000 + volumes: + - ./notification/internal/app/logs:/logs + environment: + TZ: Asia/Seoul + JAVA_TOOL_OPTIONS: -Duser.timezone=Asia/Seoul + HOSTNAME: ${HOSTNAME} + SPRING_PROFILES_ACTIVE: dev + SPRING_DATASOURCE_USERNAME_DEV: ${SPRING_DATASOURCE_USERNAME_DEV} + SPRING_DATASOURCE_PASSWORD_DEV: ${SPRING_DATASOURCE_PASSWORD_DEV} + networks: + - dev-kokomen-net + + kokomen-notification-dev-api: + image: samhap/kokomen-notification-api:dev + container_name: kokomen-notification-dev-api + restart: on-failure:3 + expose: + - 8080 + - 8001 + volumes: + - ./notification/api/app/logs:/logs + environment: + TZ: Asia/Seoul + JAVA_TOOL_OPTIONS: -Duser.timezone=Asia/Seoul + HOSTNAME: ${HOSTNAME} + SPRING_PROFILES_ACTIVE: dev + SPRING_DATASOURCE_USERNAME_DEV: ${SPRING_DATASOURCE_USERNAME_DEV} + SPRING_DATASOURCE_PASSWORD_DEV: ${SPRING_DATASOURCE_PASSWORD_DEV} + networks: + - dev-kokomen-net + + kokomen-notification-mysql-dev: + image: mysql:8.4.5 + container_name: kokomen-notification-mysql-dev + command: + [ + "mysqld", + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci" + ] + volumes: + - notification-mysql-data:/var/lib/mysql + - notification-mysql-init:/docker-entrypoint-initdb.d + expose: + - 3306 + restart: always + environment: + TZ: Asia/Seoul + MYSQL_USER: ${SPRING_DATASOURCE_USERNAME_DEV} + MYSQL_PASSWORD: ${SPRING_DATASOURCE_PASSWORD_DEV} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD_DEV} + MYSQL_DATABASE: kokomen-notification-dev + LANG: C.UTF-8 + MYSQL_INIT_CONNECT: "SET NAMES utf8mb4" + networks: + - dev-kokomen-net + + notification-mysql-dev-exporter: + image: prom/mysqld-exporter + container_name: notification-mysql-dev-exporter + restart: unless-stopped + environment: + DATA_SOURCE_NAME: "root:${MYSQL_ROOT_PASSWORD_DEV}@tcp(kokomen-notification-mysql-dev:3306)/" + TZ: Asia/Seoul + expose: + - 9104 + volumes: + - ./my.cnf:/.my.cnf + networks: + - dev-kokomen-net + + promtail-notification-dev: + image: grafana/promtail + container_name: promtail-notification-dev + volumes: + - ./promtail/promtail.yaml:/etc/promtail/promtail.yaml + - ./notification/internal/app/logs:/logs/internal + - ./notification/api/app/logs:/logs/api + - promtail-notification-tmp:/tmp + command: + - "-config.file=/etc/promtail/promtail.yaml" + - "-config.expand-env=true" + restart: unless-stopped + environment: + TZ: Asia/Seoul + HOSTNAME: ${HOSTNAME} + networks: + - dev-kokomen-net + +volumes: + notification-mysql-data: + notification-mysql-init: + promtail-notification-tmp: + +networks: + dev-kokomen-net: + external: true + driver: bridge diff --git a/docker/dev/my.cnf b/docker/dev/my.cnf new file mode 100644 index 0000000..8672866 --- /dev/null +++ b/docker/dev/my.cnf @@ -0,0 +1,4 @@ +[client] +user=root +password=root +host=kokomen-notification-mysql-dev diff --git a/docker/dev/promtail/promtail.yaml b/docker/dev/promtail/promtail.yaml new file mode 100644 index 0000000..1703787 --- /dev/null +++ b/docker/dev/promtail/promtail.yaml @@ -0,0 +1,34 @@ +server: + http_listen_port: 9080 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: kokomen-notification-api-dev + static_configs: + - labels: + job: kokomen-notification-api + app: kokomen-notification-api + host: ${HOSTNAME} + __path__: /logs/api/app.log + pipeline_stages: + - regex: + expression: '\[.*?\] [\d\-:.\s]+ [^\[\]]+ \[.*?\]\s+(?P[A-Z]+)\s+[^\s]+' + - labels: + level: + - job_name: kokomen-notification-internal-dev + static_configs: + - labels: + job: kokomen-notification-internal + app: kokomen-notification-internal + host: ${HOSTNAME} + __path__: /logs/internal/app.log + pipeline_stages: + - regex: + expression: '\[.*?\] [\d\-:.\s]+ [^\[\]]+ \[.*?\]\s+(?P[A-Z]+)\s+[^\s]+' + - labels: + level: diff --git a/docker/prod/docker-compose-prod.yml b/docker/prod/docker-compose-prod.yml new file mode 100644 index 0000000..73dd388 --- /dev/null +++ b/docker/prod/docker-compose-prod.yml @@ -0,0 +1,86 @@ +services: + kokomen-notification-api: + image: samhap/kokomen-notification-api:prod + container_name: kokomen-notification-api + restart: on-failure:3 + expose: + - 8080 + ports: + - "8001:8001" + volumes: + - ./notification/api/logs:/logs + environment: + TZ: Asia/Seoul + JAVA_TOOL_OPTIONS: -Duser.timezone=Asia/Seoul + HOSTNAME: ${HOSTNAME} + SPRING_PROFILES_ACTIVE: prod + REDIS_PRIMARY_HOST_PROD: ${REDIS_PRIMARY_HOST_PROD} + NOTIFICATION_DATASOURCE_URL_PROD: ${NOTIFICATION_DATASOURCE_URL_PROD} + NOTIFICATION_DATASOURCE_USERNAME_PROD: ${NOTIFICATION_DATASOURCE_USERNAME_PROD} + NOTIFICATION_DATASOURCE_PASSWORD_PROD: ${NOTIFICATION_DATASOURCE_PASSWORD_PROD} + + kokomen-notification-internal: + image: samhap/kokomen-notification-internal:prod + container_name: kokomen-notification-internal + restart: on-failure:3 + expose: + - 8080 + ports: + - "8000:8000" + volumes: + - ./notification/internal/logs:/logs + environment: + TZ: Asia/Seoul + JAVA_TOOL_OPTIONS: -Duser.timezone=Asia/Seoul + HOSTNAME: ${HOSTNAME} + SPRING_PROFILES_ACTIVE: prod + REDIS_PRIMARY_HOST_PROD: ${REDIS_PRIMARY_HOST_PROD} + NOTIFICATION_DATASOURCE_URL_PROD: ${NOTIFICATION_DATASOURCE_URL_PROD} + NOTIFICATION_DATASOURCE_USERNAME_PROD: ${NOTIFICATION_DATASOURCE_USERNAME_PROD} + NOTIFICATION_DATASOURCE_PASSWORD_PROD: ${NOTIFICATION_DATASOURCE_PASSWORD_PROD} + + nginx: + image: nginx:1.28.0 + container_name: nginx + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/logs/api:/var/log/nginx/api + - ./nginx/logs/internal:/var/log/nginx/internal + restart: unless-stopped + environment: + TZ: Asia/Seoul + + node: + image: prom/node-exporter + container_name: node + restart: unless-stopped + pid: host + ports: + - 9100:9100 + command: + - '--path.rootfs=/host' + volumes: + - '/:/host:ro' + environment: + TZ: Asia/Seoul + + promtail: + image: grafana/promtail + container_name: promtail + volumes: + - ./promtail/promtail.yaml:/etc/promtail/promtail.yaml + - ./notification/api/logs:/logs/api + - ./notification/internal/logs:/logs/internal + - promtail-tmp:/tmp + command: + - "-config.file=/etc/promtail/promtail.yaml" + - "-config.expand-env=true" + restart: unless-stopped + environment: + TZ: Asia/Seoul + HOSTNAME: ${HOSTNAME} + +volumes: + promtail-tmp: diff --git a/docker/prod/nginx/nginx.conf b/docker/prod/nginx/nginx.conf new file mode 100644 index 0000000..e51b6b5 --- /dev/null +++ b/docker/prod/nginx/nginx.conf @@ -0,0 +1,66 @@ +events {} + +http { + charset utf-8; + + server_names_hash_bucket_size 128; # 서버 네임 길이 짧아서 안된다고함 + + log_format api_log '$request_id $remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" "$request_time" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + '"$ssl_protocol/$ssl_cipher" "$content_length" "$request_length"'; + + log_format internal_log '$http_x_requestid $remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" "$request_time" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + '"$ssl_protocol/$ssl_cipher" "$content_length" "$request_length"'; + + server { + listen 80 default_server; + server_name _; + + return 404; + } + + server { + listen 80; + access_log /var/log/nginx/api/access.log api_log; + + set_real_ip_from 10.0.0.0/16; + set_real_ip_from 43.203.50.14; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + server_name notification-api.kokomen.kr; + server_tokens off; + + location / { + set $backend "kokomen-notification-api:8080"; + proxy_pass http://$backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-RequestID $request_id; + resolver 127.0.0.11 valid=5s; + } + } + + server { + listen 80; + access_log /var/log/nginx/internal/access.log internal_log; + + set_real_ip_from 10.0.0.0/16; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + server_name internal-notification-private-alb-prod-190218547.ap-northeast-2.elb.amazonaws.com; + server_tokens off; + + location / { + set $backend "kokomen-notification-internal:8080"; + proxy_pass http://$backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + resolver 127.0.0.11 valid=5s; + } + } +} diff --git a/docker/prod/promtail/promtail.yaml b/docker/prod/promtail/promtail.yaml new file mode 100644 index 0000000..622655b --- /dev/null +++ b/docker/prod/promtail/promtail.yaml @@ -0,0 +1,34 @@ +server: + http_listen_port: 9080 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://10.0.37.94:3100/loki/api/v1/push + +scrape_configs: + - job_name: kokomen-notification-api + static_configs: + - labels: + job: kokomen-notification-api + app: kokomen-notification-api + host: ${HOSTNAME} + __path__: /logs/api/app.log + pipeline_stages: + - regex: + expression: '\[.*?\] [\d\-:.\s]+ [^\[\]]+ \[.*?\]\s+(?P[A-Z]+)\s+[^\s]+' + - labels: + level: + - job_name: kokomen-notification-internal + static_configs: + - labels: + job: kokomen-notification-internal + app: kokomen-notification-internal + host: ${HOSTNAME} + __path__: /logs/internal/app.log + pipeline_stages: + - regex: + expression: '\[.*?\] [\d\-:.\s]+ [^\[\]]+ \[.*?\]\s+(?P[A-Z]+)\s+[^\s]+' + - labels: + level: diff --git a/domain/build.gradle b/domain/build.gradle index 92719a5..6d18cd0 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -1,37 +1,15 @@ -plugins { - id 'java' - id 'org.springframework.boot' version '3.5.3' - id 'io.spring.dependency-management' version '1.1.7' -} - -group = 'com.samhap' -version = '0.0.1-SNAPSHOT' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.flywaydb:flyway-core:11.9.1' + implementation 'org.flywaydb:flyway-mysql:11.9.1' -repositories { - mavenCentral() + runtimeOnly 'com.mysql:mysql-connector-j' } -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'com.h2database:h2' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +bootJar { + enabled = false } -tasks.named('test') { - useJUnitPlatform() +jar { + enabled = true } diff --git a/domain/local-docker-compose.yml b/domain/local-docker-compose.yml new file mode 100644 index 0000000..3ff68fc --- /dev/null +++ b/domain/local-docker-compose.yml @@ -0,0 +1,36 @@ +volumes: + kokomen-notification-local-mysql-data: +# kokomen-notification-local-redis-data: + +services: + # ✅ MySQL + kokomen-notification-local-mysql: + image: mysql:8.4.5 + container_name: kokomen-notification-local-mysql + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: kokomen-notification-local + TZ: Asia/Seoul + LANG: C.UTF-8 + MYSQL_INIT_CONNECT: "SET NAMES utf8mb4" + command: + [ + "mysqld", + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci" + ] + expose: + - 3306 + volumes: + - kokomen-notification-local-mysql-data:/var/lib/mysql + healthcheck: + test: [ "CMD", "mysql", "-u", "root", "-proot", "-e", "SELECT 1" ] + interval: 5s + retries: 10 + networks: + - local-kokomen-net + +networks: + local-kokomen-net: + external: true + driver: bridge diff --git a/domain/run-test-mysql-redis.sh b/domain/run-test-mysql-redis.sh new file mode 100755 index 0000000..fc79f86 --- /dev/null +++ b/domain/run-test-mysql-redis.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +if ! docker ps --format '{{.Names}}' | grep -q '^notification-test-mysql$'; then + echo "notification-test-mysql 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d notification-test-mysql로 시작합니다..." + docker compose -f test.yml up -d notification-test-mysql +else + echo "notification-test-mysql 컨테이너가 이미 실행 중입니다." +fi + +#if ! docker ps --format '{{.Names}}' | grep -q '^notification-test-redis$'; then +# echo "notification-test-redis 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d notification-test-redis로 시작합니다..." +# docker compose -f test.yml up -d notification-test-redis +#else +# echo "notification-test-redis 컨테이너가 이미 실행 중입니다." +#fi + +# MySQL Health check 대기 (최대 30초, 1초 간격) +echo "notification-test-mysql 컨테이너의 Health check 상태 확인 중..." +for i in {1..30}; do + health_status=$(docker inspect --format='{{.State.Health.Status}}' notification-test-mysql 2>/dev/null) + + if [ "$health_status" == "healthy" ]; then + echo "notification-test-mysql 컨테이너가 healthy 상태입니다." + break + elif [ "$health_status" == "unhealthy" ]; then + echo "notification-test-mysql 컨테이너가 unhealthy 상태입니다. 중단합니다." + exit 1 + else + echo "아직 healthy 아님 ($i초)..." + sleep 1 + fi + + if [ $i -eq 30 ]; then + echo "notification-test-mysql 컨테이너가 30초 안에 healthy 상태가 되지 않았습니다. 중단합니다." + exit 1 + fi +done + +## Redis Health check 대기 (최대 30초, 1초 간격) +#echo "notification-test-redis 컨테이너의 Health check 상태 확인 중..." +#for i in {1..30}; do +# health_status=$(docker inspect --format='{{.State.Health.Status}}' notification-test-redis 2>/dev/null) +# +# if [ "$health_status" == "healthy" ]; then +# echo "notification-test-redis 컨테이너가 healthy 상태입니다. 빌드를 시작합니다." +# break +# elif [ "$health_status" == "unhealthy" ]; then +# echo "notification-test-redis 컨테이너가 unhealthy 상태입니다. 중단합니다." +# exit 1 +# else +# echo "아직 healthy 아님 ($i초)..." +# sleep 1 +# fi +# +# if [ $i -eq 30 ]; then +# echo "notification-test-redis 컨테이너가 30초 안에 healthy 상태가 되지 않았습니다. 중단합니다." +# exit 1 +# fi +#done diff --git a/domain/run-test-mysql.sh b/domain/run-test-mysql.sh new file mode 100755 index 0000000..432d218 --- /dev/null +++ b/domain/run-test-mysql.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +if ! docker ps --format '{{.Names}}' | grep -q '^notification-test-mysql$'; then + echo "notification-test-mysql 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d notification-test-mysql로 시작합니다..." + docker compose -f test.yml up -d notification-test-mysql +else + echo "notification-test-mysql 컨테이너가 이미 실행 중입니다." +fi + +# MySQL Health check 대기 (최대 30초, 1초 간격) +echo "notification-test-mysql 컨테이너의 Health check 상태 확인 중..." +for i in {1..30}; do + health_status=$(docker inspect --format='{{.State.Health.Status}}' notification-test-mysql 2>/dev/null) + + if [ "$health_status" == "healthy" ]; then + echo "notification-test-mysql 컨테이너가 healthy 상태입니다." + break + elif [ "$health_status" == "unhealthy" ]; then + echo "notification-test-mysql 컨테이너가 unhealthy 상태입니다. 중단합니다." + exit 1 + else + echo "아직 healthy 아님 ($i초)..." + sleep 1 + fi + + if [ $i -eq 30 ]; then + echo "notification-test-mysql 컨테이너가 30초 안에 healthy 상태가 되지 않았습니다. 중단합니다." + exit 1 + fi +done diff --git a/domain/src/main/java/com/samhap/kokomen/global/domain/BaseEntity.java b/domain/src/main/java/com/samhap/kokomen/global/domain/BaseEntity.java new file mode 100644 index 0000000..0bb2cae --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/global/domain/BaseEntity.java @@ -0,0 +1,19 @@ +package com.samhap.kokomen.global.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false, nullable = false) + private LocalDateTime createdAt; +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/domain/Notification.java b/domain/src/main/java/com/samhap/kokomen/notification/domain/Notification.java new file mode 100644 index 0000000..7f88ae7 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/domain/Notification.java @@ -0,0 +1,50 @@ +package com.samhap.kokomen.notification.domain; + +import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.notification.domain.payload.NotificationPayload; +import com.samhap.kokomen.notification.infrastructure.NotificationPayloadConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Notification extends BaseEntity { + + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + @Column(name = "receiver_member_id", nullable = false) + private Long receiverMemberId; + + @Convert(converter = NotificationPayloadConverter.class) + @Column(columnDefinition = "json", name = "notification_payload", nullable = false, length = 1000) + private NotificationPayload notificationPayload; + + @Column(name = "notification_state", nullable = false) + @Enumerated(value = EnumType.STRING) + private NotificationState notificationState; + + public Notification(Long receiverMemberId, NotificationPayload notificationPayload) { + this.receiverMemberId = receiverMemberId; + this.notificationPayload = notificationPayload; + this.notificationState = NotificationState.UNREAD; + } + + public void markAsRead() { + this.notificationState = NotificationState.READ; + } +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/domain/NotificationState.java b/domain/src/main/java/com/samhap/kokomen/notification/domain/NotificationState.java new file mode 100644 index 0000000..2befbd1 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/domain/NotificationState.java @@ -0,0 +1,7 @@ +package com.samhap.kokomen.notification.domain; + +public enum NotificationState { + UNREAD, + READ, + ; +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/domain/NotificationType.java b/domain/src/main/java/com/samhap/kokomen/notification/domain/NotificationType.java new file mode 100644 index 0000000..6346da1 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/domain/NotificationType.java @@ -0,0 +1,26 @@ +package com.samhap.kokomen.notification.domain; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.samhap.kokomen.notification.domain.payload.AnswerLikePayload; +import com.samhap.kokomen.notification.domain.payload.InterviewLikePayload; +import com.samhap.kokomen.notification.domain.payload.InterviewViewCountPayload; +import com.samhap.kokomen.notification.domain.payload.NotificationPayload; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum NotificationType { + INTERVIEW_LIKE(InterviewLikePayload.class), + ANSWER_LIKE(AnswerLikePayload.class), + INTERVIEW_VIEW_COUNT(InterviewViewCountPayload.class), + ; + + private final Class clazz; + + public NotificationPayload toNotificationPayload(ObjectMapper objectMapper, String notificationPayload) { + try { + return objectMapper.readValue(notificationPayload, clazz); + } catch (Exception e) { + throw new IllegalArgumentException("알림 메시지(notification_payload)가 올바르지 않습니다.", e); + } + } +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/AnswerLikePayload.java b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/AnswerLikePayload.java new file mode 100644 index 0000000..a65a39a --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/AnswerLikePayload.java @@ -0,0 +1,12 @@ +package com.samhap.kokomen.notification.domain.payload; + +import com.samhap.kokomen.notification.domain.NotificationType; + +public record AnswerLikePayload( + NotificationType notificationType, + Long answerId, + Long interviewId, + Long likerMemberId, + Long likeCount +) implements NotificationPayload { +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/InterviewLikePayload.java b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/InterviewLikePayload.java new file mode 100644 index 0000000..dcfa586 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/InterviewLikePayload.java @@ -0,0 +1,11 @@ +package com.samhap.kokomen.notification.domain.payload; + +import com.samhap.kokomen.notification.domain.NotificationType; + +public record InterviewLikePayload( + NotificationType notificationType, + Long interviewId, + Long likerMemberId, + Long likeCount +) implements NotificationPayload { +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/InterviewViewCountPayload.java b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/InterviewViewCountPayload.java new file mode 100644 index 0000000..33dc0a2 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/InterviewViewCountPayload.java @@ -0,0 +1,10 @@ +package com.samhap.kokomen.notification.domain.payload; + +import com.samhap.kokomen.notification.domain.NotificationType; + +public record InterviewViewCountPayload( + NotificationType notificationType, + Long interviewId, + Long viewCount +) implements NotificationPayload { +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/NotificationPayload.java b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/NotificationPayload.java new file mode 100644 index 0000000..0beccf8 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/domain/payload/NotificationPayload.java @@ -0,0 +1,4 @@ +package com.samhap.kokomen.notification.domain.payload; + +public interface NotificationPayload { +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/infrastructure/NotificationPayloadConverter.java b/domain/src/main/java/com/samhap/kokomen/notification/infrastructure/NotificationPayloadConverter.java new file mode 100644 index 0000000..da622a9 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/infrastructure/NotificationPayloadConverter.java @@ -0,0 +1,37 @@ +package com.samhap.kokomen.notification.infrastructure; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.samhap.kokomen.notification.domain.NotificationType; +import com.samhap.kokomen.notification.domain.payload.NotificationPayload; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.io.IOException; + +@Converter +public class NotificationPayloadConverter implements AttributeConverter { + + private final ObjectMapper objectMapper = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + + @Override + public String convertToDatabaseColumn(NotificationPayload notificationPayload) { + try { + return objectMapper.writeValueAsString(notificationPayload); + } catch (JsonProcessingException e) { + throw new IllegalStateException("notificationPayload를 JSON으로 변환하는데 실패하였습니다.", e); + } + } + + @Override + public NotificationPayload convertToEntityAttribute(String notificationPayloadColumn) { + try { + JsonNode node = objectMapper.readTree(notificationPayloadColumn); + NotificationType notificationType = NotificationType.valueOf(node.get("notification_type").asText()); + return notificationType.toNotificationPayload(objectMapper, node.toString()); + } catch (IOException e) { + throw new IllegalStateException("DB의 notification_payload를 객체로 변환하는데 실패하였습니다.", e); + } + } +} diff --git a/domain/src/main/java/com/samhap/kokomen/notification/repository/NotificationRepository.java b/domain/src/main/java/com/samhap/kokomen/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..a84b670 --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/notification/repository/NotificationRepository.java @@ -0,0 +1,12 @@ +package com.samhap.kokomen.notification.repository; + +import com.samhap.kokomen.notification.domain.Notification; +import com.samhap.kokomen.notification.domain.NotificationState; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { + + List findByReceiverMemberIdAndNotificationState(Long receiverMemberId, NotificationState notificationState, Pageable pageable); +} diff --git a/domain/src/main/resources/application-domain-test.yml b/domain/src/main/resources/application-domain-test.yml new file mode 100644 index 0000000..da14f3e --- /dev/null +++ b/domain/src/main/resources/application-domain-test.yml @@ -0,0 +1,12 @@ +spring: + datasource: + url: jdbc:mysql://localhost:13307/kokomen-notification-test?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: root + jpa: + hibernate: + ddl-auto: none + show-sql: true + database-platform: org.hibernate.dialect.MySQL8Dialect + diff --git a/domain/src/main/resources/application-domain.yml b/domain/src/main/resources/application-domain.yml new file mode 100644 index 0000000..d70c17d --- /dev/null +++ b/domain/src/main/resources/application-domain.yml @@ -0,0 +1,60 @@ +spring: + jpa: + open-in-view: false + + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + validate-on-migrate: true + out-of-order: false + clean-disabled: true +--- +# local profile +spring: + config: + activate: + on-profile: local + datasource: + url: jdbc:mysql://kokomen-notification-local-mysql:3306/kokomen-notification-local + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + database: mysql + show-sql: true + hibernate: + ddl-auto: validate + +--- +# dev profile +spring: + config: + activate: + on-profile: dev + datasource: + url: jdbc:mysql://kokomen-notification-mysql-dev:3306/kokomen-notification-dev + username: ${SPRING_DATASOURCE_USERNAME_DEV} + password: ${SPRING_DATASOURCE_PASSWORD_DEV} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + database: mysql + show-sql: false + hibernate: + ddl-auto: validate +--- +# prod profile +spring: + config: + activate: + on-profile: prod + datasource: + url: ${NOTIFICATION_DATASOURCE_URL_PROD} + username: ${NOTIFICATION_DATASOURCE_USERNAME_PROD} + password: ${NOTIFICATION_DATASOURCE_PASSWORD_PROD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + database: mysql + show-sql: false + hibernate: + ddl-auto: validate diff --git a/domain/src/main/resources/application.yml b/domain/src/main/resources/application.yml deleted file mode 100644 index ee17436..0000000 --- a/domain/src/main/resources/application.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring: - datasource: - url: jdbc:h2:mem:testdb - driver-class-name: org.h2.Driver - username: sa - password: - jpa: - hibernate: - ddl-auto: create-drop - show-sql: true - database-platform: org.hibernate.dialect.H2Dialect diff --git a/domain/src/main/resources/db/migration/V1__create_init_database.sql b/domain/src/main/resources/db/migration/V1__create_init_database.sql new file mode 100644 index 0000000..8336bdf --- /dev/null +++ b/domain/src/main/resources/db/migration/V1__create_init_database.sql @@ -0,0 +1,8 @@ +CREATE TABLE notification +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + receiver_member_id BIGINT NOT NULL, + content VARCHAR(1000) NOT NULL, + notification_state ENUM('READ', 'UNREAD') NOT NULL, + created_at TIMESTAMP NOT NULL +); diff --git a/domain/src/main/resources/db/migration/V2__add_notification_payload_to_notification.sql b/domain/src/main/resources/db/migration/V2__add_notification_payload_to_notification.sql new file mode 100644 index 0000000..f219c1d --- /dev/null +++ b/domain/src/main/resources/db/migration/V2__add_notification_payload_to_notification.sql @@ -0,0 +1,7 @@ +-- notification_payload 컬럼 JSON 타입으로 추가 (NOT NULL) +ALTER TABLE notification + ADD COLUMN notification_payload JSON NOT NULL AFTER receiver_member_id; + +-- content 컬럼 제거 +ALTER TABLE notification +DROP COLUMN content; diff --git a/domain/test.yml b/domain/test.yml new file mode 100644 index 0000000..ba7327e --- /dev/null +++ b/domain/test.yml @@ -0,0 +1,39 @@ +services: + notification-test-mysql: + container_name: notification-test-mysql + image: mysql:8.4.5 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: kokomen-notification-test + TZ: Asia/Seoul + LANG: C.UTF-8 + MYSQL_INIT_CONNECT: "SET NAMES utf8mb4" + command: + [ + "mysqld", + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_general_ci" + ] + ports: + - 13307:3306 + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + interval: 5s + timeout: 3s + retries: 5 + +# notification-test-redis: +# container_name: notification-test-redis +# image: valkey/valkey:8.0.1 +# ports: +# - 16380:6379 +# volumes: +# - kokomen-notification-test-redis-data:/data +# healthcheck: +# test: [ "CMD", "redis-cli", "ping" ] +# interval: 5s +# timeout: 3s +# retries: 5 +# +#volumes: +# kokomen-notification-test-redis-data: diff --git a/internal/Dockerfile b/internal/Dockerfile new file mode 100644 index 0000000..2ba0963 --- /dev/null +++ b/internal/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:17-jdk + +COPY build/libs/*SNAPSHOT.jar /app.jar + +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/internal/build.gradle b/internal/build.gradle index fe58379..376861f 100644 --- a/internal/build.gradle +++ b/internal/build.gradle @@ -1,51 +1,37 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.3' - id 'io.spring.dependency-management' version '1.1.7' - id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } -group = 'com.samhap' -version = '0.0.1-SNAPSHOT' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } +ext { + set('snippetsDir', file("build/generated-snippets")) } -configurations { - compileOnly { - extendsFrom annotationProcessor - } -} +dependencies { + implementation project(':domain') + implementation project(':common') + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' -repositories { - mavenCentral() -} + runtimeOnly 'com.mysql:mysql-connector-j' -ext { - set('snippetsDir', file("build/generated-snippets")) + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' } -dependencies { - implementation project(':domain') - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +tasks.named('test') { + useJUnitPlatform() + outputs.dir snippetsDir } -tasks.named('test') { - outputs.dir snippetsDir - useJUnitPlatform() +asciidoctor { + attributes 'snippetsDir': snippetsDir + inputs.dir snippetsDir + dependsOn test } -tasks.named('asciidoctor') { - inputs.dir snippetsDir - dependsOn test +bootJar { + dependsOn asciidoctor + from("${asciidoctor.outputDir}") { + into 'static/docs' + } } diff --git a/internal/local-internal-docker-compose.yml b/internal/local-internal-docker-compose.yml new file mode 100644 index 0000000..295b201 --- /dev/null +++ b/internal/local-internal-docker-compose.yml @@ -0,0 +1,19 @@ +services: + # ✅ Notification Internal Spring Application + kokomen-notification-local-internal: + container_name: kokomen-notification-local-internal + build: + dockerfile: ./Dockerfile + no_cache: true + ports: + - 8090:8080 + - 8000:8000 + environment: + SPRING_PROFILES_ACTIVE: local + networks: + - local-kokomen-net + +networks: + local-kokomen-net: + external: true + driver: bridge diff --git a/internal/run-local-internal.sh b/internal/run-local-internal.sh new file mode 100755 index 0000000..380d654 --- /dev/null +++ b/internal/run-local-internal.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -e + +# 현재 쉘 스크립트 파일의 디렉토리로 이동 +cd "$(dirname "$0")" + +cd ../domain + +chmod +x run-test-mysql.sh +./run-test-mysql.sh + +# 도커 네트워크 생성 +NETWORK_NAME="local-kokomen-net" + +if ! docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then + echo "Creating Docker network: $NETWORK_NAME" + docker network create --driver bridge "$NETWORK_NAME" +else + echo "Docker network '$NETWORK_NAME' already exists. Skipping." +fi + +# 도메인 도커 컴포즈 실행 +docker compose -f ../domain/local-docker-compose.yml up -d + +../gradlew clean :internal:build + +# 로컬 도커 컴포즈 실행 +# 실행 전에 헬스 체크 필요 +cd ../internal +docker rm -f kokomen-notification-local-internal +docker compose -f local-internal-docker-compose.yml up --build -d diff --git a/internal/src/docs/asciidoc/index.adoc b/internal/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..4570e89 --- /dev/null +++ b/internal/src/docs/asciidoc/index.adoc @@ -0,0 +1,32 @@ += Kokomen API Guide +:doctype: book +:icons: font +:toc: left +:toc-title: Table of Contents +:toclevels: 2 +:source-highlighter: highlightjs +:sectlinks: +:sectnums: + +== 알림 + +=== 인터뷰 좋아요 알림 생성 + +include::{snippetsDir}/notification-saveNotification-likeInterview/http-request.adoc[] +include::{snippetsDir}/notification-saveNotification-likeInterview/http-response.adoc[] +include::{snippetsDir}/notification-saveNotification-likeInterview/request-fields.adoc[] +include::{snippetsDir}/notification-saveNotification-likeInterview/curl-request.adoc[] + +=== 인터뷰 답변 좋아요 알림 생성 + +include::{snippetsDir}/notification-saveNotification-likeAnswer/http-request.adoc[] +include::{snippetsDir}/notification-saveNotification-likeAnswer/http-response.adoc[] +include::{snippetsDir}/notification-saveNotification-likeAnswer/request-fields.adoc[] +include::{snippetsDir}/notification-saveNotification-likeAnswer/curl-request.adoc[] + +=== 인터뷰 조회수 알림 생성 + +include::{snippetsDir}/notification-saveNotification-interviewViewCount/http-request.adoc[] +include::{snippetsDir}/notification-saveNotification-interviewViewCount/http-response.adoc[] +include::{snippetsDir}/notification-saveNotification-interviewViewCount/request-fields.adoc[] +include::{snippetsDir}/notification-saveNotification-interviewViewCount/curl-request.adoc[] diff --git a/internal/src/main/java/com/samhap/kokomen/KokomenApplication.java b/internal/src/main/java/com/samhap/kokomen/KokomenApplication.java deleted file mode 100644 index 7b02c8a..0000000 --- a/internal/src/main/java/com/samhap/kokomen/KokomenApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.samhap.kokomen; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class KokomenApplication { - - public static void main(String[] args) { - SpringApplication.run(KokomenApplication.class, args); - } - -} diff --git a/internal/src/main/java/com/samhap/kokomen/KokomenNotificationInternalApplication.java b/internal/src/main/java/com/samhap/kokomen/KokomenNotificationInternalApplication.java new file mode 100644 index 0000000..12333e7 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/KokomenNotificationInternalApplication.java @@ -0,0 +1,15 @@ +package com.samhap.kokomen; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class KokomenNotificationInternalApplication { + + public static void main(String[] args) { + SpringApplication.run(KokomenNotificationInternalApplication.class, args); + } + +} diff --git a/internal/src/main/java/com/samhap/kokomen/global/logging/LoggingFilter.java b/internal/src/main/java/com/samhap/kokomen/global/logging/LoggingFilter.java new file mode 100644 index 0000000..2b4e7af --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/logging/LoggingFilter.java @@ -0,0 +1,65 @@ +package com.samhap.kokomen.global.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StopWatch; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +public class LoggingFilter extends OncePerRequestFilter { + + private static final List WHITE_LIST = List.of( + "/favicon.ico", + "/docs/index.html", + "/metrics", + "/actuator/**"); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + String requestId = readRequestId(request); + MDC.put("requestId", requestId); + + try { + filterChain.doFilter(request, response); + } finally { + stopWatch.stop(); + log.info("{} {} ({}) - {}ms", + request.getMethod(), + request.getRequestURI(), + HttpStatus.valueOf(response.getStatus()), + stopWatch.getTotalTimeMillis()); + + MDC.clear(); + } + } + + private String readRequestId(HttpServletRequest request) { + String requestId = request.getHeader("X-RequestID"); + if (requestId != null && !requestId.isEmpty()) { + return requestId; + } + return UUID.randomUUID().toString(); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String requestURI = request.getRequestURI(); + AntPathMatcher antPathMatcher = new AntPathMatcher(); + return WHITE_LIST.stream().anyMatch(path -> antPathMatcher.match(path, requestURI)); + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/notification/controller/NotificationInternalController.java b/internal/src/main/java/com/samhap/kokomen/notification/controller/NotificationInternalController.java new file mode 100644 index 0000000..7caea12 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/notification/controller/NotificationInternalController.java @@ -0,0 +1,24 @@ +package com.samhap.kokomen.notification.controller; + +import com.samhap.kokomen.notification.service.NotificationInternalService; +import com.samhap.kokomen.notification.service.dto.NotificationRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/internal/v1/notifications") +@RestController +public class NotificationInternalController { + + private final NotificationInternalService notificationInternalService; + + @PostMapping + public ResponseEntity saveNotification(@RequestBody NotificationRequest notificationRequest) { + notificationInternalService.saveNotification(notificationRequest); + return ResponseEntity.noContent().build(); + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/notification/infrastructure/NotificationPayloadDeserializer.java b/internal/src/main/java/com/samhap/kokomen/notification/infrastructure/NotificationPayloadDeserializer.java new file mode 100644 index 0000000..557e1c7 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/notification/infrastructure/NotificationPayloadDeserializer.java @@ -0,0 +1,22 @@ +package com.samhap.kokomen.notification.infrastructure; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.samhap.kokomen.notification.domain.NotificationType; +import com.samhap.kokomen.notification.domain.payload.NotificationPayload; +import java.io.IOException; + +public class NotificationPayloadDeserializer extends JsonDeserializer { + + @Override + public NotificationPayload deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + ObjectMapper objectMapper = (ObjectMapper) p.getCodec(); + ObjectNode node = p.readValueAsTree(); + + NotificationType notificationType = NotificationType.valueOf(node.get("notification_type").asText()); + return notificationType.toNotificationPayload(objectMapper, node.toString()); + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/notification/service/NotificationInternalService.java b/internal/src/main/java/com/samhap/kokomen/notification/service/NotificationInternalService.java new file mode 100644 index 0000000..c478bc1 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/notification/service/NotificationInternalService.java @@ -0,0 +1,17 @@ +package com.samhap.kokomen.notification.service; + +import com.samhap.kokomen.notification.repository.NotificationRepository; +import com.samhap.kokomen.notification.service.dto.NotificationRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class NotificationInternalService { + + private final NotificationRepository notificationRepository; + + public void saveNotification(NotificationRequest notificationRequest) { + notificationRepository.save(notificationRequest.toNotification()); + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/notification/service/dto/NotificationRequest.java b/internal/src/main/java/com/samhap/kokomen/notification/service/dto/NotificationRequest.java new file mode 100644 index 0000000..9da5a50 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/notification/service/dto/NotificationRequest.java @@ -0,0 +1,21 @@ +package com.samhap.kokomen.notification.service.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.samhap.kokomen.notification.domain.Notification; +import com.samhap.kokomen.notification.domain.payload.NotificationPayload; +import com.samhap.kokomen.notification.infrastructure.NotificationPayloadDeserializer; + +public record NotificationRequest( + Long receiverMemberId, + @JsonDeserialize(using = NotificationPayloadDeserializer.class) + NotificationPayload notificationPayload +) { + + public Notification toNotification() { + try { + return new Notification(receiverMemberId, notificationPayload); + } catch (Exception e) { + throw new IllegalStateException("NotificationPayload 파싱 실패", e); + } + } +} diff --git a/internal/src/main/resources/application.yml b/internal/src/main/resources/application.yml index e69de29..31b55bf 100644 --- a/internal/src/main/resources/application.yml +++ b/internal/src/main/resources/application.yml @@ -0,0 +1,14 @@ +management: + server: + address: 0.0.0.0 + port: 8000 +spring: + application: + name: kokomen-notification-internal + profiles: + include: + - domain + - common + jackson: + property-naming-strategy: SNAKE_CASE + default-property-inclusion: non_null diff --git a/internal/src/test/java/com/samhap/kokomen/KokomenApplicationTests.java b/internal/src/test/java/com/samhap/kokomen/KokomenApplicationTests.java deleted file mode 100644 index 234e7e7..0000000 --- a/internal/src/test/java/com/samhap/kokomen/KokomenApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.samhap.kokomen; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class KokomenApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/internal/src/test/java/com/samhap/kokomen/KokomenNotificationInternalApplicationTests.java b/internal/src/test/java/com/samhap/kokomen/KokomenNotificationInternalApplicationTests.java new file mode 100644 index 0000000..03dfdfb --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/KokomenNotificationInternalApplicationTests.java @@ -0,0 +1,14 @@ +package com.samhap.kokomen; + +import com.samhap.kokomen.global.BaseTest; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class KokomenNotificationInternalApplicationTests extends BaseTest { + + @Test + void contextLoads() { + } + +} diff --git a/internal/src/test/java/com/samhap/kokomen/global/BaseControllerTest.java b/internal/src/test/java/com/samhap/kokomen/global/BaseControllerTest.java new file mode 100644 index 0000000..779ae8f --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/global/BaseControllerTest.java @@ -0,0 +1,39 @@ +package com.samhap.kokomen.global; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@ExtendWith(RestDocumentationExtension.class) +public abstract class BaseControllerTest extends BaseTest { + + protected MockMvc mockMvc; + + @BeforeEach + void baseControllerTestSetUp(WebApplicationContext context, RestDocumentationContextProvider restDocumentation) { + var uriPreprocessor = modifyUris() + .scheme("http") + .host("localhost") + .removePort(); + + var headerPreprocessor = modifyHeaders().remove(HttpHeaders.CONTENT_LENGTH); + + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .alwaysDo(print()) + .apply(documentationConfiguration(restDocumentation).operationPreprocessors() + .withRequestDefaults(uriPreprocessor, prettyPrint(), headerPreprocessor) + .withResponseDefaults(prettyPrint(), headerPreprocessor) + ).build(); + } +} diff --git a/internal/src/test/java/com/samhap/kokomen/global/BaseTest.java b/internal/src/test/java/com/samhap/kokomen/global/BaseTest.java new file mode 100644 index 0000000..739137d --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/global/BaseTest.java @@ -0,0 +1,21 @@ +package com.samhap.kokomen.global; + + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = WebEnvironment.MOCK) +public abstract class BaseTest { + + @Autowired + private MySQLDatabaseCleaner mySQLDatabaseCleaner; + + @BeforeEach + void baseTestSetUp() { + mySQLDatabaseCleaner.executeTruncate(); + } +} diff --git a/internal/src/test/java/com/samhap/kokomen/global/MySQLDatabaseCleaner.java b/internal/src/test/java/com/samhap/kokomen/global/MySQLDatabaseCleaner.java new file mode 100644 index 0000000..9db2dd5 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/global/MySQLDatabaseCleaner.java @@ -0,0 +1,69 @@ +package com.samhap.kokomen.global; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.metamodel.EntityType; +import java.util.List; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class MySQLDatabaseCleaner { + + public static final String CAMEL_CASE = "([a-z])([A-Z])"; + public static final String SNAKE_CASE = "$1_$2"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @PostConstruct + public void findTableNames() { + tableNames = entityManager.getMetamodel().getEntities().stream() + .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) + .map(MySQLDatabaseCleaner::convertCamelToSnake) + .toList(); + } + + private static String convertCamelToSnake(final EntityType e) { + return e.getName() + .replaceAll(CAMEL_CASE, SNAKE_CASE) + .toLowerCase(); + } + + @Transactional + public void executeTruncate() { + entityManager.flush(); + entityManager.clear(); + + disableIntegrity(); + for (String tableName : tableNames) { + truncateTable(tableName); + resetAutoIncrement(tableName); + } + enableIntegrity(); + } + + private void disableIntegrity() { + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0") + .executeUpdate(); + } + + private void truncateTable(final String tableName) { + entityManager.createNativeQuery(String.format("TRUNCATE TABLE %s", tableName)) + .executeUpdate(); + } + + private void resetAutoIncrement(final String tableName) { + entityManager.createNativeQuery(String.format("ALTER TABLE %s AUTO_INCREMENT = 1", tableName)) + .executeUpdate(); + } + + private void enableIntegrity() { + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1") + .executeUpdate(); + } +} diff --git a/internal/src/test/java/com/samhap/kokomen/notification/controller/NotificationInternalControllerTest.java b/internal/src/test/java/com/samhap/kokomen/notification/controller/NotificationInternalControllerTest.java new file mode 100644 index 0000000..fe1c3c5 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/notification/controller/NotificationInternalControllerTest.java @@ -0,0 +1,107 @@ +package com.samhap.kokomen.notification.controller; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.samhap.kokomen.global.BaseControllerTest; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +class NotificationInternalControllerTest extends BaseControllerTest { + + @Test + void 인터뷰_좋아요_알림_생성() throws Exception { + // given + String requestJson = """ + { + "receiver_member_id": 1, + "notification_payload": { + "notification_type": "INTERVIEW_LIKE", + "interview_id": 1, + "liker_member_id": 2, + "like_count": 5 + } + } + """; + + // when & then + mockMvc.perform(post("/internal/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isNoContent()) + .andDo(document( + "notification-saveNotification-likeInterview", + requestFields(fieldWithPath("receiver_member_id").description("알림 수신자 회원 ID"), + fieldWithPath("notification_payload").description("알림 페이로드"), + fieldWithPath("notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notification_payload.interview_id").description("인터뷰 ID"), + fieldWithPath("notification_payload.liker_member_id").description("좋아요를 누른 회원 ID"), + fieldWithPath("notification_payload.like_count").description("좋아요 수")) + )); + } + + @Test + void 인터뷰_답변_좋아요_알림_생성() throws Exception { + // given + String requestJson = """ + { + "receiver_member_id": 1, + "notification_payload": { + "notification_type": "ANSWER_LIKE", + "answer_id": 1, + "interview_id": 2, + "liker_member_id": 3, + "like_count": 5 + } + } + """; + + // when & then + mockMvc.perform(post("/internal/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isNoContent()) + .andDo(document( + "notification-saveNotification-likeAnswer", + requestFields(fieldWithPath("receiver_member_id").description("알림 수신자 회원 ID"), + fieldWithPath("notification_payload").description("알림 페이로드"), + fieldWithPath("notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notification_payload.answer_id").description("답변 ID"), + fieldWithPath("notification_payload.interview_id").description("인터뷰 ID"), + fieldWithPath("notification_payload.liker_member_id").description("좋아요를 누른 회원 ID"), + fieldWithPath("notification_payload.like_count").description("좋아요 수")) + )); + } + + @Test + void 인터뷰_조회수_알림_생성() throws Exception { + // given + String requestJson = """ + { + "receiver_member_id": 1, + "notification_payload": { + "notification_type": "INTERVIEW_VIEW_COUNT", + "interview_id": 1, + "view_count": 10 + } + } + """; + + // when & then + mockMvc.perform(post("/internal/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isNoContent()) + .andDo(document( + "notification-saveNotification-interviewViewCount", + requestFields(fieldWithPath("receiver_member_id").description("알림 수신자 회원 ID"), + fieldWithPath("notification_payload").description("알림 페이로드"), + fieldWithPath("notification_payload.notification_type").description("알림 타입"), + fieldWithPath("notification_payload.interview_id").description("인터뷰 ID"), + fieldWithPath("notification_payload.view_count").description("조회수")) + )); + } +} diff --git a/internal/src/test/resources/application.yml b/internal/src/test/resources/application.yml new file mode 100644 index 0000000..71202a2 --- /dev/null +++ b/internal/src/test/resources/application.yml @@ -0,0 +1,10 @@ +spring: + profiles: + include: + - domain-test + - common-test + main: + lazy-initialization: true + jackson: + property-naming-strategy: SNAKE_CASE + default-property-inclusion: non_null diff --git a/settings.gradle b/settings.gradle index b2d976b..01d295d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ rootProject.name = 'kokomen-notifications' -include 'api' +include 'common' include 'domain' +include 'api' include 'internal'