diff --git a/.github/workflows/dev-cicd.yaml b/.github/workflows/dev-cicd.yaml index d10a900a..dd0932c4 100644 --- a/.github/workflows/dev-cicd.yaml +++ b/.github/workflows/dev-cicd.yaml @@ -84,10 +84,6 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest - environment: - name: Development - url: https://api-dev.souzip.com - steps: - name: Checkout uses: actions/checkout@v3 @@ -140,16 +136,6 @@ jobs: DEV_API_DOCS_URL=${{ secrets.DEV_API_DOCS_URL }} ADMIN_INITIAL_USERNAME=${{ secrets.ADMIN_INITIAL_USERNAME }} ADMIN_INITIAL_PASSWORD=${{ secrets.ADMIN_INITIAL_PASSWORD }} - GRAFANA_ADMIN_USER=${{ secrets.DEV_GRAFANA_ADMIN_USER }} - GRAFANA_ADMIN_PASSWORD=${{ secrets.DEV_GRAFANA_ADMIN_PASSWORD }} - FIREBASE_ENABLED=${{ secrets.FIREBASE_ENABLED }} - FIREBASE_CREDENTIALS_PATH=${{ secrets.DEV_FIREBASE_CREDENTIALS_PATH }} - MAIL_SMTP_ENABLED=${{ secrets.MAIL_SMTP_ENABLED }} - MAIL_SMTP_HOST=${{ secrets.MAIL_SMTP_HOST }} - MAIL_SMTP_PORT=${{ secrets.MAIL_SMTP_PORT }} - MAIL_SMTP_USERNAME=${{ secrets.MAIL_SMTP_USERNAME }} - MAIL_SMTP_PASSWORD=${{ secrets.MAIL_SMTP_PASSWORD }} - MAIL_SMTP_FROM=${{ secrets.MAIL_SMTP_FROM }} EOF echo "DEPLOYER=${{ github.actor }}" >> deploy/dev/.env @@ -159,4 +145,4 @@ jobs: gcloud auth configure-docker asia-northeast3-docker.pkg.dev - ./deploy/dev/scripts/deploy.sh + ./deploy/dev/scripts/deploy.sh \ No newline at end of file diff --git a/.github/workflows/prod-cicd.yaml b/.github/workflows/prod-cicd.yaml index 91d56424..0dda8930 100644 --- a/.github/workflows/prod-cicd.yaml +++ b/.github/workflows/prod-cicd.yaml @@ -79,10 +79,6 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest - environment: - name: Production - url: https://api.souzip.com - steps: - uses: actions/checkout@v3 @@ -135,16 +131,6 @@ jobs: NCP_BUCKET=${{ secrets.NCP_BUCKET }} NCP_ACCESS_KEY=${{ secrets.NCP_ACCESS_KEY }} NCP_SECRET_KEY=${{ secrets.NCP_SECRET_KEY }} - GRAFANA_ADMIN_USER=${{ secrets.PROD_GRAFANA_ADMIN_USER }} - GRAFANA_ADMIN_PASSWORD=${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} - FIREBASE_ENABLED=${{ secrets.FIREBASE_ENABLED }} - FIREBASE_CREDENTIALS_PATH=${{ secrets.PROD_FIREBASE_CREDENTIALS_PATH }} - MAIL_SMTP_ENABLED=${{ secrets.MAIL_SMTP_ENABLED }} - MAIL_SMTP_HOST=${{ secrets.MAIL_SMTP_HOST }} - MAIL_SMTP_PORT=${{ secrets.MAIL_SMTP_PORT }} - MAIL_SMTP_USERNAME=${{ secrets.MAIL_SMTP_USERNAME }} - MAIL_SMTP_PASSWORD=${{ secrets.MAIL_SMTP_PASSWORD }} - MAIL_SMTP_FROM=${{ secrets.MAIL_SMTP_FROM }} EOF echo "DEPLOYER=${{ github.actor }}" >> deploy/prod/.env @@ -154,4 +140,4 @@ jobs: gcloud auth configure-docker asia-northeast3-docker.pkg.dev - ./deploy/prod/scripts/deploy.sh + ./deploy/prod/scripts/deploy.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a7e5f013..9cd07fe2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,9 @@ FROM eclipse-temurin:21-jdk WORKDIR /app -COPY build/libs/*.jar /souzip-api.jar +ARG JAR_FILE=build/libs/souzip-api-0.0.1-SNAPSHOT.jar +COPY ${JAR_FILE} /souzip-api.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "/souzip-api.jar"] +ENTRYPOINT ["java", "-jar", "/souzip-api.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3d2a6793..ff3abae8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,164 +1,155 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.8' - id 'io.spring.dependency-management' version '1.1.7' - id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'java' + id 'org.springframework.boot' version '3.5.8' + id 'io.spring.dependency-management' version '1.1.7' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } group = 'com.souzip' -version = '1.1.0-SNAPSHOT' -description = 'Souzip API - Travel Souvenir Recommendation Platform' +version = '0.0.1-SNAPSHOT' +description = 'Demo project for Spring Boot' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -jar { - enabled = false + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } - asciidoctorExt + compileOnly { + extendsFrom annotationProcessor + } + asciidoctorExt } repositories { - mavenCentral() + mavenCentral() } ext { - snippetsDir = file('build/generated-snippets') + snippetsDir = file('build/generated-snippets') } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-aop' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'me.paulschwarz:spring-dotenv:4.0.0' + implementation 'me.paulschwarz:spring-dotenv:4.0.0' - runtimeOnly 'org.springframework.boot:spring-boot-docker-compose' + runtimeOnly 'org.springframework.boot:spring-boot-docker-compose' - runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'org.postgresql:postgresql' - implementation 'org.flywaydb:flyway-core' - implementation 'org.flywaydb:flyway-database-postgresql' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' - implementation 'io.jsonwebtoken:jjwt-api:0.12.3' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' - implementation 'io.micrometer:micrometer-registry-prometheus' - implementation 'net.logstash.logback:logstash-logback-encoder:8.1' + implementation 'io.micrometer:micrometer-registry-prometheus' - implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' - annotationProcessor 'jakarta.annotation:jakarta.annotation-api' - annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' - implementation 'com.amazonaws:aws-java-sdk-s3:1.12.529' + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.529' - implementation 'com.google.firebase:firebase-admin:9.4.3' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'com.h2database:h2' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' - testImplementation 'com.h2database:h2' - testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' } def generatedDir = 'build/generated/sources/annotationProcessor/java/main' tasks.withType(JavaCompile).configureEach { - options.getGeneratedSourceOutputDirectory().set(file(generatedDir)) + options.getGeneratedSourceOutputDirectory().set(file(generatedDir)) } sourceSets { - main.java.srcDirs += [generatedDir] + main.java.srcDirs += [generatedDir] } clean { - delete file(generatedDir) + delete file(generatedDir) } tasks.named('test') { - outputs.dir snippetsDir - useJUnitPlatform() - systemProperty 'spring.profiles.active', 'test' + outputs.dir snippetsDir + useJUnitPlatform() } def appDocsOutDir = file("$buildDir/docs/app") def adminDocsOutDir = file("$buildDir/docs/admin") tasks.register('asciidoctorApp', org.asciidoctor.gradle.jvm.AsciidoctorTask) { - group = 'documentation' - description = 'Generate App API docs (Asciidoctor)' + group = 'documentation' + description = 'Generate App API docs (Asciidoctor)' - inputs.dir snippetsDir - configurations 'asciidoctorExt' - dependsOn tasks.named('test') + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn tasks.named('test') - baseDirFollowsSourceFile() + baseDirFollowsSourceFile() - sourceDir = file('src/docs/asciidoc/app') - sources { include 'index.adoc' } + sourceDir = file('src/docs/asciidoc/app') + sources { include 'index.adoc' } - outputDir = appDocsOutDir + outputDir = appDocsOutDir } tasks.register('asciidoctorAdmin', org.asciidoctor.gradle.jvm.AsciidoctorTask) { - group = 'documentation' - description = 'Generate Admin API docs (Asciidoctor)' + group = 'documentation' + description = 'Generate Admin API docs (Asciidoctor)' - inputs.dir snippetsDir - configurations 'asciidoctorExt' - dependsOn tasks.named('test') + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn tasks.named('test') - baseDirFollowsSourceFile() + baseDirFollowsSourceFile() - sourceDir = file('src/docs/asciidoc/admin') - sources { include 'index.adoc' } + sourceDir = file('src/docs/asciidoc/admin') + sources { include 'index.adoc' } - outputDir = adminDocsOutDir + outputDir = adminDocsOutDir } tasks.named('bootJar') { - dependsOn tasks.named('asciidoctorApp'), tasks.named('asciidoctorAdmin') - - from(appDocsOutDir) { - into 'static/docs/app' - } - from(adminDocsOutDir) { - into 'static/docs/admin' - } + dependsOn tasks.named('asciidoctorApp'), tasks.named('asciidoctorAdmin') + + from(appDocsOutDir) { + into 'static/docs/app' + } + from(adminDocsOutDir) { + into 'static/docs/admin' + } } tasks.register('copyDocsApp', Copy) { - group = 'documentation' - description = 'Copy App docs to src/main/resources/static/docs/app' - dependsOn tasks.named('asciidoctorApp') - from appDocsOutDir - into file("src/main/resources/static/docs/app") + group = 'documentation' + description = 'Copy App docs to src/main/resources/static/docs/app' + dependsOn tasks.named('asciidoctorApp') + from appDocsOutDir + into file("src/main/resources/static/docs/app") } tasks.register('copyDocsAdmin', Copy) { - group = 'documentation' - description = 'Copy Admin docs to src/main/resources/static/docs/admin' - dependsOn tasks.named('asciidoctorAdmin') - from adminDocsOutDir - into file("src/main/resources/static/docs/admin") -} \ No newline at end of file + group = 'documentation' + description = 'Copy Admin docs to src/main/resources/static/docs/admin' + dependsOn tasks.named('asciidoctorAdmin') + from adminDocsOutDir + into file("src/main/resources/static/docs/admin") +} diff --git a/deploy/dev/docker-compose.app.yaml b/deploy/dev/docker-compose.app.yaml index 0bd70480..7caffcff 100644 --- a/deploy/dev/docker-compose.app.yaml +++ b/deploy/dev/docker-compose.app.yaml @@ -9,9 +9,6 @@ services: - souzip-network env_file: - .env - volumes: - - /home/souzip-dev/secrets:/home/souzip-dev/secrets:ro - - /home/souzip-dev/logs/souzip-api:/home/souzip-dev/logs/souzip-api ports: - "127.0.0.1:8080:8080" diff --git a/deploy/dev/docker-compose.db.yaml b/deploy/dev/docker-compose.db.yaml index 8083de54..ca1a12cc 100644 --- a/deploy/dev/docker-compose.db.yaml +++ b/deploy/dev/docker-compose.db.yaml @@ -8,7 +8,7 @@ services: networks: - souzip-network ports: - - "127.0.0.1:5432:5432" + - "5432:5432" env_file: - .env environment: diff --git a/deploy/dev/docker-compose.monitoring.yaml b/deploy/dev/docker-compose.monitoring.yaml deleted file mode 100644 index fc02ed0a..00000000 --- a/deploy/dev/docker-compose.monitoring.yaml +++ /dev/null @@ -1,44 +0,0 @@ -version: '3.8' - -services: - prometheus: - image: prom/prometheus:latest - container_name: souzip-prometheus-dev - ports: - - "127.0.0.1:9090:9090" - volumes: - - ./prometheus.yaml:/etc/prometheus/prometheus.yaml - - prometheus-data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yaml' - - '--storage.tsdb.retention.time=30d' - networks: - - souzip-network - restart: unless-stopped - - grafana: - image: grafana/grafana:latest - container_name: souzip-grafana-dev - ports: - - "127.0.0.1:3000:3000" - volumes: - - grafana-data:/var/lib/grafana - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} - - GF_USERS_ALLOW_SIGN_UP=false - - GF_USERS_DEFAULT_THEME=light - - GF_SERVER_ROOT_URL=https://dev-monitor.souzip.com - networks: - - souzip-network - restart: unless-stopped - depends_on: - - prometheus - -networks: - souzip-network: - external: true - -volumes: - prometheus-data: - grafana-data: diff --git a/deploy/dev/prometheus.yaml b/deploy/dev/prometheus.yaml deleted file mode 100644 index 5cdeca0f..00000000 --- a/deploy/dev/prometheus.yaml +++ /dev/null @@ -1,16 +0,0 @@ -global: - scrape_interval: 30s - evaluation_interval: 30s - -scrape_configs: - - job_name: 'prometheus' - static_configs: - - targets: ['localhost:9090'] - - - job_name: 'souzip-api-dev' - metrics_path: '/actuator/prometheus' - static_configs: - - targets: ['souzip-api:8080'] - labels: - application: 'souzip-api' - environment: 'dev' diff --git a/deploy/dev/scripts/deploy.sh b/deploy/dev/scripts/deploy.sh index 73d68afb..747ade22 100644 --- a/deploy/dev/scripts/deploy.sh +++ b/deploy/dev/scripts/deploy.sh @@ -12,7 +12,6 @@ DEPLOY_DIR="$WORK_DIR/deploy/dev" APP_COMPOSE="docker-compose.app.yaml" DB_COMPOSE="docker-compose.db.yaml" -MONITORING_COMPOSE="docker-compose.monitoring.yaml" HEALTH_CHECK_URL="http://localhost:8080/actuator/health" MAX_RETRY=6 @@ -32,7 +31,7 @@ fi cd "$WORK_DIR" || exit 1 -echo -e "${YELLOW}[1/10] 현재 이미지를 롤백용으로 보관${NC}" +echo -e "${YELLOW}[1/9] 현재 이미지를 롤백용으로 보관${NC}" CURRENT_IMAGE=$(docker images ${REGISTRY}:latest -q || true) if [ ! -z "${CURRENT_IMAGE:-}" ]; then docker rmi ${REGISTRY}:previous 2>/dev/null || true @@ -42,7 +41,7 @@ else echo -e "${YELLOW}[INFO] 기존 이미지 없음 (첫 배포)${NC}" fi -echo -e "${YELLOW}[2/10] 오래된 이미지 정리${NC}" +echo -e "${YELLOW}[2/9] 오래된 이미지 정리${NC}" OLD_IMAGES=$(docker images ${REGISTRY} -q | tail -n +3 || true) if [ ! -z "${OLD_IMAGES:-}" ]; then echo "$OLD_IMAGES" | xargs docker rmi -f 2>/dev/null || true @@ -51,7 +50,7 @@ else echo -e "${YELLOW}[INFO] 삭제할 오래된 이미지 없음${NC}" fi -echo -e "${YELLOW}[3/10] 최신 이미지 다운로드${NC}" +echo -e "${YELLOW}[3/9] 최신 이미지 다운로드${NC}" docker pull ${REGISTRY}:latest NEW_IMAGE=$(docker images ${REGISTRY}:latest -q || true) if [ -z "${NEW_IMAGE:-}" ]; then @@ -67,7 +66,7 @@ if [ ! -f ".env" ]; then exit 1 fi -echo -e "${YELLOW}[4/10] DB 컨테이너 확인${NC}" +echo -e "${YELLOW}[4/9] DB 컨테이너 확인(없으면 시작)${NC}" if ! docker ps --format '{{.Names}}' | grep -q '^souzip-dev-db$'; then docker compose -f "$DB_COMPOSE" up -d if [ $? -ne 0 ]; then @@ -79,31 +78,11 @@ else echo -e "${GREEN}[SUCCESS] DB 컨테이너 이미 실행 중${NC}" fi -echo -e "${YELLOW}[5/10] 모니터링 스택 확인${NC}" -PROMETHEUS_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-prometheus-dev$' && echo "true" || echo "false") -GRAFANA_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-grafana-dev$' && echo "true" || echo "false") - -if [ "$PROMETHEUS_RUNNING" = "false" ] || [ "$GRAFANA_RUNNING" = "false" ]; then - if [ -f "$MONITORING_COMPOSE" ]; then - echo -e "${YELLOW}[INFO] 모니터링 스택 시작 중...${NC}" - docker compose -f "$MONITORING_COMPOSE" up -d - if [ $? -ne 0 ]; then - echo -e "${RED}[WARNING] 모니터링 스택 시작 실패 (계속 진행)${NC}" - else - echo -e "${GREEN}[SUCCESS] 모니터링 스택 시작 완료${NC}" - fi - else - echo -e "${YELLOW}[INFO] 모니터링 설정 파일 없음 ($MONITORING_COMPOSE)${NC}" - fi -else - echo -e "${GREEN}[SUCCESS] 모니터링 스택 이미 실행 중${NC}" -fi - -echo -e "${YELLOW}[6/10] 기존 APP 컨테이너 중지${NC}" +echo -e "${YELLOW}[5/9] 기존 APP 컨테이너 중지${NC}" docker compose -f "$APP_COMPOSE" down 2>/dev/null || docker rm -f souzip-api 2>/dev/null || true echo -e "${GREEN}[SUCCESS] 기존 APP 컨테이너 중지 완료${NC}" -echo -e "${YELLOW}[7/10] 새 APP 컨테이너 시작${NC}" +echo -e "${YELLOW}[6/9] 새 APP 컨테이너 시작${NC}" docker compose -f "$APP_COMPOSE" up -d if [ $? -ne 0 ]; then echo -e "${RED}[ERROR] APP 컨테이너 시작 실패${NC}" @@ -113,10 +92,10 @@ if [ $? -ne 0 ]; then fi echo -e "${GREEN}[SUCCESS] 새 컨테이너 시작 완료${NC}" -echo -e "${YELLOW}[8/10] 애플리케이션 시작 대기${NC}" +echo -e "${YELLOW}[7/9] 애플리케이션 시작 대기${NC}" sleep 10 -echo -e "${YELLOW}[9/10] 헬스체크 (최대 ${MAX_RETRY}번 시도)${NC}" +echo -e "${YELLOW}[8/9] 헬스체크 (최대 ${MAX_RETRY}번 시도)${NC}" RETRY_COUNT=0 HEALTH_OK=false @@ -146,7 +125,7 @@ if [ "$HEALTH_OK" = false ]; then exit 1 fi -echo -e "${YELLOW}[10/10] 정리${NC}" +echo -e "${YELLOW}[9/9] 정리${NC}" docker image prune -f || true if [ ! -z "${DEVELOP_DISCORD_WEBHOOK_URL:-}" ] && [ -f "$WORK_DIR/deploy/shared/discord-notify.sh" ]; then diff --git a/deploy/prod/docker-compose.blue.yaml b/deploy/prod/docker-compose.blue.yaml index 934dc837..bdeecb19 100644 --- a/deploy/prod/docker-compose.blue.yaml +++ b/deploy/prod/docker-compose.blue.yaml @@ -11,12 +11,9 @@ services: - TZ=Asia/Seoul env_file: - .env - volumes: - - /home/souzip-prod/secrets:/home/souzip-prod/secrets:ro - - /home/souzip-prod/logs/souzip-api-blue:/home/souzip-prod/logs/souzip-api restart: always networks: default: name: souzip-network - external: true + external: true \ No newline at end of file diff --git a/deploy/prod/docker-compose.green.yaml b/deploy/prod/docker-compose.green.yaml index d62da854..dcddb22e 100644 --- a/deploy/prod/docker-compose.green.yaml +++ b/deploy/prod/docker-compose.green.yaml @@ -11,12 +11,9 @@ services: - TZ=Asia/Seoul env_file: - .env - volumes: - - /home/souzip-prod/secrets:/home/souzip-prod/secrets:ro - - /home/souzip-prod/logs/souzip-api-green:/home/souzip-prod/logs/souzip-api restart: always networks: default: name: souzip-network - external: true + external: true \ No newline at end of file diff --git a/deploy/prod/docker-compose.logging.yaml b/deploy/prod/docker-compose.logging.yaml deleted file mode 100644 index d0ab9aa2..00000000 --- a/deploy/prod/docker-compose.logging.yaml +++ /dev/null @@ -1,37 +0,0 @@ -version: '3.8' - -services: - loki: - image: grafana/loki:3.0.0 - container_name: souzip-loki-prod - ports: - - "127.0.0.1:3100:3100" - volumes: - - loki-data:/loki - - ./loki-config.yaml:/etc/loki/local-config.yaml - command: -config.file=/etc/loki/local-config.yaml - networks: - - souzip-network - restart: unless-stopped - - promtail: - image: grafana/promtail:3.0.0 - container_name: souzip-promtail-prod - volumes: - - /home/souzip-prod/logs:/logs:ro - - ./promtail.yaml:/etc/promtail/config.yaml:ro - - promtail-positions:/promtail - command: -config.file=/etc/promtail/config.yaml - networks: - - souzip-network - restart: unless-stopped - depends_on: - - loki - -networks: - souzip-network: - external: true - -volumes: - loki-data: - promtail-positions: diff --git a/deploy/prod/docker-compose.monitoring.yaml b/deploy/prod/docker-compose.monitoring.yaml deleted file mode 100644 index 2173d198..00000000 --- a/deploy/prod/docker-compose.monitoring.yaml +++ /dev/null @@ -1,78 +0,0 @@ -version: '3.8' - -services: - prometheus: - image: prom/prometheus:latest - container_name: souzip-prometheus-prod - ports: - - "127.0.0.1:9090:9090" - volumes: - - ./prometheus.yaml:/etc/prometheus/prometheus.yaml - - prometheus-data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yaml' - - '--storage.tsdb.retention.time=90d' - networks: - - souzip-network - restart: unless-stopped - - grafana: - image: grafana/grafana:latest - container_name: souzip-grafana-prod - ports: - - "127.0.0.1:3000:3000" - volumes: - - grafana-data:/var/lib/grafana - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} - - GF_USERS_ALLOW_SIGN_UP=false - - GF_SERVER_ROOT_URL=https://monitor.souzip.com - networks: - - souzip-network - restart: unless-stopped - depends_on: - - prometheus - - node-exporter: - image: prom/node-exporter:latest - container_name: souzip-node-exporter - ports: - - "127.0.0.1:9100:9100" - command: - - '--path.procfs=/host/proc' - - '--path.sysfs=/host/sys' - - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' - volumes: - - /proc:/host/proc:ro - - /sys:/host/sys:ro - - /:/rootfs:ro - networks: - - souzip-network - restart: unless-stopped - - cadvisor: - image: gcr.io/cadvisor/cadvisor:latest - container_name: souzip-cadvisor - ports: - - "127.0.0.1:8083:8080" - volumes: - - /:/rootfs:ro - - /var/run:/var/run:ro - - /sys:/sys:ro - - /var/lib/docker/:/var/lib/docker:ro - - /dev/disk/:/dev/disk:ro - privileged: true - devices: - - /dev/kmsg - networks: - - souzip-network - restart: unless-stopped - -networks: - souzip-network: - external: true - -volumes: - prometheus-data: - grafana-data: diff --git a/deploy/prod/loki-config.yaml b/deploy/prod/loki-config.yaml deleted file mode 100644 index 17f2cc37..00000000 --- a/deploy/prod/loki-config.yaml +++ /dev/null @@ -1,47 +0,0 @@ -auth_enabled: false - -server: - http_listen_port: 3100 - grpc_listen_port: 9096 - -common: - instance_addr: 127.0.0.1 - path_prefix: /loki - storage: - filesystem: - chunks_directory: /loki/chunks - rules_directory: /loki/rules - replication_factor: 1 - ring: - kvstore: - store: inmemory - -query_range: - results_cache: - cache: - embedded_cache: - enabled: true - max_size_mb: 100 - -schema_config: - configs: - - from: 2024-01-01 - store: tsdb - object_store: filesystem - schema: v13 - index: - prefix: index_ - period: 24h - -limits_config: - retention_period: 90d - reject_old_samples: true - reject_old_samples_max_age: 168h - -compactor: - working_directory: /loki/compactor - compaction_interval: 10m - retention_enabled: true - retention_delete_delay: 2h - retention_delete_worker_count: 150 - delete_request_store: filesystem \ No newline at end of file diff --git a/deploy/prod/prometheus.yaml b/deploy/prod/prometheus.yaml deleted file mode 100644 index 33ad773f..00000000 --- a/deploy/prod/prometheus.yaml +++ /dev/null @@ -1,38 +0,0 @@ -global: - scrape_interval: 60s - evaluation_interval: 60s - -scrape_configs: - - job_name: 'prometheus' - static_configs: - - targets: ['localhost:9090'] - - - job_name: 'souzip-api-blue' - metrics_path: '/actuator/prometheus' - static_configs: - - targets: ['souzip-api-blue:8080'] - labels: - application: 'souzip-api' - environment: 'prod' - deployment: 'blue' - - - job_name: 'souzip-api-green' - metrics_path: '/actuator/prometheus' - static_configs: - - targets: ['souzip-api-green:8080'] - labels: - application: 'souzip-api' - environment: 'prod' - deployment: 'green' - - - job_name: 'node-exporter' - static_configs: - - targets: ['souzip-node-exporter:9100'] - labels: - environment: 'prod' - - - job_name: 'cadvisor' - static_configs: - - targets: ['souzip-cadvisor:8080'] - labels: - environment: 'prod' diff --git a/deploy/prod/promtail.yaml b/deploy/prod/promtail.yaml deleted file mode 100644 index 966bf2de..00000000 --- a/deploy/prod/promtail.yaml +++ /dev/null @@ -1,28 +0,0 @@ -server: - http_listen_port: 9080 - grpc_listen_port: 0 - -positions: - filename: /promtail/positions.yaml - -clients: - - url: http://souzip-loki-prod:3100/loki/api/v1/push - -scrape_configs: - - job_name: souzip-api-blue - static_configs: - - targets: [localhost] - labels: - job: souzip-api - environment: prod - deployment: blue - __path__: /logs/souzip-api-blue/*.log - - - job_name: souzip-api-green - static_configs: - - targets: [localhost] - labels: - job: souzip-api - environment: prod - deployment: green - __path__: /logs/souzip-api-green/*.log diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java deleted file mode 100644 index 6c3f0f2d..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminEmailApi.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.webapi.admin.dto.EmailBroadcastRequest; -import com.souzip.adapter.webapi.admin.dto.EmailTestSendRequest; -import com.souzip.application.email.EmailBroadcastService; -import com.souzip.application.email.dto.EmailBroadcastResult; -import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; -import com.souzip.shared.common.dto.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -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; - -@RequestMapping("/api/admin/email") -@RequiredArgsConstructor -@RestController -public class AdminEmailApi { - - private final EmailBroadcastService emailBroadcastService; - - // 한 주소로만 테스트 발송합니다(실회원 DB 조회 없음). - @AdminAccess - @PostMapping("/test-send") - public SuccessResponse testSend(@Valid @RequestBody EmailTestSendRequest request) { - EmailBroadcastResult result = emailBroadcastService.sendTestToSingleAddress( - request.to(), - request.subject(), - request.body() - ); - return SuccessResponse.of(result, buildTestMessage(result)); - } - - // 전체 회원(이메일 보유·탈퇴 제외) 고유 주소로 동일 메일을 발송합니다. - @AdminAccess - @PostMapping("/broadcast") - public SuccessResponse broadcast(@Valid @RequestBody EmailBroadcastRequest request) { - EmailBroadcastResult result = emailBroadcastService.broadcastToAllMemberEmails( - request.subject(), - request.body() - ); - return SuccessResponse.of(result, buildMessage(result)); - } - - private static String buildTestMessage(EmailBroadcastResult result) { - if (!result.smtpConfigured()) { - return "SMTP가 설정되지 않아 테스트 메일을 보내지 않았습니다."; - } - if (result.successCount() > 0) { - return "테스트 메일을 발송했습니다."; - } - return "테스트 메일 발송에 실패했습니다."; - } - - private static String buildMessage(EmailBroadcastResult result) { - if (result.totalTargets() == 0) { - return "발송 대상 이메일이 없어 전송하지 않았습니다."; - } - if (!result.smtpConfigured()) { - return "SMTP가 설정되지 않아 전송하지 않았습니다. (대상 " + result.totalTargets() + "건)"; - } - return String.format( - "이메일 발송 완료. 대상 %d건, 성공 %d건, 실패 %d건", - result.totalTargets(), - result.successCount(), - result.failCount() - ); - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java b/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java deleted file mode 100644 index 7464dcd2..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.souzip.adapter.webapi.admin; - -import com.souzip.adapter.webapi.admin.dto.PushBroadcastRequest; -import com.souzip.application.notification.FcmNotificationService; -import com.souzip.application.notification.PushBroadcastHistoryCommandService; -import com.souzip.application.notification.PushBroadcastHistoryQueryService; -import com.souzip.application.notification.dto.MarketingConsentStats; -import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; -import com.souzip.application.notification.dto.PushBroadcastResult; -import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; -import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; -import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; -import com.souzip.domain.user.repository.UserAgreementRepository; -import com.souzip.shared.common.dto.SuccessResponse; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@RequestMapping("/api/admin/push") -@RequiredArgsConstructor -@RestController -public class AdminPushApi { - - private final FcmNotificationService fcmNotificationService; - private final PushBroadcastHistoryCommandService pushBroadcastHistoryCommandService; - private final PushBroadcastHistoryQueryService pushBroadcastHistoryQueryService; - private final UserAgreementRepository userAgreementRepository; - - // 마케팅 수신 동의한 사용자에게만 푸시를 발송합니다. - @AdminAccess - @PostMapping("/broadcast") - public SuccessResponse broadcast( - @CurrentAdminId UUID adminId, - @Valid @RequestBody PushBroadcastRequest request - ) { - PushBroadcastResult result = fcmNotificationService.broadcastToMarketingConsentedTokens( - request.title(), - request.body() - ); - pushBroadcastHistoryCommandService.record(adminId, request.title(), request.body(), result); - String message = buildMessage(result); - return SuccessResponse.of(result, message); - } - - // 마케팅 수신 동의 현황을 조회합니다. - @ViewerAccess - @GetMapping("/marketing-consent/stats") - public SuccessResponse marketingConsentStats() { - long total = userAgreementRepository.count(); - long consented = userAgreementRepository.countByMarketingConsentTrue(); - return SuccessResponse.of(new MarketingConsentStats(total, consented, total - consented)); - } - - // 푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다. - @ViewerAccess - @GetMapping("/broadcast/history") - public SuccessResponse> broadcastHistory( - @ModelAttribute PaginationRequest paginationRequest - ) { - PaginationResponse page = - pushBroadcastHistoryQueryService.findPage(paginationRequest); - return SuccessResponse.of(page); - } - - private static String buildMessage(PushBroadcastResult result) { - if (result.totalTargets() == 0) { - return "등록된 활성 기기가 없어 전송하지 않았습니다."; - } - if (!result.firebaseConfigured()) { - return "Firebase가 설정되지 않아 전송하지 않았습니다. (대상 기기 " + result.totalTargets() + "건)"; - } - return String.format( - "푸시 발송 완료. 대상 %d건, 성공 %d건, 실패 %d건", - result.totalTargets(), - result.successCount(), - result.failCount() - ); - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailBroadcastRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailBroadcastRequest.java deleted file mode 100644 index 47402e5e..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailBroadcastRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record EmailBroadcastRequest( - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") - String subject, - - @NotBlank(message = "본문은 필수입니다.") - @Size(max = 20000, message = "본문은 20000자를 초과할 수 없습니다.") - String body -) { - public EmailBroadcastRequest { - subject = subject != null ? subject.trim() : null; - body = body != null ? body.trim() : null; - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailTestSendRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailTestSendRequest.java deleted file mode 100644 index 65299c32..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/EmailTestSendRequest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record EmailTestSendRequest( - @NotBlank(message = "수신 이메일은 필수입니다.") - @Email(message = "수신 이메일 형식이 올바르지 않습니다.") - @Size(max = 320, message = "수신 이메일이 너무 깁니다.") - String to, - - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") - String subject, - - @NotBlank(message = "본문은 필수입니다.") - @Size(max = 20000, message = "본문은 20000자를 초과할 수 없습니다.") - String body -) { - public EmailTestSendRequest { - to = to != null ? to.trim() : null; - subject = subject != null ? subject.trim() : null; - body = body != null ? body.trim() : null; - } -} diff --git a/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java b/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java deleted file mode 100644 index cc63fc20..00000000 --- a/src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.souzip.adapter.webapi.admin.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record PushBroadcastRequest( - @NotBlank(message = "제목은 필수입니다.") - @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") - String title, - - @NotBlank(message = "내용은 필수입니다.") - @Size(max = 1000, message = "내용은 1000자를 초과할 수 없습니다.") - String body -) { - public PushBroadcastRequest { - title = title != null ? title.trim() : null; - body = body != null ? body.trim() : null; - } -} diff --git a/src/main/java/com/souzip/application/email/EmailBroadcastService.java b/src/main/java/com/souzip/application/email/EmailBroadcastService.java deleted file mode 100644 index 0a66817a..00000000 --- a/src/main/java/com/souzip/application/email/EmailBroadcastService.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.souzip.application.email; - -import com.souzip.application.email.dto.EmailBroadcastResult; -import com.souzip.application.email.provided.UserEmailFinder; -import com.souzip.shared.config.MailSmtpProperties; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; - -import java.util.List; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.mail.MailException; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EmailBroadcastService { - - private final UserEmailFinder userEmailFinder; - private final ObjectProvider javaMailSender; - private final MailSmtpProperties mailSmtpProperties; - - // 지정한 한 주소로만 발송합니다(연동·문구 검증용). - public EmailBroadcastResult sendTestToSingleAddress(String to, String subject, String body) { - JavaMailSender sender = javaMailSender.getIfAvailable(); - if (sender == null || !mailSmtpProperties.isEnabled()) { - log.warn("JavaMailSender 가 없거나 mail.smtp.enabled=false 입니다. 테스트 메일을 보내지 않습니다."); - return new EmailBroadcastResult(1, 0, 0, false); - } - String from = resolveFromAddress(); - try { - sendPlain(from, to, subject, body, sender); - return new EmailBroadcastResult(1, 1, 0, true); - } catch (MessagingException | MailException e) { - log.warn("테스트 이메일 발송 실패 toMasked={} error={}", maskEmail(to), e.getMessage()); - return new EmailBroadcastResult(1, 0, 1, true); - } - } - - // SMTP가 설정된 경우 전체(탈퇴 제외) 회원의 고유 이메일로 동일 제목·본문을 발송합니다. - public EmailBroadcastResult broadcastToAllMemberEmails(String subject, String body) { - List emails = userEmailFinder.findDistinctEmailsForActiveUsers(); - if (emails.isEmpty()) { - return new EmailBroadcastResult(0, 0, 0, isSmtpReady()); - } - JavaMailSender sender = javaMailSender.getIfAvailable(); - if (sender == null || !mailSmtpProperties.isEnabled()) { - log.warn("JavaMailSender 가 없거나 mail.smtp.enabled=false 입니다. 이메일 발송을 건너뜁니다. 대상={}건", emails.size()); - return new EmailBroadcastResult(emails.size(), 0, 0, false); - } - String from = resolveFromAddress(); - int success = 0; - int fail = 0; - for (String to : emails) { - try { - sendPlain(from, to, subject, body, sender); - success++; - } catch (MessagingException | MailException e) { - fail++; - log.warn("이메일 발송 실패(다음 주소로 계속) toDomain={} error={}", maskEmail(to), e.getMessage()); - } - } - return new EmailBroadcastResult(emails.size(), success, fail, true); - } - - private boolean isSmtpReady() { - return javaMailSender.getIfAvailable() != null && mailSmtpProperties.isEnabled(); - } - - private void sendPlain(String from, String to, String subject, String body, JavaMailSender sender) - throws MessagingException { - MimeMessage message = sender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); - helper.setFrom(from); - helper.setTo(to); - helper.setSubject(subject); - helper.setText(body, false); - sender.send(message); - } - - private String resolveFromAddress() { - if (StringUtils.hasText(mailSmtpProperties.getFrom())) { - return mailSmtpProperties.getFrom(); - } - if (StringUtils.hasText(mailSmtpProperties.getUsername())) { - return mailSmtpProperties.getUsername(); - } - throw new BusinessException(ErrorCode.EMAIL_SEND_FAILED, "발신 주소(mail.smtp.from 또는 username)가 없습니다."); - } - - private static String maskEmail(String email) { - if (email == null || !email.contains("@")) { - return "***"; - } - int at = email.indexOf('@'); - if (at <= 2) { - return "***@" + email.substring(at + 1); - } - return email.substring(0, 2) + "***@" + email.substring(at + 1); - } -} diff --git a/src/main/java/com/souzip/application/email/UserEmailQueryService.java b/src/main/java/com/souzip/application/email/UserEmailQueryService.java deleted file mode 100644 index 397be644..00000000 --- a/src/main/java/com/souzip/application/email/UserEmailQueryService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.souzip.application.email; - -import com.souzip.application.email.provided.UserEmailFinder; -import com.souzip.domain.user.repository.UserRepository; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Service -public class UserEmailQueryService implements UserEmailFinder { - - private final UserRepository userRepository; - - @Override - public List findDistinctEmailsForActiveUsers() { - return userRepository.findDistinctEmailsByActiveUsers().stream() - .filter(e -> e != null && !e.isBlank()) - .distinct() - .toList(); - } -} diff --git a/src/main/java/com/souzip/application/email/dto/EmailBroadcastResult.java b/src/main/java/com/souzip/application/email/dto/EmailBroadcastResult.java deleted file mode 100644 index 3a3eee44..00000000 --- a/src/main/java/com/souzip/application/email/dto/EmailBroadcastResult.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.application.email.dto; - -public record EmailBroadcastResult( - int totalTargets, - int successCount, - int failCount, - boolean smtpConfigured -) { -} diff --git a/src/main/java/com/souzip/application/email/provided/UserEmailFinder.java b/src/main/java/com/souzip/application/email/provided/UserEmailFinder.java deleted file mode 100644 index 5e815d0f..00000000 --- a/src/main/java/com/souzip/application/email/provided/UserEmailFinder.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.application.email.provided; - -import java.util.List; - -public interface UserEmailFinder { - - List findDistinctEmailsForActiveUsers(); -} diff --git a/src/main/java/com/souzip/application/notification/FcmNotificationService.java b/src/main/java/com/souzip/application/notification/FcmNotificationService.java deleted file mode 100644 index 14207f0d..00000000 --- a/src/main/java/com/souzip/application/notification/FcmNotificationService.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.souzip.application.notification; - -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.Notification; -import com.souzip.application.notification.dto.PushBroadcastResult; -import com.souzip.application.notification.provided.FcmTokenFinder; -import com.souzip.domain.notification.FcmToken; -import com.souzip.shared.exception.BusinessException; -import com.souzip.shared.exception.ErrorCode; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class FcmNotificationService { - - private final FcmTokenFinder fcmTokenFinder; - private final ObjectProvider firebaseMessaging; - - // 단일 기기 토큰으로 알림(제목·본문)을 전송합니다. - public void sendToToken(String registrationToken, String title, String body) { - sendToToken(registrationToken, title, body, Map.of()); - } - - // 데이터 페이로드를 포함해 전송합니다. - public void sendToToken(String registrationToken, String title, String body, Map data) { - FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); - if (messaging == null) { - log.warn("FirebaseMessaging 빈이 없습니다. firebase.enabled 와 credentials 를 확인하세요. 전송을 건너뜁니다."); - return; - } - try { - Notification notification = Notification.builder() - .setTitle(title) - .setBody(body) - .build(); - Message.Builder messageBuilder = Message.builder() - .setToken(registrationToken) - .setNotification(notification); - if (data != null && !data.isEmpty()) { - messageBuilder.putAllData(new HashMap<>(data)); - } - messaging.send(messageBuilder.build()); - } catch (FirebaseMessagingException e) { - log.error("FCM 전송 실패 tokenPrefix={} error={}", maskToken(registrationToken), e.getMessagingErrorCode(), e); - throw new BusinessException(ErrorCode.FCM_SEND_FAILED, e.getMessage()); - } - } - - // DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송은 트랜잭션 밖에서 수행합니다. - public void sendToUser(Long userId, String title, String body) { - List tokens = fcmTokenFinder.getActiveTokensByUserId(userId); - if (tokens.isEmpty()) { - return; - } - int successCount = 0; - int failCount = 0; - for (FcmToken token : tokens) { - try { - sendToToken(token.getToken(), title, body); - successCount++; - } catch (BusinessException e) { - failCount++; - log.warn( - "FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}", - userId, - token.getId(), - e.getErrorCode() - ); - } - } - if (failCount > 0 && successCount == 0) { - throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다."); - } - if (failCount > 0) { - log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount); - } - } - - // 마케팅 수신 동의한 사용자의 활성 토큰에만 푸시를 발송합니다. - public PushBroadcastResult broadcastToMarketingConsentedTokens(String title, String body) { - List tokens = fcmTokenFinder.getAllActiveTokensWithMarketingConsent(); - return doBroadcast(tokens, title, body); - } - - // 활성 토큰 전체에 푸시를 발송합니다. - public PushBroadcastResult broadcastToAllActiveTokens(String title, String body) { - List tokens = fcmTokenFinder.getAllActiveTokens(); - return doBroadcast(tokens, title, body); - } - - private PushBroadcastResult doBroadcast(List tokens, String title, String body) { - if (tokens.isEmpty()) { - return new PushBroadcastResult(0, 0, 0, true); - } - FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); - if (messaging == null) { - log.warn("FirebaseMessaging 빈이 없습니다. 브로드캐스트를 건너뜁니다. 대상 기기 수={}", tokens.size()); - return new PushBroadcastResult(tokens.size(), 0, 0, false); - } - int successCount = 0; - int failCount = 0; - for (FcmToken token : tokens) { - try { - sendToToken(token.getToken(), title, body); - successCount++; - } catch (BusinessException e) { - failCount++; - log.warn( - "FCM 브로드캐스트 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}", - token.getId(), - e.getErrorCode() - ); - } - } - return new PushBroadcastResult(tokens.size(), successCount, failCount, true); - } - - private static String maskToken(String token) { - if (token == null || token.length() < 12) { - return "***"; - } - return token.substring(0, 6) + "..." + token.substring(token.length() - 4); - } -} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java b/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java deleted file mode 100644 index 826006ea..00000000 --- a/src/main/java/com/souzip/application/notification/FcmTokenCommandService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.required.FcmTokenRepository; -import com.souzip.domain.notification.FcmToken; -import com.souzip.domain.notification.FcmTokenRegisterRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional -public class FcmTokenCommandService { - - private final FcmTokenRepository fcmTokenRepository; - - // 로그인 사용자 기준으로 FCM 토큰을 등록하거나 동일 디바이스·토큰에 맞춰 갱신합니다. - public FcmToken registerOrUpdate(Long userId, FcmTokenRegisterRequest request) { - return fcmTokenRepository.findByToken(request.fcmToken()) - .map(existing -> syncExistingToken(existing, userId, request)) - .orElseGet(() -> fcmTokenRepository.findByUserIdAndDeviceId(userId, request.deviceId()) - .map(existing -> updateExistingDeviceRow(existing, request, userId)) - .orElseGet(() -> createNew(userId, request))); - } - - private FcmToken syncExistingToken(FcmToken existing, Long userId, FcmTokenRegisterRequest request) { - existing.linkUser(userId); - existing.syncDeviceIdentity(request.deviceType(), request.deviceId()); - existing.updateDeviceInfo(request.deviceModel(), request.osVersion(), request.appVersion()); - existing.activate(); - return fcmTokenRepository.save(existing); - } - - private FcmToken updateExistingDeviceRow(FcmToken existing, FcmTokenRegisterRequest request, Long userId) { - existing.updateToken(request.fcmToken()); - existing.syncDeviceIdentity(request.deviceType(), request.deviceId()); - existing.updateDeviceInfo(request.deviceModel(), request.osVersion(), request.appVersion()); - existing.linkUser(userId); - existing.activate(); - return fcmTokenRepository.save(existing); - } - - private FcmToken createNew(Long userId, FcmTokenRegisterRequest request) { - FcmToken created = FcmToken.register(request); - created.linkUser(userId); - return fcmTokenRepository.save(created); - } - - // 해당 디바이스의 푸시 토큰을 비활성화합니다. - public void deactivateByDevice(Long userId, String deviceId) { - fcmTokenRepository.findByUserIdAndDeviceId(userId, deviceId) - .ifPresent(token -> { - token.deactivate(); - fcmTokenRepository.save(token); - }); - } -} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenNotFoundException.java b/src/main/java/com/souzip/application/notification/FcmTokenNotFoundException.java deleted file mode 100644 index ac3b093f..00000000 --- a/src/main/java/com/souzip/application/notification/FcmTokenNotFoundException.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.souzip.domain.notification; - -public class FcmTokenNotFoundException extends RuntimeException { - - public FcmTokenNotFoundException(String message) { - super(message); - } - - public static FcmTokenNotFoundException byDeviceId(String deviceId) { - return new FcmTokenNotFoundException( - String.format("디바이스 ID '%s'에 해당하는 FCM 토큰을 찾을 수 없습니다.", deviceId) - ); - } - - public static FcmTokenNotFoundException byToken(String token) { - return new FcmTokenNotFoundException( - String.format("FCM 토큰 '%s'를 찾을 수 없습니다.", token) - ); - } -} diff --git a/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java b/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java deleted file mode 100644 index 8bdf2fcd..00000000 --- a/src/main/java/com/souzip/application/notification/FcmTokenQueryService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.provided.FcmTokenFinder; -import com.souzip.application.notification.required.FcmTokenRepository; -import com.souzip.domain.notification.FcmToken; -import com.souzip.domain.notification.FcmTokenNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class FcmTokenQueryService implements FcmTokenFinder { - - private final FcmTokenRepository fcmTokenRepository; - - @Override - public FcmToken getByDeviceId(String deviceId) { - return fcmTokenRepository.findByDeviceId(deviceId) - .orElseThrow(() -> FcmTokenNotFoundException.byDeviceId(deviceId)); - } - - @Override - public FcmToken getByToken(String token) { - return fcmTokenRepository.findByToken(token) - .orElseThrow(() -> FcmTokenNotFoundException.byToken(token)); - } - - @Override - public List getActiveTokensByUserId(Long userId) { - return fcmTokenRepository.findByUserIdAndActiveTrue(userId); - } - - @Override - public List getAllActiveTokens() { - return fcmTokenRepository.findAllByActiveTrue(); - } - - @Override - public List getAllActiveTokensWithMarketingConsent() { - return fcmTokenRepository.findAllActiveWithMarketingConsent(); - } -} diff --git a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java deleted file mode 100644 index 49e02368..00000000 --- a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.dto.PushBroadcastResult; -import com.souzip.application.notification.required.PushBroadcastHistoryRepository; -import com.souzip.domain.notification.PushBroadcastHistory; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class PushBroadcastHistoryCommandService { - - private final PushBroadcastHistoryRepository pushBroadcastHistoryRepository; - - // 관리자 푸시 브로드캐스트 결과를 이력으로 저장합니다. - @Transactional - public void record(UUID adminId, String title, String body, PushBroadcastResult result) { - PushBroadcastHistory row = PushBroadcastHistory.record( - adminId, - title, - body, - result.totalTargets(), - result.successCount(), - result.failCount(), - result.firebaseConfigured() - ); - pushBroadcastHistoryRepository.save(row); - } -} diff --git a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java b/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java deleted file mode 100644 index 48df8f43..00000000 --- a/src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.souzip.application.notification; - -import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; -import com.souzip.application.notification.required.PushBroadcastHistoryRepository; -import com.souzip.domain.notification.PushBroadcastHistory; -import com.souzip.shared.common.dto.pagination.PaginationRequest; -import com.souzip.shared.common.dto.pagination.PaginationResponse; - -import java.util.List; - -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class PushBroadcastHistoryQueryService { - - private final PushBroadcastHistoryRepository pushBroadcastHistoryRepository; - - // 푸시 브로드캐스트 이력을 최신순 페이지로 조회합니다. - public PaginationResponse findPage(PaginationRequest paginationRequest) { - Page page = - pushBroadcastHistoryRepository.findAllByOrderByCreatedAtDesc(paginationRequest.toPageable()); - List content = page.getContent().stream() - .map(PushBroadcastHistoryResponse::from) - .toList(); - return PaginationResponse.of(page, content); - } -} diff --git a/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java b/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java deleted file mode 100644 index 49d644de..00000000 --- a/src/main/java/com/souzip/application/notification/dto/MarketingConsentStats.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.souzip.application.notification.dto; - -public record MarketingConsentStats( - long totalUsers, - long consentedUsers, - long declinedUsers -) { -} diff --git a/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java b/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java deleted file mode 100644 index 958536ac..00000000 --- a/src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.souzip.application.notification.dto; - -import com.souzip.domain.notification.PushBroadcastHistory; -import java.time.LocalDateTime; -import java.util.UUID; - -public record PushBroadcastHistoryResponse( - Long id, - UUID adminId, - String title, - String body, - int totalTargets, - int successCount, - int failCount, - boolean firebaseConfigured, - LocalDateTime createdAt -) { - - public static PushBroadcastHistoryResponse from(PushBroadcastHistory entity) { - return new PushBroadcastHistoryResponse( - entity.getId(), - entity.getAdminId(), - entity.getTitle(), - entity.getBody(), - entity.getTotalTargets(), - entity.getSuccessCount(), - entity.getFailCount(), - entity.isFirebaseConfigured(), - entity.getCreatedAt() - ); - } -} diff --git a/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java b/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java deleted file mode 100644 index 94c01244..00000000 --- a/src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.application.notification.dto; - -public record PushBroadcastResult( - int totalTargets, - int successCount, - int failCount, - boolean firebaseConfigured -) { -} diff --git a/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java b/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java deleted file mode 100644 index 99895949..00000000 --- a/src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.souzip.application.notification.provided; - -import com.souzip.domain.notification.FcmToken; -import java.util.List; - -public interface FcmTokenFinder { - - FcmToken getByDeviceId(String deviceId); - - FcmToken getByToken(String token); - - List getActiveTokensByUserId(Long userId); - - List getAllActiveTokens(); - - List getAllActiveTokensWithMarketingConsent(); -} diff --git a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java b/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java deleted file mode 100644 index 177aa1e9..00000000 --- a/src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.souzip.application.notification.required; - -import com.souzip.domain.notification.FcmToken; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; - -public interface FcmTokenRepository extends Repository { - - FcmToken save(FcmToken fcmToken); - - Optional findByToken(String token); - - Optional findByDeviceId(String deviceId); - - Optional findByUserIdAndDeviceId(Long userId, String deviceId); - - List findByUserIdAndActiveTrue(Long userId); - - List findAllByActiveTrue(); - - @Query("SELECT ft FROM FcmToken ft WHERE ft.active = true AND ft.userId IN " + - "(SELECT ua.user.id FROM UserAgreement ua WHERE ua.marketingConsent = true)") - List findAllActiveWithMarketingConsent(); - - void delete(FcmToken fcmToken); - - void deleteByDeviceId(String deviceId); - - boolean existsByToken(String token); - - boolean existsByDeviceId(String deviceId); -} diff --git a/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java b/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java deleted file mode 100644 index d9f4397b..00000000 --- a/src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.souzip.application.notification.required; - -import com.souzip.domain.notification.PushBroadcastHistory; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.repository.Repository; - -public interface PushBroadcastHistoryRepository extends Repository { - - PushBroadcastHistory save(PushBroadcastHistory entity); - - Page findAllByOrderByCreatedAtDesc(Pageable pageable); -} diff --git a/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java b/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java index c2bdb9f0..c0c666f0 100644 --- a/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java +++ b/src/main/java/com/souzip/auth/adapter/security/config/SecurityConfig.java @@ -59,7 +59,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/souvenirs/nearby", "/api/discovery/**", "/actuator/health", - "/actuator/prometheus", "/api/admin/auth/login", "/api/admin/auth/refresh", "/docs/**" diff --git a/src/main/java/com/souzip/domain/country/service/CountryService.java b/src/main/java/com/souzip/domain/country/service/CountryService.java index 75ccd41f..2d9866c4 100644 --- a/src/main/java/com/souzip/domain/country/service/CountryService.java +++ b/src/main/java/com/souzip/domain/country/service/CountryService.java @@ -38,7 +38,6 @@ public CountryListResponse getAllCountries() { public CountryResponseDto getCountryByCode(String code) { Country country = findCountryByCodeOrThrow(code); - return CountryResponseDto.from(country); } diff --git a/src/main/java/com/souzip/domain/notification/DeviceType.java b/src/main/java/com/souzip/domain/notification/DeviceType.java deleted file mode 100644 index f75060a6..00000000 --- a/src/main/java/com/souzip/domain/notification/DeviceType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.souzip.domain.notification; - -public enum DeviceType { - ANDROID, - IOS -} diff --git a/src/main/java/com/souzip/domain/notification/FcmToken.java b/src/main/java/com/souzip/domain/notification/FcmToken.java deleted file mode 100644 index 3268edf5..00000000 --- a/src/main/java/com/souzip/domain/notification/FcmToken.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.souzip.domain.notification; - -import com.souzip.shared.domain.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -import static java.util.Objects.requireNonNull; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class FcmToken extends BaseEntity { - - private Long userId; - - private String token; - - private DeviceType deviceType; - - private String deviceId; - - private String deviceModel; - - private String osVersion; - - private String appVersion; - - private boolean active; - - private LocalDateTime lastUsedAt; - - public static FcmToken register(FcmTokenRegisterRequest request) { - FcmToken fcmToken = new FcmToken(); - - fcmToken.token = requireNonNull(request.fcmToken(), "FCM 토큰은 필수입니다."); - fcmToken.deviceType = requireNonNull(request.deviceType(), "디바이스 타입은 필수입니다."); - fcmToken.deviceId = requireNonNull(request.deviceId(), "디바이스 ID는 필수입니다."); - fcmToken.deviceModel = request.deviceModel(); - fcmToken.osVersion = request.osVersion(); - fcmToken.appVersion = request.appVersion(); - fcmToken.active = true; - fcmToken.lastUsedAt = LocalDateTime.now(); - - return fcmToken; - } - - public void linkUser(Long userId) { - this.userId = requireNonNull(userId, "사용자 ID는 필수입니다."); - this.lastUsedAt = LocalDateTime.now(); - } - - public void unlinkUser() { - this.userId = null; - this.lastUsedAt = LocalDateTime.now(); - } - - public void updateToken(String token) { - this.token = requireNonNull(token, "FCM 토큰은 필수입니다."); - this.lastUsedAt = LocalDateTime.now(); - } - - public void updateDeviceInfo(String deviceModel, String osVersion, String appVersion) { - this.deviceModel = deviceModel; - this.osVersion = osVersion; - this.appVersion = appVersion; - this.lastUsedAt = LocalDateTime.now(); - } - - public void syncDeviceIdentity(DeviceType deviceType, String deviceId) { - this.deviceType = requireNonNull(deviceType, "디바이스 타입은 필수입니다."); - this.deviceId = requireNonNull(deviceId, "디바이스 ID는 필수입니다."); - this.lastUsedAt = LocalDateTime.now(); - } - - public void deactivate() { - this.active = false; - } - - public void activate() { - this.active = true; - this.lastUsedAt = LocalDateTime.now(); - } - - public boolean isActive() { - return this.active; - } - - public boolean isLoggedIn() { - return this.userId != null; - } -} diff --git a/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java b/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java deleted file mode 100644 index dd8cc355..00000000 --- a/src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.souzip.domain.notification; - -import static java.util.Objects.requireNonNull; - -public record FcmTokenRegisterRequest( - String fcmToken, - DeviceType deviceType, - String deviceId, - String deviceModel, - String osVersion, - String appVersion -) { - private static final int MAX_FCM_TOKEN_LENGTH = 255; - private static final int MAX_DEVICE_ID_LENGTH = 100; - private static final int MAX_DEVICE_MODEL_LENGTH = 100; - private static final int MAX_OS_VERSION_LENGTH = 50; - private static final int MAX_APP_VERSION_LENGTH = 50; - - public FcmTokenRegisterRequest { - fcmToken = validateFcmToken(fcmToken); - requireNonNull(deviceType, "디바이스 타입은 필수입니다."); - deviceId = validateDeviceId(deviceId); - deviceModel = validateOptionalField(deviceModel, MAX_DEVICE_MODEL_LENGTH, "디바이스 모델"); - osVersion = validateOptionalField(osVersion, MAX_OS_VERSION_LENGTH, "OS 버전"); - appVersion = validateOptionalField(appVersion, MAX_APP_VERSION_LENGTH, "앱 버전"); - } - - public static FcmTokenRegisterRequest of( - String fcmToken, - DeviceType deviceType, - String deviceId, - String deviceModel, - String osVersion, - String appVersion - ) { - return new FcmTokenRegisterRequest( - fcmToken, - deviceType, - deviceId, - deviceModel, - osVersion, - appVersion - ); - } - - private static String validateFcmToken(String fcmToken) { - requireNonNull(fcmToken, "FCM 토큰은 필수입니다."); - - String trimmed = fcmToken.trim(); - if (trimmed.isEmpty()) { - throw new IllegalArgumentException("FCM 토큰은 비어있을 수 없습니다."); - } - - if (trimmed.length() > MAX_FCM_TOKEN_LENGTH) { - throw new IllegalArgumentException( - String.format("FCM 토큰은 %d자를 초과할 수 없습니다.", MAX_FCM_TOKEN_LENGTH) - ); - } - - return trimmed; - } - - private static String validateDeviceId(String deviceId) { - requireNonNull(deviceId, "디바이스 ID는 필수입니다."); - - String trimmed = deviceId.trim(); - if (trimmed.isEmpty()) { - throw new IllegalArgumentException("디바이스 ID는 비어있을 수 없습니다."); - } - - if (trimmed.length() > MAX_DEVICE_ID_LENGTH) { - throw new IllegalArgumentException( - String.format("디바이스 ID는 %d자를 초과할 수 없습니다.", MAX_DEVICE_ID_LENGTH) - ); - } - - return trimmed; - } - - private static String validateOptionalField(String value, int maxLength, String fieldName) { - if (value == null || value.trim().isEmpty()) { - return null; - } - - String trimmed = value.trim(); - if (trimmed.length() > maxLength) { - throw new IllegalArgumentException( - String.format("%s는 %d자를 초과할 수 없습니다.", fieldName, maxLength) - ); - } - - return trimmed; - } -} diff --git a/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java b/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java deleted file mode 100644 index c8d7e98d..00000000 --- a/src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.souzip.domain.notification; - -import com.souzip.shared.domain.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -import static java.util.Objects.requireNonNull; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class PushBroadcastHistory extends BaseEntity { - - private UUID adminId; - - private String title; - - private String body; - - private int totalTargets; - - private int successCount; - - private int failCount; - - private boolean firebaseConfigured; - - public static PushBroadcastHistory record( - UUID adminId, - String title, - String body, - int totalTargets, - int successCount, - int failCount, - boolean firebaseConfigured - ) { - PushBroadcastHistory row = new PushBroadcastHistory(); - row.adminId = requireNonNull(adminId, "관리자 ID는 필수입니다."); - row.title = requireNonNull(title, "제목은 필수입니다."); - row.body = requireNonNull(body, "본문은 필수입니다."); - row.totalTargets = totalTargets; - row.successCount = successCount; - row.failCount = failCount; - row.firebaseConfigured = firebaseConfigured; - return row; - } -} diff --git a/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java b/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java deleted file mode 100644 index 14c51ae8..00000000 --- a/src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.souzip.domain.notification.controller; - -import com.souzip.application.notification.FcmTokenCommandService; -import com.souzip.auth.adapter.security.annotation.CurrentUserId; -import com.souzip.auth.adapter.security.annotation.RequireAuth; -import com.souzip.domain.notification.FcmToken; -import com.souzip.domain.notification.FcmTokenRegisterRequest; -import com.souzip.domain.notification.dto.FcmTokenResponse; -import com.souzip.shared.common.dto.SuccessResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RequestMapping("/api/users/me/fcm-tokens") -@RestController -public class FcmTokenController { - - private final FcmTokenCommandService fcmTokenCommandService; - - // 로그인 사용자의 FCM 디바이스 토큰을 등록하거나 갱신합니다. - @PostMapping - @RequireAuth - public SuccessResponse register( - @CurrentUserId Long userId, - @Valid @RequestBody FcmTokenRegisterRequest request - ) { - FcmToken saved = fcmTokenCommandService.registerOrUpdate(userId, request); - return SuccessResponse.of(FcmTokenResponse.from(saved), "FCM 토큰이 등록되었습니다."); - } - - // 로그아웃 등으로 해당 디바이스 토큰을 비활성화합니다. - @DeleteMapping - @RequireAuth - public SuccessResponse deactivate( - @CurrentUserId Long userId, - @RequestParam String deviceId - ) { - fcmTokenCommandService.deactivateByDevice(userId, deviceId); - return SuccessResponse.of("FCM 토큰이 비활성화되었습니다."); - } -} diff --git a/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java b/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java deleted file mode 100644 index 8807399f..00000000 --- a/src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.souzip.domain.notification.dto; - -import com.souzip.domain.notification.FcmToken; - -public record FcmTokenResponse( - Long id, - String deviceId, - boolean active -) { - - public static FcmTokenResponse from(FcmToken token) { - return new FcmTokenResponse(token.getId(), token.getDeviceId(), token.isActive()); - } -} diff --git a/src/main/java/com/souzip/shared/config/AppConfig.java b/src/main/java/com/souzip/shared/config/AppConfig.java index 5db0dfbe..e583c381 100644 --- a/src/main/java/com/souzip/shared/config/AppConfig.java +++ b/src/main/java/com/souzip/shared/config/AppConfig.java @@ -11,8 +11,7 @@ ObjectStorageProperties.class, ClovaStudioProperties.class, AppleMigrationConfig.class, - CorsProperties.class, - MailSmtpProperties.class + CorsProperties.class }) public class AppConfig { } diff --git a/src/main/java/com/souzip/shared/config/FirebaseConfig.java b/src/main/java/com/souzip/shared/config/FirebaseConfig.java deleted file mode 100644 index 4eccec0d..00000000 --- a/src/main/java/com/souzip/shared/config/FirebaseConfig.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.souzip.shared.config; - -import com.google.auth.oauth2.GoogleCredentials; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; -import com.google.firebase.messaging.FirebaseMessaging; - -import java.io.FileInputStream; -import java.io.IOException; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.util.StringUtils; - -@Configuration -@RequiredArgsConstructor -@ConditionalOnProperty(name = "firebase.enabled", havingValue = "true") -public class FirebaseConfig { - - private final FirebaseProperties firebaseProperties; - - @Bean - public FirebaseApp firebaseApp() throws IOException { - if (!StringUtils.hasText(firebaseProperties.getCredentialsPath())) { - throw new IllegalStateException("firebase.enabled=true 일 때 firebase.credentials-path 가 필요합니다."); - } - try (FileInputStream serviceAccount = new FileInputStream(firebaseProperties.getCredentialsPath())) { - FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(serviceAccount)) - .build(); - if (FirebaseApp.getApps().isEmpty()) { - return FirebaseApp.initializeApp(options); - } - return FirebaseApp.getInstance(); - } - } - - @Bean - public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { - return FirebaseMessaging.getInstance(firebaseApp); - } -} diff --git a/src/main/java/com/souzip/shared/config/FirebaseProperties.java b/src/main/java/com/souzip/shared/config/FirebaseProperties.java deleted file mode 100644 index d5c57fa7..00000000 --- a/src/main/java/com/souzip/shared/config/FirebaseProperties.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.souzip.shared.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@Setter -@ConfigurationProperties(prefix = "firebase") -public class FirebaseProperties { - - private boolean enabled = false; - - private String credentialsPath = ""; -} diff --git a/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java b/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java deleted file mode 100644 index 53ce0cbd..00000000 --- a/src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.souzip.shared.config; - -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties(FirebaseProperties.class) -public class FirebasePropertiesConfiguration { -} diff --git a/src/main/java/com/souzip/shared/config/MailSmtpConfig.java b/src/main/java/com/souzip/shared/config/MailSmtpConfig.java deleted file mode 100644 index 4a9de9d9..00000000 --- a/src/main/java/com/souzip/shared/config/MailSmtpConfig.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.souzip.shared.config; - -import java.util.Properties; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.JavaMailSenderImpl; -import org.springframework.util.StringUtils; - -@Configuration -@RequiredArgsConstructor -@ConditionalOnProperty(name = "mail.smtp.enabled", havingValue = "true") -public class MailSmtpConfig { - - private final MailSmtpProperties mailSmtpProperties; - - @Bean - public JavaMailSender javaMailSender() { - if (!StringUtils.hasText(mailSmtpProperties.getUsername()) - || !StringUtils.hasText(mailSmtpProperties.getPassword())) { - throw new IllegalStateException( - "mail.smtp.enabled=true 일 때 mail.smtp.username 과 mail.smtp.password 가 필요합니다." - ); - } - JavaMailSenderImpl sender = new JavaMailSenderImpl(); - sender.setHost(mailSmtpProperties.getHost()); - sender.setPort(mailSmtpProperties.getPort()); - sender.setUsername(mailSmtpProperties.getUsername()); - sender.setPassword(mailSmtpProperties.getPassword()); - - Properties props = sender.getJavaMailProperties(); - props.put("mail.transport.protocol", "smtp"); - props.put("mail.smtp.auth", "true"); - props.put("mail.smtp.starttls.enable", "true"); - props.put("mail.smtp.starttls.required", "true"); - props.put("mail.debug", "false"); - return sender; - } -} diff --git a/src/main/java/com/souzip/shared/config/MailSmtpProperties.java b/src/main/java/com/souzip/shared/config/MailSmtpProperties.java deleted file mode 100644 index 5767db37..00000000 --- a/src/main/java/com/souzip/shared/config/MailSmtpProperties.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.souzip.shared.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@Setter -@ConfigurationProperties(prefix = "mail.smtp") -public class MailSmtpProperties { - - private boolean enabled = false; - private String host = "smtp.gmail.com"; - private int port = 587; - private String username = ""; - private String password = ""; - private String from = ""; -} diff --git a/src/main/java/com/souzip/shared/exception/ErrorCode.java b/src/main/java/com/souzip/shared/exception/ErrorCode.java index 25db9642..00cbc984 100644 --- a/src/main/java/com/souzip/shared/exception/ErrorCode.java +++ b/src/main/java/com/souzip/shared/exception/ErrorCode.java @@ -49,9 +49,11 @@ public enum ErrorCode implements BaseErrorCode { AI_RECOMMENDATION_NOT_READY(HttpStatus.BAD_REQUEST, "추천 시스템을 위해 기념품 업로드 이력이 있어야 합니다."), - FCM_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 푸시 전송에 실패했습니다."), - EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송에 실패했습니다."); + APPLE_MIGRATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple 마이그레이션 준비 중 오류가 발생했습니다."), + APPLE_TRANSFER_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "App Transfer가 아직 완료되지 않았습니다."), + NO_APPLE_USERS_FOUND(HttpStatus.NOT_FOUND, "마이그레이션할 Apple 사용자가 없습니다.") + ; private final HttpStatus status; private final String message; -} \ No newline at end of file +} diff --git a/src/main/resources/.env.example b/src/main/resources/.env.example index aa3ad102..b2ab1dd4 100644 --- a/src/main/resources/.env.example +++ b/src/main/resources/.env.example @@ -25,13 +25,3 @@ CLOVA_STUDIO_API_URL= ADMIN_INITIAL_USERNAME= ADMIN_INITIAL_PASSWORD= - -FIREBASE_ENABLED= -FIREBASE_CREDENTIALS_PATH= - -MAIL_SMTP_ENABLED= -MAIL_SMTP_HOST= -MAIL_SMTP_PORT= -MAIL_SMTP_USERNAME= -MAIL_SMTP_PASSWORD= -MAIL_SMTP_FROM= diff --git a/src/main/resources/META-INF/orm.xml b/src/main/resources/META-INF/orm.xml index a690286c..dd65e9f9 100644 --- a/src/main/resources/META-INF/orm.xml +++ b/src/main/resources/META-INF/orm.xml @@ -109,75 +109,6 @@ - - - - token - - - user_id - device_id - -
- - - - - - - - - - STRING - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 9d28d17c..ce23510a 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -41,19 +41,3 @@ management: endpoint: health: show-details: when-authorized - probes: - enabled: true - metrics: - tags: - application: ${spring.application.name} - environment: dev - distribution: - percentiles-histogram: - http.server.requests: true - prometheus: - metrics: - export: - enabled: true - -logging: - config: classpath:logback-dev.xml diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 99598bbe..6f27257b 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -22,19 +22,7 @@ management: endpoints: web: exposure: - include: "*" - base-path: /actuator + include: "loggers,env" endpoint: health: show-details: always - metrics: - tags: - application: ${spring.application.name} - environment: local - prometheus: - metrics: - export: - enabled: true - -logging: - config: classpath:logback-local.xml diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 7b6b401f..41b7619a 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -16,7 +16,7 @@ spring: open-in-view: false properties: hibernate: - format_sql: false + format_sql: true default_schema: public flyway: @@ -36,25 +36,7 @@ management: endpoints: web: exposure: - include: "health,metrics,prometheus" + include: "health,info,metrics,prometheus" endpoint: health: - show-details: never - probes: - enabled: true - metrics: - tags: - application: ${spring.application.name} - environment: prod - distribution: - percentiles-histogram: - http.server.requests: true - percentiles: - http.server.requests: 0.95, 0.99 - prometheus: - metrics: - export: - enabled: true - -logging: - config: classpath:logback-prod.xml + show-details: when-authorized diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5359718a..19b728c0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -35,6 +35,13 @@ management: tags: application: ${spring.application.name} +logging: + pattern: + console: "[%d{yyyy-MM-dd HH:mm:ss}] [%X{traceId}] [%-5level] %logger{36} - %msg%n" + level: + root: INFO + com.souzip.api: DEBUG + jwt: expiration: 3600000 refresh-expiration: 2592000000 @@ -80,16 +87,3 @@ admin: initial: username: ${ADMIN_INITIAL_USERNAME} password: ${ADMIN_INITIAL_PASSWORD} - -firebase: - enabled: ${FIREBASE_ENABLED:false} - credentials-path: ${FIREBASE_CREDENTIALS_PATH:} - -mail: - smtp: - enabled: ${MAIL_SMTP_ENABLED:false} - host: ${MAIL_SMTP_HOST:smtp.gmail.com} - port: ${MAIL_SMTP_PORT:587} - username: ${MAIL_SMTP_USERNAME:} - password: ${MAIL_SMTP_PASSWORD:} - from: ${MAIL_SMTP_FROM:} diff --git a/src/main/resources/db/migration/V14__create_fcm_token_tablel.sql b/src/main/resources/db/migration/V14__create_fcm_token_tablel.sql deleted file mode 100644 index 06c6bc02..00000000 --- a/src/main/resources/db/migration/V14__create_fcm_token_tablel.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE fcm_tokens ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT, - token VARCHAR(255) NOT NULL, - device_type VARCHAR(20) NOT NULL, - device_id VARCHAR(100) NOT NULL, - device_model VARCHAR(100), - os_version VARCHAR(50), - app_version VARCHAR(50), - is_active BOOLEAN DEFAULT true NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - last_used_at TIMESTAMP -); - --- 인덱스 생성 -CREATE INDEX idx_fcm_tokens_user_id ON fcm_tokens(user_id) WHERE user_id IS NOT NULL; -CREATE INDEX idx_fcm_tokens_device_id ON fcm_tokens(device_id); -CREATE INDEX idx_fcm_tokens_device_type ON fcm_tokens(device_type); -CREATE INDEX idx_fcm_tokens_is_active ON fcm_tokens(is_active); -CREATE UNIQUE INDEX idx_fcm_tokens_token ON fcm_tokens(token); -CREATE UNIQUE INDEX idx_fcm_tokens_user_device ON fcm_tokens(user_id, device_id) - WHERE user_id IS NOT NULL; diff --git a/src/main/resources/db/migration/V16__create_push_broadcast_history.sql b/src/main/resources/db/migration/V16__create_push_broadcast_history.sql deleted file mode 100644 index d1d791f4..00000000 --- a/src/main/resources/db/migration/V16__create_push_broadcast_history.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE push_broadcast_histories ( - id BIGSERIAL PRIMARY KEY, - admin_id UUID NOT NULL, - title VARCHAR(200) NOT NULL, - body VARCHAR(1000) NOT NULL, - total_targets INT NOT NULL, - success_count INT NOT NULL, - fail_count INT NOT NULL, - firebase_configured BOOLEAN NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL -); - -CREATE INDEX idx_push_broadcast_histories_created_at ON push_broadcast_histories (created_at DESC); diff --git a/src/main/resources/logback-dev.xml b/src/main/resources/logback-dev.xml deleted file mode 100644 index 0a0e8336..00000000 --- a/src/main/resources/logback-dev.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - ${LOG_FILE}.log - - INFO - - - ${LOG_FILE}.%d{yyyy-MM-dd}.log - 30 - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - ${LOG_PATH}/souzip-api-debug.log - - INFO - DENY - ACCEPT - - - ${LOG_PATH}/souzip-api-debug.%d{yyyy-MM-dd}.log - 3 - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - ${LOG_PATH}/souzip-api-error.log - - ERROR - - - ${LOG_PATH}/souzip-api-error.%d{yyyy-MM-dd}.log - 30 - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/logback-local.xml b/src/main/resources/logback-local.xml deleted file mode 100644 index 29e4e170..00000000 --- a/src/main/resources/logback-local.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %cyan(%logger{36}) - %msg%n - - - - - - - - - - - - - - diff --git a/src/main/resources/logback-prod.xml b/src/main/resources/logback-prod.xml deleted file mode 100644 index a1b9d3ac..00000000 --- a/src/main/resources/logback-prod.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - ${LOG_FILE}.log - - INFO - - - ${LOG_FILE}.%d{yyyy-MM-dd}.log - 60 - - - - - - - ${LOG_PATH}/souzip-api-warn.log - - WARN - - - ${LOG_PATH}/souzip-api-warn.%d{yyyy-MM-dd}.log - 90 - - - - - - - ${LOG_PATH}/souzip-api-error.log - - ERROR - - - ${LOG_PATH}/souzip-api-error.%d{yyyy-MM-dd}.log - 180 - - - - - - - - - - - - - - - - diff --git a/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java b/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java deleted file mode 100644 index 615f0474..00000000 --- a/src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.souzip.domain.notification; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.*; - -class FcmTokenRegisterRequestTest { - - @DisplayName("정상적으로 요청 객체를 생성한다") - @Test - void create_success() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - "device-id-123", - "Galaxy S23", - "Android 14", - "1.0.0" - ); - - // then - assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); - assertThat(request.deviceType()).isEqualTo(DeviceType.ANDROID); - assertThat(request.deviceId()).isEqualTo("device-id-123"); - assertThat(request.deviceModel()).isEqualTo("Galaxy S23"); - assertThat(request.osVersion()).isEqualTo("Android 14"); - assertThat(request.appVersion()).isEqualTo("1.0.0"); - } - - @DisplayName("선택 필드 없이 요청 객체를 생성한다") - @Test - void create_withoutOptionalFields() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.IOS, - "device-id-456", - null, - null, - null - ); - - // then - assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); - assertThat(request.deviceType()).isEqualTo(DeviceType.IOS); - assertThat(request.deviceModel()).isNull(); - assertThat(request.osVersion()).isNull(); - assertThat(request.appVersion()).isNull(); - } - - @DisplayName("FCM 토큰이 null이면 예외가 발생한다") - @Test - void create_withNullFcmToken_throwsException() { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - null, - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - )).isInstanceOf(NullPointerException.class) - .hasMessage("FCM 토큰은 필수입니다."); - } - - @DisplayName("FCM 토큰이 비어있으면 예외가 발생한다") - @ValueSource(strings = {"", " ", " "}) - @ParameterizedTest - void create_withEmptyFcmToken_throwsException(String emptyToken) { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - emptyToken, - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessage("FCM 토큰은 비어있을 수 없습니다."); - } - - @DisplayName("FCM 토큰이 최대 길이를 초과하면 예외가 발생한다") - @Test - void create_withTooLongFcmToken_throwsException() { - // given - String tooLongToken = "a".repeat(256); - - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - tooLongToken, - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("FCM 토큰은 255자를 초과할 수 없습니다"); - } - - @DisplayName("디바이스 타입이 null이면 예외가 발생한다") - @Test - void create_withNullDeviceType_throwsException() { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - null, - "device-id-123", - null, - null, - null - )).isInstanceOf(NullPointerException.class) - .hasMessage("디바이스 타입은 필수입니다."); - } - - @DisplayName("디바이스 ID가 null이면 예외가 발생한다") - @Test - void create_withNullDeviceId_throwsException() { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - null, - null, - null, - null - )).isInstanceOf(NullPointerException.class) - .hasMessage("디바이스 ID는 필수입니다."); - } - - @DisplayName("디바이스 ID가 비어있으면 예외가 발생한다") - @ValueSource(strings = {"", " ", " "}) - @ParameterizedTest - void create_withEmptyDeviceId_throwsException(String emptyDeviceId) { - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - emptyDeviceId, - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessage("디바이스 ID는 비어있을 수 없습니다."); - } - - @DisplayName("디바이스 ID가 최대 길이를 초과하면 예외가 발생한다") - @Test - void create_withTooLongDeviceId_throwsException() { - // given - String tooLongDeviceId = "a".repeat(101); - - // when & then - assertThatThrownBy(() -> FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - tooLongDeviceId, - null, - null, - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("디바이스 ID는 100자를 초과할 수 없습니다"); - } - - @DisplayName("FCM 토큰 앞뒤 공백을 제거한다") - @Test - void create_trimsFcmToken() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - " fcm-token-123 ", - DeviceType.ANDROID, - "device-id-123", - null, - null, - null - ); - - // then - assertThat(request.fcmToken()).isEqualTo("fcm-token-123"); - } - - @DisplayName("디바이스 ID 앞뒤 공백을 제거한다") - @Test - void create_trimsDeviceId() { - // when - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - " device-id-123 ", - null, - null, - null - ); - - // then - assertThat(request.deviceId()).isEqualTo("device-id-123"); - } -} diff --git a/src/test/java/com/souzip/domain/notification/FcmTokenTest.java b/src/test/java/com/souzip/domain/notification/FcmTokenTest.java deleted file mode 100644 index 90dc5cbd..00000000 --- a/src/test/java/com/souzip/domain/notification/FcmTokenTest.java +++ /dev/null @@ -1,210 +0,0 @@ -package com.souzip.domain.notification; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.*; - -class FcmTokenTest { - - @DisplayName("FCM 토큰을 정상적으로 등록한다") - @Test - void register_success() { - // given - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - "device-id-123", - "Galaxy S23", - "Android 14", - "1.0.0" - ); - - // when - FcmToken fcmToken = FcmToken.register(request); - - // then - assertThat(fcmToken.getToken()).isEqualTo("fcm-token-123"); - assertThat(fcmToken.getDeviceType()).isEqualTo(DeviceType.ANDROID); - assertThat(fcmToken.getDeviceId()).isEqualTo("device-id-123"); - assertThat(fcmToken.getDeviceModel()).isEqualTo("Galaxy S23"); - assertThat(fcmToken.getOsVersion()).isEqualTo("Android 14"); - assertThat(fcmToken.getAppVersion()).isEqualTo("1.0.0"); - assertThat(fcmToken.isActive()).isTrue(); - assertThat(fcmToken.getUserId()).isNull(); - assertThat(fcmToken.getLastUsedAt()).isNotNull(); - } - - @DisplayName("선택 필드 없이 FCM 토큰을 등록한다") - @Test - void register_withoutOptionalFields() { - // given - FcmTokenRegisterRequest request = FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.IOS, - "device-id-456", - null, - null, - null - ); - - // when - FcmToken fcmToken = FcmToken.register(request); - - // then - assertThat(fcmToken.getToken()).isEqualTo("fcm-token-123"); - assertThat(fcmToken.getDeviceType()).isEqualTo(DeviceType.IOS); - assertThat(fcmToken.getDeviceModel()).isNull(); - assertThat(fcmToken.getOsVersion()).isNull(); - assertThat(fcmToken.getAppVersion()).isNull(); - } - - @DisplayName("로그인 시 사용자 ID를 연결한다") - @Test - void linkUser_success() { - // given - FcmToken fcmToken = createFcmToken(); - Long userId = 1L; - - // when - fcmToken.linkUser(userId); - - // then - assertThat(fcmToken.getUserId()).isEqualTo(userId); - assertThat(fcmToken.isLoggedIn()).isTrue(); - assertThat(fcmToken.getLastUsedAt()).isNotNull(); - } - - @Test - @DisplayName("null 사용자 ID로 연결하면 예외가 발생한다") - void linkUser_withNull_throwsException() { - // given - FcmToken fcmToken = createFcmToken(); - - // when & then - assertThatThrownBy(() -> fcmToken.linkUser(null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("사용자 ID는 필수입니다."); - } - - @DisplayName("로그아웃 시 사용자 ID 연결을 해제한다") - @Test - void unlinkUser_success() { - // given - FcmToken fcmToken = createFcmToken(); - fcmToken.linkUser(1L); - - // when - fcmToken.unlinkUser(); - - // then - assertThat(fcmToken.getUserId()).isNull(); - assertThat(fcmToken.isLoggedIn()).isFalse(); - } - - @DisplayName("FCM 토큰을 갱신한다") - @Test - void updateToken_success() { - // given - FcmToken fcmToken = createFcmToken(); - String newToken = "new-fcm-token"; - LocalDateTime beforeUpdate = fcmToken.getLastUsedAt(); - - // when - fcmToken.updateToken(newToken); - - // then - assertThat(fcmToken.getToken()).isEqualTo(newToken); - assertThat(fcmToken.getLastUsedAt()).isAfter(beforeUpdate); - } - - @DisplayName("null 토큰으로 갱신하면 예외가 발생한다") - @Test - void updateToken_withNull_throwsException() { - // given - FcmToken fcmToken = createFcmToken(); - - // when & then - assertThatThrownBy(() -> fcmToken.updateToken(null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("FCM 토큰은 필수입니다."); - } - - @DisplayName("디바이스 정보를 업데이트한다") - @Test - void updateDeviceInfo_success() { - // given - FcmToken fcmToken = createFcmToken(); - - // when - fcmToken.updateDeviceInfo("iPhone 15 Pro", "iOS 17.2", "1.1.0"); - - // then - assertThat(fcmToken.getDeviceModel()).isEqualTo("iPhone 15 Pro"); - assertThat(fcmToken.getOsVersion()).isEqualTo("iOS 17.2"); - assertThat(fcmToken.getAppVersion()).isEqualTo("1.1.0"); - } - - @DisplayName("토큰을 비활성화한다") - @Test - void deactivate_success() { - // given - FcmToken fcmToken = createFcmToken(); - - // when - fcmToken.deactivate(); - - // then - assertThat(fcmToken.isActive()).isFalse(); - } - - @DisplayName("토큰을 활성화한다") - @Test - void activate_success() { - // given - FcmToken fcmToken = createFcmToken(); - fcmToken.deactivate(); - - // when - fcmToken.activate(); - - // then - assertThat(fcmToken.isActive()).isTrue(); - } - - @DisplayName("비로그인 상태를 확인한다") - @Test - void isLoggedIn_whenNotLoggedIn_returnsFalse() { - // given - FcmToken fcmToken = createFcmToken(); - - // when & then - assertThat(fcmToken.isLoggedIn()).isFalse(); - } - - @DisplayName("로그인 상태를 확인한다") - @Test - void isLoggedIn_whenLoggedIn_returnsTrue() { - // given - FcmToken fcmToken = createFcmToken(); - fcmToken.linkUser(1L); - - // when & then - assertThat(fcmToken.isLoggedIn()).isTrue(); - } - - private FcmToken createFcmToken() { - return FcmToken.register( - FcmTokenRegisterRequest.of( - "fcm-token-123", - DeviceType.ANDROID, - "device-id-123", - "Galaxy S23", - "Android 14", - "1.0.0" - ) - ); - } -} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index ccd7b556..572af4bd 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -5,9 +5,6 @@ spring: username: sa password: - flyway: - enabled: false - jpa: hibernate: ddl-auto: update @@ -19,7 +16,4 @@ spring: logging: level: root: INFO - org.springframework.test.context: DEBUG - -firebase: - enabled: false \ No newline at end of file + org.springframework.test.context: DEBUG \ No newline at end of file